mirror of
https://github.com/KeygraphHQ/shannon.git
synced 2026-09-18 07:52:20 +02:00
merge: integrate Shannon 3.0 with public v2.6.0
- preserve the versioned and non-TTY banners from public main - keep workspace launch classification ahead of shared infrastructure setup - carry the eleven-commit Agentic SAST feature history unchanged - normalize Capella prompt endings to the accepted candidate tree
This commit is contained in:
@@ -1,86 +1,72 @@
|
||||
// Copyright (C) 2025 Keygraph, Inc.
|
||||
// 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.
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// 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 { classifyProviderFailure } from '../services/error-handling.js';
|
||||
import type { ProviderFailure } from '../types/errors.js';
|
||||
import { type ModelSelection, resolveModelSelection } from './models.js';
|
||||
|
||||
/** Intended cost/capability role for a model call. All roles use the run's one selected model. */
|
||||
export type ModelRole = 'small' | 'medium' | 'large';
|
||||
|
||||
/** Credential-preserving model selection and provider-failure classification boundary. */
|
||||
export interface ModelHost {
|
||||
resolve(role: ModelRole): Promise<ModelSelection>;
|
||||
classify(error: unknown, contextWindow?: number): ProviderFailure;
|
||||
}
|
||||
|
||||
export type ModelSelectionResolver = () => Promise<ModelSelection>;
|
||||
|
||||
class ShannonModelHost implements ModelHost {
|
||||
private selection: Promise<ModelSelection> | undefined;
|
||||
|
||||
constructor(private readonly resolver: ModelSelectionResolver) {}
|
||||
|
||||
// Cache only a selection that resolves. The catch clears the slot on rejection so a later
|
||||
// attempt (a retried activity) can resolve again instead of replaying the first failure forever.
|
||||
// The identity guard leaves a newer in-flight selection in place if one already replaced this one.
|
||||
resolve(_role: ModelRole): Promise<ModelSelection> {
|
||||
if (this.selection) return this.selection;
|
||||
|
||||
const selection = Promise.resolve()
|
||||
.then(() => this.resolver())
|
||||
.catch((error: unknown) => {
|
||||
if (this.selection === selection) this.selection = undefined;
|
||||
throw error;
|
||||
});
|
||||
this.selection = selection;
|
||||
return this.selection;
|
||||
}
|
||||
|
||||
classify(error: unknown, contextWindow?: number): ProviderFailure {
|
||||
return classifyProviderFailure(error, contextWindow);
|
||||
}
|
||||
}
|
||||
|
||||
/** Create an isolated host, primarily for callers with an explicit lifecycle or focused verification. */
|
||||
export function createModelHost(resolver: ModelSelectionResolver = resolveModelSelection): ModelHost {
|
||||
return new ShannonModelHost(resolver);
|
||||
}
|
||||
|
||||
/** Process-local model host shared by production model callers. */
|
||||
export const modelHost: ModelHost = createModelHost();
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (C) 2025 Keygraph, Inc.
|
||||
// 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
|
||||
@@ -19,6 +19,13 @@
|
||||
*
|
||||
* Resolution returns a pi `Model` plus the `ModelRuntime` that owns its auth,
|
||||
* built over an in-memory credential store primed from the environment.
|
||||
*
|
||||
* The CLI cannot import this module (it ships as a separate bundle), so
|
||||
* `apps/cli/src/model-spec.ts` mirrors the parse rule and the provider/credential
|
||||
* tables by hand for its own `status` rendering and setup wizard. The two copies
|
||||
* have no shared compile-time link: a provider added or renamed on one side and
|
||||
* not the other does not fail to build, it just makes the CLI's guidance or
|
||||
* guard rails disagree with what the worker actually accepts at runtime.
|
||||
*/
|
||||
|
||||
import { existsSync } from 'node:fs';
|
||||
@@ -30,6 +37,11 @@ import { getAgentDir, ModelRuntime } from '@earendil-works/pi-coding-agent';
|
||||
* Providers Shannon curates with their own credential variables, config sections,
|
||||
* and setup flows. Each is a pi-ai provider id; any other pi provider is still
|
||||
* reachable through the generic credential path below.
|
||||
*
|
||||
* Kept identical to the CLI's own copy of this list (`apps/cli/src/model-spec.ts`),
|
||||
* which the CLI uses to decide whether "only one provider is configured" and to
|
||||
* gate its "Other provider" setup option. A curated provider missing from one
|
||||
* copy is silently treated as generic on that side.
|
||||
*/
|
||||
export const CURATED_PROVIDERS = ['anthropic', 'openai', 'xai', 'amazon-bedrock'] as const;
|
||||
|
||||
@@ -47,6 +59,11 @@ export const GENERIC_API_KEY_ENV = 'SHANNON_AI_API_KEY';
|
||||
* does not invent credential names — these are the variables each provider's own
|
||||
* tooling uses. Bedrock pairs its bearer token with AWS_REGION, which is provider
|
||||
* config rather than a credential.
|
||||
*
|
||||
* Mirrored by the CLI's own table of the same name, used there to decide which
|
||||
* env vars to forward into the worker container. A variable added here without
|
||||
* its CLI counterpart never reaches the container: the worker looks for a
|
||||
* credential the CLI never forwarded, and preflight reports it as absent.
|
||||
*/
|
||||
export const PROVIDER_API_KEY_ENV: Readonly<Record<CuratedProviderId, readonly string[]>> = {
|
||||
anthropic: ['ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN'],
|
||||
@@ -232,10 +249,11 @@ export async function createModelRuntime(providerId: string, apiKey: string | un
|
||||
}
|
||||
|
||||
export interface ModelSelection {
|
||||
model: Model<Api>;
|
||||
modelRuntime: ModelRuntime;
|
||||
modelId: string;
|
||||
providerId: string;
|
||||
readonly model: Model<Api>;
|
||||
readonly modelRuntime: ModelRuntime;
|
||||
readonly modelId: string;
|
||||
readonly providerId: string;
|
||||
readonly credentialSource: 'api-key' | 'pi-auth' | 'ambient';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -324,6 +342,7 @@ export async function resolveModelSelection(): Promise<ModelSelection> {
|
||||
const credentials = resolveProviderCredentials(providerId);
|
||||
const format = resolveGatewayFormat(providerId, credentials.baseUrl);
|
||||
|
||||
const mountedPiAuth = piAuthPresent();
|
||||
const modelRuntime = await createModelRuntime(providerId, credentials.apiKey);
|
||||
|
||||
const model = resolveModel(modelRuntime, providerId, modelId, credentials.baseUrl, format);
|
||||
@@ -333,10 +352,18 @@ export async function resolveModelSelection(): Promise<ModelSelection> {
|
||||
);
|
||||
}
|
||||
|
||||
let credentialSource: ModelSelection['credentialSource'] = 'ambient';
|
||||
if (mountedPiAuth) {
|
||||
credentialSource = 'pi-auth';
|
||||
} else if (credentials.apiKey) {
|
||||
credentialSource = 'api-key';
|
||||
}
|
||||
|
||||
return {
|
||||
model,
|
||||
modelRuntime,
|
||||
modelId,
|
||||
providerId,
|
||||
credentialSource,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (C) 2025 Keygraph, Inc.
|
||||
// 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
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,606 @@
|
||||
// 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 type { AgentMessage } from '@earendil-works/pi-agent-core';
|
||||
import {
|
||||
type AgentSession,
|
||||
type AgentSessionEvent,
|
||||
createAgentSession,
|
||||
DefaultResourceLoader,
|
||||
defineTool,
|
||||
getAgentDir,
|
||||
SessionManager,
|
||||
SettingsManager,
|
||||
type ToolDefinition,
|
||||
} 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';
|
||||
import type { CapellaAgentErrorName as SharedCapellaAgentErrorName } from '../sast/capella/error-contract.js';
|
||||
import { CAPELLA_REPOSITORY_TOOL_NAMES, isCapellaRepositoryTool } from '../sast/capella/tools/repository-tools.js';
|
||||
import type { CapellaUsage } from '../sast/types.js';
|
||||
import type {
|
||||
CapellaAgentExecutor,
|
||||
CapellaAgentRequest,
|
||||
CapellaAgentResponse,
|
||||
CapellaTool,
|
||||
} from './capella-agent-types.js';
|
||||
import { PI_RETRY_SETTINGS } from './retry-settings.js';
|
||||
|
||||
const MAX_ERROR_LENGTH = 2_000;
|
||||
const MAX_TOOLS_PER_SESSION = 32;
|
||||
const MAX_TURNS_PER_SESSION = 1_000;
|
||||
const MAX_TIMEOUT_MS = 24 * 60 * 60 * 1_000;
|
||||
|
||||
// The closed set of stage-specific tools a caller is allowed to hand in alongside the confined
|
||||
// repository tools. Anything not on this list, and not a repository tool, is rejected as unknown
|
||||
// by validateCallerTools below.
|
||||
const CAPELLA_COLLECTOR_TOOL_NAMES = new Set([
|
||||
'report_finding',
|
||||
'record_duplicates',
|
||||
'record_review_verdict',
|
||||
'record_viability',
|
||||
'record_static_confirmation',
|
||||
'record_calibration',
|
||||
]);
|
||||
|
||||
// A Capella stage reasons over a read-only, confined view of the repository; none of these may
|
||||
// ever be offered to it. `bash`/`shell`/`network`/`browser`/`web_search` would give it an escape
|
||||
// hatch out of the confined tool set entirely; `edit`/`write` would let a review agent change the
|
||||
// code it is meant to only analyze; `task` would let it spawn further sessions outside this
|
||||
// executor's bounded turn/timeout accounting; `glob`/`ls`/`todo`/`todo_write` duplicate tools the
|
||||
// stage already gets from the confined factory or has no use for.
|
||||
const FORBIDDEN_TOOL_NAMES = new Set([
|
||||
'bash',
|
||||
'browser',
|
||||
'edit',
|
||||
'glob',
|
||||
'ls',
|
||||
'network',
|
||||
'shell',
|
||||
'task',
|
||||
'todo',
|
||||
'todo_write',
|
||||
'web_search',
|
||||
'write',
|
||||
]);
|
||||
|
||||
export type CapellaAgentErrorName = SharedCapellaAgentErrorName;
|
||||
|
||||
export type CapellaAgentErrorCode =
|
||||
| 'DUPLICATE_RESULT'
|
||||
| 'INVALID_REQUEST'
|
||||
| 'INVALID_RESULT'
|
||||
| 'INVALID_TOOL_SET'
|
||||
| 'MISSING_RESULT'
|
||||
| 'PROVIDER_FAILURE'
|
||||
| 'SESSION_FAILURE'
|
||||
| 'TIMEOUT'
|
||||
| 'TURN_LIMIT'
|
||||
| 'USAGE_LEDGER_FAILURE';
|
||||
|
||||
/** Typed, bounded executor failure suitable for Temporal error-name mapping. */
|
||||
export class CapellaAgentError extends Error {
|
||||
constructor(
|
||||
override readonly name: CapellaAgentErrorName,
|
||||
readonly code: CapellaAgentErrorCode,
|
||||
message: string,
|
||||
readonly retryable: boolean,
|
||||
readonly usage?: CapellaUsage,
|
||||
readonly providerCategory?: ProviderFailureCategory,
|
||||
) {
|
||||
super(message.slice(0, MAX_ERROR_LENGTH));
|
||||
}
|
||||
}
|
||||
|
||||
type TerminationReason = 'cancellation' | 'timeout' | 'turn-limit';
|
||||
|
||||
interface CapturedSubmission {
|
||||
readonly tool: ToolDefinition;
|
||||
readonly getCount: () => number;
|
||||
readonly getInvalid: () => boolean;
|
||||
readonly getValue: () => unknown;
|
||||
}
|
||||
|
||||
interface SessionOutcome {
|
||||
readonly submissionCount: number;
|
||||
readonly submissionValue: unknown;
|
||||
readonly invalidSubmission: boolean;
|
||||
readonly pendingProviderError: unknown;
|
||||
readonly promptError: unknown;
|
||||
readonly usage: CapellaUsage;
|
||||
}
|
||||
|
||||
class CapellaCancellationError extends Error {
|
||||
override readonly name = 'AbortError';
|
||||
|
||||
constructor(
|
||||
readonly usage: CapellaUsage,
|
||||
cause: Error,
|
||||
) {
|
||||
super('Capella session cancelled.', { cause });
|
||||
}
|
||||
}
|
||||
|
||||
function agentError(
|
||||
name: CapellaAgentErrorName,
|
||||
code: CapellaAgentErrorCode,
|
||||
message: string,
|
||||
retryable: boolean,
|
||||
usage?: CapellaUsage,
|
||||
providerCategory?: ProviderFailureCategory,
|
||||
): CapellaAgentError {
|
||||
return new CapellaAgentError(name, code, message, retryable, usage, providerCategory);
|
||||
}
|
||||
|
||||
function assertRequest(request: CapellaAgentRequest<unknown>): void {
|
||||
if (!Number.isInteger(request.maxTurns) || request.maxTurns < 1 || request.maxTurns > MAX_TURNS_PER_SESSION) {
|
||||
throw agentError('InvalidInputError', 'INVALID_REQUEST', 'Capella maxTurns is outside its bounded range.', false);
|
||||
}
|
||||
if (!Number.isInteger(request.timeoutMs) || request.timeoutMs < 1 || request.timeoutMs > MAX_TIMEOUT_MS) {
|
||||
throw agentError('InvalidInputError', 'INVALID_REQUEST', 'Capella timeoutMs is outside its bounded range.', false);
|
||||
}
|
||||
if (!request.cwd || !request.systemPrompt || !request.userPrompt) {
|
||||
throw agentError('InvalidInputError', 'INVALID_REQUEST', 'Capella request is incomplete.', false);
|
||||
}
|
||||
if (request.tools.length > MAX_TOOLS_PER_SESSION) {
|
||||
throw agentError('InvalidInputError', 'INVALID_TOOL_SET', 'Capella tool count exceeds its bounded limit.', false);
|
||||
}
|
||||
}
|
||||
|
||||
// Gate the caller's tool set before a session starts. Repository tools must come from the confined
|
||||
// factory (never a caller-built look-alike), collectors must be known by name, and nothing outside
|
||||
// that closed set is allowed. `submit_result` is executor-owned, so a caller supplying one alongside
|
||||
// an output schema is rejected. Any violation fails the request as invalid input, not a model error.
|
||||
function validateCallerTools(tools: readonly CapellaTool[], hasOutputSchema: boolean): void {
|
||||
const names = new Set<string>();
|
||||
for (const tool of tools) {
|
||||
const name = tool.name;
|
||||
if (!name || names.has(name) || FORBIDDEN_TOOL_NAMES.has(name) || name === 'submit_result') {
|
||||
throw agentError('InvalidInputError', 'INVALID_TOOL_SET', 'Capella tool set contains a forbidden name.', false);
|
||||
}
|
||||
names.add(name);
|
||||
|
||||
if ((CAPELLA_REPOSITORY_TOOL_NAMES as readonly string[]).includes(name)) {
|
||||
if (!isCapellaRepositoryTool(tool)) {
|
||||
throw agentError(
|
||||
'InvalidInputError',
|
||||
'INVALID_TOOL_SET',
|
||||
'Capella repository tools must come from the confined tool factory.',
|
||||
false,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!CAPELLA_COLLECTOR_TOOL_NAMES.has(name)) {
|
||||
throw agentError(
|
||||
'InvalidInputError',
|
||||
'INVALID_TOOL_SET',
|
||||
'Capella tool set contains an unknown collector.',
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (hasOutputSchema && names.has('submit_result')) {
|
||||
throw agentError('InvalidInputError', 'INVALID_TOOL_SET', 'Capella submit_result is executor-owned.', false);
|
||||
}
|
||||
}
|
||||
|
||||
function createCapturedSubmission(schema: TSchema): CapturedSubmission {
|
||||
let count = 0;
|
||||
let invalid = false;
|
||||
let value: unknown;
|
||||
return {
|
||||
tool: defineTool({
|
||||
name: 'submit_result',
|
||||
label: 'Submit result',
|
||||
description: 'Return the final structured result exactly once.',
|
||||
promptSnippet: 'submit_result: return the final structured result exactly once',
|
||||
promptGuidelines: ['Call submit_result exactly once as the final action. Do not print JSON as text.'],
|
||||
parameters: schema,
|
||||
async execute(_toolCallId, parameters) {
|
||||
if (!Value.Check(schema, parameters)) {
|
||||
invalid = true;
|
||||
throw agentError(
|
||||
'AgentExecutionError',
|
||||
'INVALID_RESULT',
|
||||
'Capella submit_result arguments failed schema validation.',
|
||||
true,
|
||||
);
|
||||
}
|
||||
count += 1;
|
||||
if (count === 1) value = parameters;
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: 'Result submitted.' }],
|
||||
details: undefined,
|
||||
terminate: true,
|
||||
};
|
||||
},
|
||||
}),
|
||||
getCount: () => count,
|
||||
getInvalid: () => invalid,
|
||||
getValue: () => value,
|
||||
};
|
||||
}
|
||||
|
||||
function finiteNonNegative(value: number): number {
|
||||
return Number.isFinite(value) ? Math.max(0, value) : 0;
|
||||
}
|
||||
|
||||
function frozenUsage(session: AgentSession, turns: number): CapellaUsage {
|
||||
const stats = session.getSessionStats();
|
||||
return Object.freeze({
|
||||
inputTokens: finiteNonNegative(stats.tokens.input),
|
||||
outputTokens: finiteNonNegative(stats.tokens.output),
|
||||
cacheReadTokens: finiteNonNegative(stats.tokens.cacheRead),
|
||||
cacheWriteTokens: finiteNonNegative(stats.tokens.cacheWrite),
|
||||
costUsd: finiteNonNegative(stats.cost),
|
||||
turns: finiteNonNegative(turns),
|
||||
});
|
||||
}
|
||||
|
||||
function isAbortLike(error: unknown): boolean {
|
||||
return error instanceof Error && (error.name === 'AbortError' || error.name === 'TimeoutError');
|
||||
}
|
||||
|
||||
function isRetryableSetupIo(error: unknown): boolean {
|
||||
const code = (error as NodeJS.ErrnoException | undefined)?.code;
|
||||
return code === 'EAGAIN' || code === 'EBUSY' || code === 'EIO' || code === 'EMFILE' || code === 'ENFILE';
|
||||
}
|
||||
|
||||
function cancellationError(signal: AbortSignal): Error {
|
||||
if (signal.reason instanceof Error) return signal.reason;
|
||||
return new DOMException('Capella session cancelled.', 'AbortError');
|
||||
}
|
||||
|
||||
function raceWithAbort<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
|
||||
if (signal.aborted) return Promise.reject(cancellationError(signal));
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const onAbort = (): void => reject(cancellationError(signal));
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
promise.then(
|
||||
(value) => {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
resolve(value);
|
||||
},
|
||||
(error: unknown) => {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
reject(error);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function classifiedModelFailure(
|
||||
host: ModelHost,
|
||||
error: unknown,
|
||||
code: 'PROVIDER_FAILURE' | 'SESSION_FAILURE',
|
||||
usage?: CapellaUsage,
|
||||
contextWindow?: number,
|
||||
): CapellaAgentError {
|
||||
const failure = host.classify(error, contextWindow);
|
||||
if (isAbortLike(error) || isRetryableSetupIo(error)) {
|
||||
return agentError(
|
||||
'AgentExecutionError',
|
||||
code,
|
||||
'The model session ended because of a retryable local or provider failure.',
|
||||
true,
|
||||
usage,
|
||||
);
|
||||
}
|
||||
return agentError(failure.type, code, failure.message, failure.retryable, usage, failure.category);
|
||||
}
|
||||
|
||||
// Map the raw failure to its true cause, with the termination reason taking priority. When the
|
||||
// session was cancelled or timed out, the caught error is typically the induced abort; surface the
|
||||
// cancellation or timeout identity instead of misreporting it as a provider or session failure.
|
||||
function normalizeRunFailure(
|
||||
error: unknown,
|
||||
termination: TerminationReason | undefined,
|
||||
signal: AbortSignal,
|
||||
host: ModelHost,
|
||||
): Error {
|
||||
if (termination === 'cancellation') {
|
||||
return error instanceof CapellaCancellationError ? error : cancellationError(signal);
|
||||
}
|
||||
if (error instanceof CapellaAgentError) return error;
|
||||
if (termination === 'timeout') {
|
||||
return agentError('AgentExecutionError', 'TIMEOUT', 'Capella session timed out.', true);
|
||||
}
|
||||
if (termination === 'turn-limit') {
|
||||
return agentError(
|
||||
'AgentExecutionError',
|
||||
'TURN_LIMIT',
|
||||
'An agentic SAST step ran out of turns before finishing.',
|
||||
true,
|
||||
);
|
||||
}
|
||||
return classifiedModelFailure(host, error, 'SESSION_FAILURE');
|
||||
}
|
||||
|
||||
class StandaloneCapellaAgentExecutor implements CapellaAgentExecutor {
|
||||
constructor(private readonly host: ModelHost) {}
|
||||
|
||||
async run<T>(request: CapellaAgentRequest<T>): Promise<CapellaAgentResponse<T>> {
|
||||
assertRequest(request as CapellaAgentRequest<unknown>);
|
||||
validateCallerTools(request.tools, request.outputSchema !== undefined);
|
||||
|
||||
const controller = new AbortController();
|
||||
let termination: TerminationReason | undefined;
|
||||
let session: AgentSession | undefined;
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
let timeout: NodeJS.Timeout | undefined;
|
||||
let turnCount = 0;
|
||||
let operationCount = 0;
|
||||
const terminate = (reason: TerminationReason): void => {
|
||||
if (termination !== undefined) return;
|
||||
termination = reason;
|
||||
controller.abort(new DOMException(`Capella session ${reason}.`, 'AbortError'));
|
||||
void session?.abort().catch(() => undefined);
|
||||
};
|
||||
const onCancellation = (): void => terminate('cancellation');
|
||||
|
||||
if (request.signal.aborted) throw cancellationError(request.signal);
|
||||
request.signal.addEventListener('abort', onCancellation, { once: true });
|
||||
timeout = setTimeout(() => terminate('timeout'), request.timeoutMs);
|
||||
|
||||
try {
|
||||
let selection: ModelSelection;
|
||||
try {
|
||||
selection = await raceWithAbort(this.host.resolve(request.role), controller.signal);
|
||||
} catch (error) {
|
||||
if (termination === 'cancellation') throw cancellationError(request.signal);
|
||||
if (termination === 'timeout') {
|
||||
throw agentError('AgentExecutionError', 'TIMEOUT', 'Capella session timed out.', true);
|
||||
}
|
||||
throw classifiedModelFailure(this.host, error, 'PROVIDER_FAILURE');
|
||||
}
|
||||
|
||||
const submit = request.outputSchema ? createCapturedSubmission(request.outputSchema) : undefined;
|
||||
const customTools = [...request.tools, ...(submit ? [submit.tool] : [])];
|
||||
const toolNames = customTools.map((tool) => tool.name);
|
||||
const systemPrompt = submit
|
||||
? `${request.systemPrompt}\n\nYou MUST call submit_result exactly once as your final action. Do not output JSON as text.`
|
||||
: request.systemPrompt;
|
||||
const agentDir = getAgentDir();
|
||||
const settingsManager = SettingsManager.inMemory({
|
||||
retry: PI_RETRY_SETTINGS,
|
||||
compaction: { enabled: true },
|
||||
});
|
||||
|
||||
const resourceLoader = new DefaultResourceLoader({
|
||||
cwd: request.cwd,
|
||||
agentDir,
|
||||
settingsManager,
|
||||
systemPrompt,
|
||||
appendSystemPrompt: [],
|
||||
noExtensions: true,
|
||||
noSkills: true,
|
||||
noPromptTemplates: true,
|
||||
noThemes: true,
|
||||
noContextFiles: true,
|
||||
});
|
||||
await raceWithAbort(resourceLoader.reload(), controller.signal);
|
||||
|
||||
const sessionPromise = createAgentSession({
|
||||
cwd: request.cwd,
|
||||
agentDir,
|
||||
model: selection.model,
|
||||
modelRuntime: selection.modelRuntime,
|
||||
noTools: 'all',
|
||||
tools: toolNames,
|
||||
customTools,
|
||||
resourceLoader,
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
settingsManager,
|
||||
});
|
||||
try {
|
||||
({ session } = await raceWithAbort(sessionPromise, controller.signal));
|
||||
} catch (error) {
|
||||
void sessionPromise.then(
|
||||
async ({ session: lateSession }) => {
|
||||
await lateSession.abort().catch(() => undefined);
|
||||
try {
|
||||
lateSession.dispose();
|
||||
} catch {
|
||||
// The late session is already aborted; cleanup remains best effort.
|
||||
}
|
||||
},
|
||||
() => undefined,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (controller.signal.aborted) {
|
||||
await session.abort().catch(() => undefined);
|
||||
} else {
|
||||
controller.signal.addEventListener('abort', () => void session?.abort().catch(() => undefined), {
|
||||
once: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Re-check the live session's tools against the intended set. If pi registered anything extra
|
||||
// or dropped one, tool isolation broke, so fail closed before the model runs.
|
||||
const configuredToolNames = session
|
||||
.getAllTools()
|
||||
.map((tool) => tool.name)
|
||||
.sort();
|
||||
if (configuredToolNames.join('\0') !== [...toolNames].sort().join('\0')) {
|
||||
throw agentError(
|
||||
'ConfigurationError',
|
||||
'INVALID_TOOL_SET',
|
||||
'An agentic SAST step could not start with the tools it needs.',
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
turnCount += 1;
|
||||
const message: AgentMessage = event.message;
|
||||
if (message.role === 'assistant' && message.stopReason === 'error') {
|
||||
pendingProviderError ??= message;
|
||||
}
|
||||
const needsAnotherTurn = message.role === 'assistant' && message.stopReason === 'toolUse';
|
||||
if (turnCount >= request.maxTurns && needsAnotherTurn && (submit?.getCount() ?? 0) === 0) {
|
||||
terminate('turn-limit');
|
||||
}
|
||||
});
|
||||
|
||||
const runStartedAt = Date.now();
|
||||
let promptError: unknown;
|
||||
try {
|
||||
await raceWithAbort(session.prompt(request.userPrompt, { expandPromptTemplates: false }), controller.signal);
|
||||
} catch (error) {
|
||||
promptError = error;
|
||||
}
|
||||
|
||||
const outcome: SessionOutcome = {
|
||||
submissionCount: submit?.getCount() ?? 0,
|
||||
submissionValue: submit?.getValue(),
|
||||
invalidSubmission: invalidSubmission || (submit?.getInvalid() ?? false),
|
||||
pendingProviderError,
|
||||
promptError,
|
||||
usage: frozenUsage(session, turnCount),
|
||||
};
|
||||
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);
|
||||
throw surfacedError;
|
||||
} finally {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
request.signal.removeEventListener('abort', onCancellation);
|
||||
try {
|
||||
unsubscribe?.();
|
||||
} catch {
|
||||
// Subscription cleanup is best effort after the session has ended.
|
||||
}
|
||||
try {
|
||||
session?.dispose();
|
||||
} catch {
|
||||
// Session cleanup is best effort after abort or completion.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private resolveOutcome<T>(
|
||||
request: CapellaAgentRequest<T>,
|
||||
outcome: SessionOutcome,
|
||||
termination: TerminationReason | undefined,
|
||||
contextWindow?: number,
|
||||
): T {
|
||||
if (termination === 'cancellation') {
|
||||
throw new CapellaCancellationError(outcome.usage, cancellationError(request.signal));
|
||||
}
|
||||
if (termination === 'timeout') {
|
||||
throw agentError('AgentExecutionError', 'TIMEOUT', 'Capella session timed out.', true, outcome.usage);
|
||||
}
|
||||
if (termination === 'turn-limit') {
|
||||
throw agentError(
|
||||
'AgentExecutionError',
|
||||
'TURN_LIMIT',
|
||||
'An agentic SAST step ran out of turns before finishing.',
|
||||
true,
|
||||
outcome.usage,
|
||||
);
|
||||
}
|
||||
if (outcome.invalidSubmission && outcome.submissionCount === 0) {
|
||||
throw agentError(
|
||||
'AgentExecutionError',
|
||||
'INVALID_RESULT',
|
||||
'Capella submit_result arguments failed schema validation.',
|
||||
true,
|
||||
outcome.usage,
|
||||
);
|
||||
}
|
||||
if (outcome.submissionCount > 1) {
|
||||
throw agentError(
|
||||
'AgentExecutionError',
|
||||
'DUPLICATE_RESULT',
|
||||
'An agentic SAST step returned its result twice.',
|
||||
true,
|
||||
outcome.usage,
|
||||
);
|
||||
}
|
||||
if (outcome.pendingProviderError !== undefined) {
|
||||
const failure = this.host.classify(outcome.pendingProviderError, contextWindow);
|
||||
throw agentError(
|
||||
failure.type,
|
||||
'PROVIDER_FAILURE',
|
||||
failure.message,
|
||||
failure.retryable,
|
||||
outcome.usage,
|
||||
failure.category,
|
||||
);
|
||||
}
|
||||
// An abort after exactly one accepted submission is the normal end of a good run: the submit tool
|
||||
// terminates the session. Treat it as success; any other prompt error is a real session failure.
|
||||
if (outcome.promptError !== undefined && !(outcome.submissionCount === 1 && isAbortLike(outcome.promptError))) {
|
||||
throw classifiedModelFailure(this.host, outcome.promptError, 'SESSION_FAILURE', outcome.usage, contextWindow);
|
||||
}
|
||||
if (request.outputSchema !== undefined) {
|
||||
if (outcome.submissionCount !== 1 || outcome.submissionValue === undefined) {
|
||||
throw agentError(
|
||||
'AgentExecutionError',
|
||||
'MISSING_RESULT',
|
||||
'Capella session ended without one structured result.',
|
||||
true,
|
||||
outcome.usage,
|
||||
);
|
||||
}
|
||||
return outcome.submissionValue as T;
|
||||
}
|
||||
return undefined as T;
|
||||
}
|
||||
}
|
||||
|
||||
/** Create a Capella executor over the process-local credential-preserving model host. */
|
||||
export function createCapellaAgentExecutor(host: ModelHost = modelHost): CapellaAgentExecutor {
|
||||
return new StandaloneCapellaAgentExecutor(host);
|
||||
}
|
||||
|
||||
/** Process-local standalone Capella executor. */
|
||||
export const capellaAgentExecutor: CapellaAgentExecutor = createCapellaAgentExecutor();
|
||||
@@ -0,0 +1,66 @@
|
||||
// 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 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;
|
||||
readonly role: ModelRole;
|
||||
readonly cwd: string;
|
||||
readonly systemPrompt: string;
|
||||
readonly userPrompt: string;
|
||||
readonly maxTurns: number;
|
||||
readonly timeoutMs: number;
|
||||
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. */
|
||||
export interface CapellaAgentResponse<T> {
|
||||
readonly output: T;
|
||||
readonly usage: CapellaUsage;
|
||||
}
|
||||
|
||||
/** Standalone executor boundary consumed by the Capella stage implementation. */
|
||||
export interface CapellaAgentExecutor {
|
||||
run<T>(request: CapellaAgentRequest<T>): Promise<CapellaAgentResponse<T>>;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (C) 2025 Keygraph, Inc.
|
||||
// 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
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
// Copyright (C) 2025 Keygraph, Inc.
|
||||
// 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.
|
||||
|
||||
// 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);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (C) 2025 Keygraph, Inc.
|
||||
// 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
|
||||
@@ -18,11 +18,11 @@
|
||||
*
|
||||
* NOTE: pi recommends keeping this at 0, since SDK-level retries consume
|
||||
* out-of-usage-limit responses before pi's classifier can mark them terminal.
|
||||
* Shannon accepts that trade for the transport-fault coverage. `maxRetryDelayMs`
|
||||
* is left at pi's 60s default so a server asking for a longer wait fails fast
|
||||
* instead of parking the activity.
|
||||
* Shannon accepts that trade for the transport-fault coverage. A two-minute
|
||||
* delay cap lets short server-directed recovery remain in the current session;
|
||||
* longer delays return to Temporal's bounded activity retry policy.
|
||||
*/
|
||||
export const PI_RETRY_SETTINGS = {
|
||||
enabled: false,
|
||||
provider: { maxRetries: 8 },
|
||||
provider: { maxRetries: 8, maxRetryDelayMs: 120_000 },
|
||||
} as const;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (C) 2025 Keygraph, Inc.
|
||||
// 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
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
// 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.
|
||||
|
||||
/** Attempt-local working-tree copy used by the task-formation model boundary. */
|
||||
|
||||
import type { Dirent, Stats } from 'node:fs';
|
||||
import { cp, lstat, mkdir, mkdtemp, readdir, realpath, rm } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { ArtifactIntegrityError, ReconciliationIoError } from '../reconciliation/artifact-store.js';
|
||||
|
||||
const JAIL_PREFIX = 'shannon-task-formation-';
|
||||
// Never copied into the model-readable jail: `.git` carries deliverables history, `.shannon` holds
|
||||
// scan internals, and `.pi` holds provider credentials. Any of these reaching the jail would expose
|
||||
// them to the tools the model drives. The post-copy verification re-checks their absence by name.
|
||||
const ALWAYS_EXCLUDED_NAMES = Object.freeze(['.git', '.shannon', '.pi'] as const);
|
||||
|
||||
export interface SourceJailOptions {
|
||||
readonly sourceRoot: string;
|
||||
readonly deliverablesPath: string;
|
||||
readonly reconciliationWorkspacePath: string;
|
||||
readonly signal?: AbortSignal;
|
||||
/** Test-only filesystem selector. Production uses `os.tmpdir()`. */
|
||||
readonly tempRoot?: string;
|
||||
}
|
||||
|
||||
/** One source-only jail plus the immutable deny rules used by its live tool gate. */
|
||||
export interface SourceJail {
|
||||
readonly dir: string;
|
||||
readonly deniedPaths: readonly string[];
|
||||
cleanup(): Promise<void>;
|
||||
}
|
||||
|
||||
function isErrno(error: unknown, code: string): boolean {
|
||||
return error instanceof Error && (error as NodeJS.ErrnoException).code === code;
|
||||
}
|
||||
|
||||
function cancellationError(signal: AbortSignal): Error {
|
||||
if (signal.reason instanceof Error) return signal.reason;
|
||||
return new DOMException('Task formation was cancelled.', 'AbortError');
|
||||
}
|
||||
|
||||
function checkCancellation(signal: AbortSignal | undefined): void {
|
||||
if (signal?.aborted === true) throw cancellationError(signal);
|
||||
}
|
||||
|
||||
// Path-confinement predicate: true only when `candidate` is `root` itself or lies beneath it.
|
||||
// A relative path that escapes upward (`..`) or is absolute means the candidate is outside the root.
|
||||
function isWithin(root: string, candidate: string): boolean {
|
||||
const relativePath = path.relative(root, candidate);
|
||||
return (
|
||||
relativePath === '' ||
|
||||
(!relativePath.startsWith(`..${path.sep}`) && relativePath !== '..' && !path.isAbsolute(relativePath))
|
||||
);
|
||||
}
|
||||
|
||||
async function relativeExclusion(
|
||||
sourceRoot: string,
|
||||
lexicalSourceRoot: string,
|
||||
candidate: string,
|
||||
): Promise<string | undefined> {
|
||||
const resolved = path.resolve(candidate);
|
||||
let relativePath: string | undefined;
|
||||
if (isWithin(sourceRoot, resolved)) {
|
||||
relativePath = path.relative(sourceRoot, resolved);
|
||||
} else if (isWithin(lexicalSourceRoot, resolved)) {
|
||||
relativePath = path.relative(lexicalSourceRoot, resolved);
|
||||
} else {
|
||||
try {
|
||||
const canonicalCandidate = await realpath(resolved);
|
||||
if (isWithin(sourceRoot, canonicalCandidate)) {
|
||||
relativePath = path.relative(sourceRoot, canonicalCandidate);
|
||||
}
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
if (relativePath === undefined) return undefined;
|
||||
|
||||
if (relativePath === '') {
|
||||
// An exclusion that resolves to the whole root would empty the jail. Fail closed rather than
|
||||
// copy nothing and hand the model an empty tree.
|
||||
throw new ArtifactIntegrityError('A task-formation exclusion resolves to the complete source root');
|
||||
}
|
||||
return relativePath;
|
||||
}
|
||||
|
||||
async function buildDynamicExclusions(
|
||||
options: SourceJailOptions,
|
||||
sourceRoot: string,
|
||||
lexicalSourceRoot: string,
|
||||
): Promise<readonly string[]> {
|
||||
const exclusions = (
|
||||
await Promise.all([
|
||||
relativeExclusion(sourceRoot, lexicalSourceRoot, options.deliverablesPath),
|
||||
relativeExclusion(sourceRoot, lexicalSourceRoot, options.reconciliationWorkspacePath),
|
||||
])
|
||||
).filter((value): value is string => value !== undefined);
|
||||
return Object.freeze([...new Set(exclusions)]);
|
||||
}
|
||||
|
||||
function pathHasAlwaysExcludedName(relativePath: string): boolean {
|
||||
const segments = relativePath.split(path.sep);
|
||||
return segments.some((segment) => (ALWAYS_EXCLUDED_NAMES as readonly string[]).includes(segment));
|
||||
}
|
||||
|
||||
function pathIsDynamicallyExcluded(relativePath: string, exclusions: readonly string[]): boolean {
|
||||
return exclusions.some((excluded) => relativePath === excluded || relativePath.startsWith(`${excluded}${path.sep}`));
|
||||
}
|
||||
|
||||
async function copySourceTree(
|
||||
sourceRoot: string,
|
||||
destination: string,
|
||||
dynamicExclusions: readonly string[],
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<void> {
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = (await readdir(sourceRoot, { withFileTypes: true })).sort((left, right) =>
|
||||
left.name.localeCompare(right.name),
|
||||
);
|
||||
} catch {
|
||||
throw new ReconciliationIoError('Unable to enumerate the task-formation source tree');
|
||||
}
|
||||
|
||||
// Cancellation is checked before every top-level entry and inside the copy filter so an aborted
|
||||
// scan stops promptly instead of copying a whole large tree first.
|
||||
for (const entry of entries) {
|
||||
checkCancellation(signal);
|
||||
const source = path.join(sourceRoot, entry.name);
|
||||
const destinationEntry = path.join(destination, entry.name);
|
||||
try {
|
||||
// verbatimSymlinks copies links as links rather than following them, so a link pointing
|
||||
// outside the tree cannot pull external content in; the filter then drops any path that
|
||||
// resolves outside the root, plus the always- and dynamically-excluded paths.
|
||||
await cp(source, destinationEntry, {
|
||||
recursive: true,
|
||||
verbatimSymlinks: true,
|
||||
errorOnExist: true,
|
||||
force: false,
|
||||
async filter(candidate) {
|
||||
checkCancellation(signal);
|
||||
const relativePath = path.relative(sourceRoot, candidate);
|
||||
if (relativePath === '' || !isWithin(sourceRoot, path.resolve(candidate))) return false;
|
||||
if (pathHasAlwaysExcludedName(relativePath)) return false;
|
||||
return !pathIsDynamicallyExcluded(relativePath, dynamicExclusions);
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (signal?.aborted === true) throw cancellationError(signal);
|
||||
if (error instanceof ArtifactIntegrityError) throw error;
|
||||
throw new ReconciliationIoError('Unable to copy the task-formation source tree');
|
||||
}
|
||||
}
|
||||
checkCancellation(signal);
|
||||
}
|
||||
|
||||
async function assertAlwaysExcludedNamesAbsent(directory: string, signal: AbortSignal | undefined): Promise<void> {
|
||||
checkCancellation(signal);
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = await readdir(directory, { withFileTypes: true });
|
||||
} catch {
|
||||
throw new ReconciliationIoError('Unable to verify the task-formation source jail');
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
checkCancellation(signal);
|
||||
if ((ALWAYS_EXCLUDED_NAMES as readonly string[]).includes(entry.name)) {
|
||||
throw new ArtifactIntegrityError('The task-formation source jail contains an excluded entry');
|
||||
}
|
||||
if (entry.isDirectory() && !entry.isSymbolicLink()) {
|
||||
await assertAlwaysExcludedNamesAbsent(path.join(directory, entry.name), signal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function assertDynamicExclusionsAbsent(
|
||||
directory: string,
|
||||
exclusions: readonly string[],
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<void> {
|
||||
for (const excluded of exclusions) {
|
||||
checkCancellation(signal);
|
||||
try {
|
||||
await lstat(path.join(directory, excluded));
|
||||
} catch (error) {
|
||||
if (isErrno(error, 'ENOENT')) continue;
|
||||
throw new ReconciliationIoError('Unable to verify a task-formation jail exclusion');
|
||||
}
|
||||
throw new ArtifactIntegrityError('The task-formation source jail contains a protected workspace entry');
|
||||
}
|
||||
}
|
||||
|
||||
// Re-verify the copied tree independently of the copy filter: the jail root must be a real
|
||||
// directory (not a symlink), and no excluded name or protected workspace path may survive. This
|
||||
// catches a filter gap or a race during the copy before the model is allowed to read the tree.
|
||||
async function verifyJail(
|
||||
directory: string,
|
||||
dynamicExclusions: readonly string[],
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<void> {
|
||||
checkCancellation(signal);
|
||||
let stats: Stats;
|
||||
try {
|
||||
stats = await lstat(directory);
|
||||
} catch {
|
||||
throw new ReconciliationIoError('Unable to inspect the task-formation source jail');
|
||||
}
|
||||
if (stats.isSymbolicLink() || !stats.isDirectory()) {
|
||||
throw new ArtifactIntegrityError('The task-formation source jail is not a real directory');
|
||||
}
|
||||
await assertAlwaysExcludedNamesAbsent(directory, signal);
|
||||
await assertDynamicExclusionsAbsent(directory, dynamicExclusions, signal);
|
||||
checkCancellation(signal);
|
||||
}
|
||||
|
||||
async function removeJail(directory: string): Promise<void> {
|
||||
try {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
} catch {
|
||||
throw new ReconciliationIoError('Unable to remove the task-formation source jail');
|
||||
}
|
||||
|
||||
try {
|
||||
await lstat(directory);
|
||||
} catch (error) {
|
||||
if (isErrno(error, 'ENOENT')) return;
|
||||
throw new ReconciliationIoError('Unable to verify task-formation source-jail cleanup');
|
||||
}
|
||||
throw new ReconciliationIoError('Task-formation source-jail cleanup left the jail on disk');
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy the scanned working tree into an isolated temporary directory without following symlinks.
|
||||
* Every failure removes the attempt-local directory before it propagates.
|
||||
*/
|
||||
export async function materializeSourceJail(options: SourceJailOptions): Promise<SourceJail> {
|
||||
checkCancellation(options.signal);
|
||||
|
||||
const lexicalSourceRoot = path.resolve(options.sourceRoot);
|
||||
let sourceRoot: string;
|
||||
try {
|
||||
sourceRoot = await realpath(options.sourceRoot);
|
||||
const sourceStats = await lstat(sourceRoot);
|
||||
if (sourceStats.isSymbolicLink() || !sourceStats.isDirectory()) {
|
||||
throw new ArtifactIntegrityError('The task-formation source root is not a real directory');
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ArtifactIntegrityError) throw error;
|
||||
throw new ReconciliationIoError('Unable to resolve the task-formation source root');
|
||||
}
|
||||
|
||||
let tempRoot: string;
|
||||
try {
|
||||
const configuredTempRoot = options.tempRoot ?? os.tmpdir();
|
||||
await mkdir(configuredTempRoot, { recursive: true });
|
||||
tempRoot = await realpath(configuredTempRoot);
|
||||
} catch {
|
||||
throw new ReconciliationIoError('Unable to resolve the task-formation temporary root');
|
||||
}
|
||||
// A temp root inside the source tree would make the copy try to copy the jail into itself.
|
||||
if (isWithin(sourceRoot, tempRoot)) {
|
||||
throw new ArtifactIntegrityError('The task-formation temporary root cannot be inside the source tree');
|
||||
}
|
||||
|
||||
const dynamicExclusions = await buildDynamicExclusions(options, sourceRoot, lexicalSourceRoot);
|
||||
let directory: string;
|
||||
try {
|
||||
directory = await mkdtemp(path.join(tempRoot, JAIL_PREFIX));
|
||||
} catch {
|
||||
throw new ReconciliationIoError('Unable to create the task-formation source jail');
|
||||
}
|
||||
|
||||
let cleaned = false;
|
||||
const cleanup = async (): Promise<void> => {
|
||||
if (cleaned) return;
|
||||
await removeJail(directory);
|
||||
cleaned = true;
|
||||
};
|
||||
|
||||
try {
|
||||
await copySourceTree(sourceRoot, directory, dynamicExclusions, options.signal);
|
||||
await verifyJail(directory, dynamicExclusions, options.signal);
|
||||
} catch (error) {
|
||||
await cleanup().catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const deniedPaths = Object.freeze([...ALWAYS_EXCLUDED_NAMES, ...dynamicExclusions]);
|
||||
return Object.freeze({ dir: directory, deniedPaths, cleanup });
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
// 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 type { AssistantMessage, Context, ToolCall } from '@earendil-works/pi-ai';
|
||||
import { Value } from 'typebox/value';
|
||||
import { providerFailureSentence } from '../../services/error-handling.js';
|
||||
import { type ModelHost, modelHost } from '../model-host.js';
|
||||
import type {
|
||||
StructuredGenerationPort,
|
||||
StructuredGenerationRequest,
|
||||
StructuredGenerationResult,
|
||||
} from '../structured-generation.js';
|
||||
import { type CapturedSubmitTool, createGenericSubmitTool } from '../submit-tool.js';
|
||||
|
||||
const ZERO_USAGE = { inputTokens: 0, outputTokens: 0, costUsd: 0 } as const;
|
||||
|
||||
// True only when this caller's own signal aborted and the error traces back to it. Walk a bounded,
|
||||
// cycle-guarded cause chain so a cancellation wrapped several layers deep is still recognized as a
|
||||
// cancellation and not misreported as a provider error. Without the `signal.aborted` gate an
|
||||
// unrelated AbortError from the provider could be mistaken for our cancellation.
|
||||
function isSignalCancellation(error: unknown, signal: AbortSignal | undefined): boolean {
|
||||
if (signal?.aborted !== true) return false;
|
||||
|
||||
let current: unknown = error;
|
||||
const seen = new Set<unknown>();
|
||||
for (let depth = 0; depth < 8 && current !== undefined && current !== null && !seen.has(current); depth++) {
|
||||
if (current === signal.reason) return true;
|
||||
seen.add(current);
|
||||
const errorName = current instanceof Error ? current.name : undefined;
|
||||
if (errorName === 'AbortError' || errorName === 'CancelledFailure') return true;
|
||||
current = current instanceof Error ? current.cause : undefined;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function responseUsage(response: AssistantMessage): StructuredGenerationResult['usage'] {
|
||||
return {
|
||||
inputTokens: response.usage.input,
|
||||
outputTokens: response.usage.output,
|
||||
costUsd: response.usage.cost.total,
|
||||
};
|
||||
}
|
||||
|
||||
type SubmitExecutor = (toolCallId: string, parameters: Record<string, unknown>) => Promise<unknown>;
|
||||
|
||||
async function captureSingleValidSubmission(
|
||||
toolCalls: readonly ToolCall[],
|
||||
submitTool: CapturedSubmitTool,
|
||||
): Promise<Array<{ name: string; arguments: unknown }>> {
|
||||
const returnedCalls = toolCalls.map((call) => ({ name: call.name, arguments: call.arguments }));
|
||||
const call = toolCalls.length === 1 ? toolCalls[0] : undefined;
|
||||
if (call?.name !== submitTool.tool.name) return returnedCalls;
|
||||
|
||||
if (!Value.Check(submitTool.tool.parameters, call.arguments)) return returnedCalls;
|
||||
|
||||
// completeSimple returns tool calls but does not execute them. Invoke the captured
|
||||
// definition only after its TypeBox validator accepts the sole submission.
|
||||
const execute = submitTool.tool.execute as unknown as SubmitExecutor;
|
||||
await execute(call.id, call.arguments);
|
||||
const captured = submitTool.getCaptured();
|
||||
return [{ name: call.name, arguments: captured }];
|
||||
}
|
||||
|
||||
async function generate(host: ModelHost, request: StructuredGenerationRequest): Promise<StructuredGenerationResult> {
|
||||
const submitTool = createGenericSubmitTool(request.tool.parametersJsonSchema);
|
||||
const context: Context = {
|
||||
...(request.systemPrompt !== undefined && { systemPrompt: request.systemPrompt }),
|
||||
messages: [{ role: 'user', content: request.userContent, timestamp: Date.now() }],
|
||||
tools: [
|
||||
{
|
||||
name: submitTool.tool.name,
|
||||
description: request.tool.description,
|
||||
parameters: submitTool.tool.parameters,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let response: AssistantMessage;
|
||||
try {
|
||||
const selection = await host.resolve('small');
|
||||
// One enrichment batch is one billable provider request. Temporal owns any
|
||||
// retry after this boundary, so provider-level retries stay disabled here.
|
||||
response = await selection.modelRuntime.completeSimple(selection.model, context, {
|
||||
maxTokens: request.maxTokens,
|
||||
maxRetries: 0,
|
||||
...(request.signal !== undefined && { signal: request.signal }),
|
||||
});
|
||||
} catch (error) {
|
||||
if (isSignalCancellation(error, request.signal)) {
|
||||
return { stopReason: 'aborted', toolCalls: [], usage: ZERO_USAGE };
|
||||
}
|
||||
const failure = host.classify(error);
|
||||
return {
|
||||
stopReason: 'error',
|
||||
toolCalls: [],
|
||||
usage: ZERO_USAGE,
|
||||
errorMessage: providerFailureSentence(failure),
|
||||
providerFailure: { type: failure.type, retryable: failure.retryable },
|
||||
};
|
||||
}
|
||||
|
||||
if (response.stopReason === 'error') {
|
||||
const failure = host.classify(response);
|
||||
return {
|
||||
stopReason: 'error',
|
||||
toolCalls: [],
|
||||
usage: responseUsage(response),
|
||||
errorMessage: providerFailureSentence(failure),
|
||||
providerFailure: { type: failure.type, retryable: failure.retryable },
|
||||
};
|
||||
}
|
||||
if (response.stopReason === 'aborted') {
|
||||
// An abort with our signal set is a real cancellation. An abort without it is a provider-side
|
||||
// stop we did not ask for, so classify it as an error the caller can retry on.
|
||||
if (request.signal?.aborted === true) {
|
||||
return { stopReason: 'aborted', toolCalls: [], usage: responseUsage(response) };
|
||||
}
|
||||
const failure = host.classify(response);
|
||||
return {
|
||||
stopReason: 'error',
|
||||
toolCalls: [],
|
||||
usage: responseUsage(response),
|
||||
errorMessage: providerFailureSentence(failure),
|
||||
providerFailure: { type: failure.type, retryable: failure.retryable },
|
||||
};
|
||||
}
|
||||
|
||||
const toolCalls = response.content.filter((block): block is ToolCall => block.type === 'toolCall');
|
||||
const capturedCalls = await captureSingleValidSubmission(toolCalls, submitTool);
|
||||
return {
|
||||
stopReason: response.stopReason,
|
||||
toolCalls: capturedCalls,
|
||||
usage: responseUsage(response),
|
||||
};
|
||||
}
|
||||
|
||||
/** Build the one-request Pi adapter used by SAST enrichment. */
|
||||
export function createPiStructuredGenerationPort(host: ModelHost = modelHost): StructuredGenerationPort<void> {
|
||||
return {
|
||||
generate(request: StructuredGenerationRequest): Promise<StructuredGenerationResult> {
|
||||
return generate(host, request);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,787 @@
|
||||
// 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.
|
||||
|
||||
/** Dedicated read-only Pi executor and live tool policy for Pass 1 task formation. */
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
import type { AgentMessage } from '@earendil-works/pi-agent-core';
|
||||
import {
|
||||
type AgentSession,
|
||||
type AgentSessionEvent,
|
||||
createAgentSession,
|
||||
DefaultResourceLoader,
|
||||
defineTool,
|
||||
getAgentDir,
|
||||
SessionManager,
|
||||
SettingsManager,
|
||||
type ToolDefinition,
|
||||
} from '@earendil-works/pi-coding-agent';
|
||||
import { Type } from 'typebox';
|
||||
import { providerFailureSentence } from '../../services/error-handling.js';
|
||||
import type { ProviderFailure } from '../../types/errors.js';
|
||||
import { type ModelHost, modelHost } from '../model-host.js';
|
||||
import type { ModelSelection } from '../models.js';
|
||||
import type { ValidatingSubmitTool } from '../reconciliation/submit-validation.js';
|
||||
import { ConfinementError, compileRepositoryGlob, RepositoryConfinement } from '../sast/capella/tools/confinement.js';
|
||||
import { createCapellaRepositoryTools } from '../sast/capella/tools/repository-tools.js';
|
||||
import { PI_RETRY_SETTINGS } from './retry-settings.js';
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 30 * 60 * 1_000;
|
||||
const DEFAULT_MAX_TURNS = 64;
|
||||
const MAX_TIMEOUT_MS = 30 * 60 * 1_000;
|
||||
const MAX_TURNS = 128;
|
||||
const MAX_LIST_RESULTS = 500;
|
||||
const DEFAULT_LIST_RESULTS = 200;
|
||||
const MAX_OUTPUT_BYTES = 64 * 1024;
|
||||
// The live-tool-side counterpart of the source jail's copy-time exclusion (source-jail.ts): even if
|
||||
// one of these somehow existed in the jailed tree, the read/grep/find/ls/glob tools built below must
|
||||
// still refuse to serve it. `.git` is deliverables history, `.shannon` is scan internals, `.pi` is
|
||||
// provider credentials.
|
||||
const ALWAYS_DENIED_PATHS = Object.freeze(['.git', '.shannon', '.pi'] as const);
|
||||
const TRANSIENT_IO_CODES = new Set([
|
||||
'EAGAIN',
|
||||
'EBUSY',
|
||||
'ECONNREFUSED',
|
||||
'ECONNRESET',
|
||||
'EIO',
|
||||
'EMFILE',
|
||||
'ENFILE',
|
||||
'ENOMEM',
|
||||
'ENOSPC',
|
||||
'EPIPE',
|
||||
'EROFS',
|
||||
'ETIMEDOUT',
|
||||
]);
|
||||
|
||||
export const TASK_FORMATION_TOOL_NAMES = Object.freeze([
|
||||
'read',
|
||||
'grep',
|
||||
'find',
|
||||
'ls',
|
||||
'glob',
|
||||
'submit_result',
|
||||
] as const);
|
||||
|
||||
// The closed set of failure reasons the integration layer accepts as grounds to fall back to a
|
||||
// single-agent formation. Only a failure carrying one of these becomes a fallback; any other
|
||||
// failure propagates. Keep this in sync with the reasons the Temporal caller recognizes.
|
||||
export const TASK_FORMATION_FALLBACK_REASONS = Object.freeze([
|
||||
'retryable_model_failure',
|
||||
'missing_accepted_submission',
|
||||
'model_stage_timeout',
|
||||
] as const);
|
||||
|
||||
export type TaskFormationFallbackReason = (typeof TASK_FORMATION_FALLBACK_REASONS)[number];
|
||||
|
||||
export type TaskFormationExecutorFailureKind = 'model' | 'input' | 'confinement' | 'infrastructure';
|
||||
|
||||
/** Safe, bounded fields supplied by the activity wrapper for per-attempt executor correlation. */
|
||||
export interface TaskFormationExecutionContext {
|
||||
readonly executionKey?: string;
|
||||
readonly attempt?: number;
|
||||
readonly stage?: string;
|
||||
readonly vulnerabilityClass?: string;
|
||||
}
|
||||
|
||||
export interface TaskFormationUsage {
|
||||
readonly costUsd: number;
|
||||
readonly inputTokens: number;
|
||||
readonly outputTokens: number;
|
||||
}
|
||||
|
||||
export class TaskFormationExecutorError extends Error {
|
||||
override readonly name = 'TaskFormationExecutorError';
|
||||
readonly code: string;
|
||||
readonly retryable: boolean;
|
||||
readonly failureKind: TaskFormationExecutorFailureKind;
|
||||
readonly fallbackReason: TaskFormationFallbackReason | undefined;
|
||||
readonly usage: TaskFormationUsage;
|
||||
readonly modelCalls: number;
|
||||
|
||||
constructor(options: {
|
||||
code: string;
|
||||
message: string;
|
||||
retryable: boolean;
|
||||
failureKind: TaskFormationExecutorFailureKind;
|
||||
fallbackReason?: TaskFormationFallbackReason;
|
||||
usage?: TaskFormationUsage;
|
||||
modelCalls?: number;
|
||||
}) {
|
||||
super(options.message);
|
||||
this.code = options.code;
|
||||
this.retryable = options.retryable;
|
||||
this.failureKind = options.failureKind;
|
||||
this.fallbackReason = options.fallbackReason;
|
||||
this.usage = options.usage ?? zeroUsage();
|
||||
this.modelCalls = options.modelCalls ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
export interface TaskFormationExecutorRequest {
|
||||
readonly cwd: string;
|
||||
readonly systemPrompt: string;
|
||||
readonly modelContext: string;
|
||||
readonly deniedPaths: readonly string[];
|
||||
readonly submitTool: ValidatingSubmitTool;
|
||||
readonly signal: AbortSignal;
|
||||
readonly timeoutMs?: number;
|
||||
readonly maxTurns?: number;
|
||||
readonly correlation?: TaskFormationExecutionContext;
|
||||
}
|
||||
|
||||
export interface TaskFormationExecutorResult {
|
||||
readonly output: unknown;
|
||||
readonly usage: TaskFormationUsage;
|
||||
readonly providerId: string;
|
||||
readonly modelId: string;
|
||||
readonly modelCalls: 1;
|
||||
readonly registeredTools: readonly string[];
|
||||
}
|
||||
|
||||
export interface TaskFormationExecutor {
|
||||
run(request: TaskFormationExecutorRequest): Promise<TaskFormationExecutorResult>;
|
||||
}
|
||||
|
||||
interface SessionOutcome {
|
||||
readonly pendingProviderError: unknown;
|
||||
readonly promptError: unknown;
|
||||
readonly usage: TaskFormationUsage;
|
||||
}
|
||||
|
||||
interface ToolFactoryOptions {
|
||||
readonly cwd: string;
|
||||
readonly deniedPaths: readonly string[];
|
||||
}
|
||||
|
||||
function zeroUsage(): TaskFormationUsage {
|
||||
return { costUsd: 0, inputTokens: 0, outputTokens: 0 };
|
||||
}
|
||||
|
||||
/** Reject unknown values from Temporal failure details instead of widening semantic fallback. */
|
||||
export function isTaskFormationFallbackReason(value: unknown): value is TaskFormationFallbackReason {
|
||||
return (TASK_FORMATION_FALLBACK_REASONS as readonly unknown[]).includes(value);
|
||||
}
|
||||
|
||||
function errorCode(error: unknown): string | undefined {
|
||||
if (typeof error !== 'object' || error === null || !('code' in error)) return undefined;
|
||||
return typeof error.code === 'string' ? error.code : undefined;
|
||||
}
|
||||
|
||||
function isTransientIoFailure(error: unknown): boolean {
|
||||
const code = errorCode(error);
|
||||
if (code !== undefined && TRANSIENT_IO_CODES.has(code)) return true;
|
||||
if (error instanceof Error && error.cause !== undefined) return isTransientIoFailure(error.cause);
|
||||
return false;
|
||||
}
|
||||
|
||||
function safeIdentifier(value: string | undefined): string | undefined {
|
||||
if (value === undefined || !/^[A-Za-z0-9._:-]{1,128}$/u.test(value)) return undefined;
|
||||
return value;
|
||||
}
|
||||
|
||||
// Emit only bounded, format-checked correlation fields. Prompt text, model context, and source
|
||||
// content never enter the log line. An unsafe or missing identifier falls back to a synthetic one
|
||||
// rather than logging the caller's raw value.
|
||||
function executionLogContext(context: TaskFormationExecutionContext | undefined): Readonly<Record<string, unknown>> {
|
||||
const attempt = context?.attempt;
|
||||
return Object.freeze({
|
||||
executionKey: safeIdentifier(context?.executionKey) ?? randomUUID(),
|
||||
attempt: Number.isSafeInteger(attempt) && (attempt ?? 0) > 0 ? attempt : null,
|
||||
stage: safeIdentifier(context?.stage) ?? 'task-formation',
|
||||
class: safeIdentifier(context?.vulnerabilityClass) ?? 'unknown',
|
||||
});
|
||||
}
|
||||
|
||||
function finiteNonNegative(value: number): number {
|
||||
return Number.isFinite(value) ? Math.max(0, value) : 0;
|
||||
}
|
||||
|
||||
function sessionUsage(session: AgentSession): TaskFormationUsage {
|
||||
const stats = session.getSessionStats();
|
||||
return {
|
||||
costUsd: finiteNonNegative(stats.cost),
|
||||
inputTokens: finiteNonNegative(stats.tokens.input),
|
||||
outputTokens: finiteNonNegative(stats.tokens.output),
|
||||
};
|
||||
}
|
||||
|
||||
function boundedText(value: string): string {
|
||||
const bytes = Buffer.from(value, 'utf8');
|
||||
if (bytes.byteLength <= MAX_OUTPUT_BYTES) return value;
|
||||
return bytes.subarray(0, MAX_OUTPUT_BYTES).toString('utf8');
|
||||
}
|
||||
|
||||
function uniqueDeniedPaths(deniedPaths: readonly string[]): readonly string[] {
|
||||
return Object.freeze([...new Set([...ALWAYS_DENIED_PATHS, ...deniedPaths])]);
|
||||
}
|
||||
|
||||
// The session must register exactly the allowlisted tools. This is checked against the built tool
|
||||
// set and again against the live session's registered tools, so an injected or dropped tool fails
|
||||
// the session closed before the model runs.
|
||||
function hasExactToolSet(toolNames: readonly string[]): boolean {
|
||||
const expected = [...TASK_FORMATION_TOOL_NAMES].sort();
|
||||
const actual = [...toolNames].sort();
|
||||
return actual.length === expected.length && actual.every((name, index) => name === expected[index]);
|
||||
}
|
||||
|
||||
function createListTool(confinement: RepositoryConfinement): ToolDefinition {
|
||||
return defineTool({
|
||||
name: 'ls',
|
||||
label: 'List source directory',
|
||||
description: 'List bounded repository-relative entries without following symlinks.',
|
||||
promptSnippet: 'ls: list entries below one source directory',
|
||||
promptGuidelines: ['Use a repository-relative directory. Absolute paths and traversal are rejected.'],
|
||||
parameters: Type.Object(
|
||||
{
|
||||
path: Type.Optional(Type.String({ minLength: 1, maxLength: 1_024 })),
|
||||
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: MAX_LIST_RESULTS })),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
async execute(_toolCallId, parameters, signal) {
|
||||
const requestedPath = parameters.path ?? '.';
|
||||
const budget = confinement.createBudget(signal);
|
||||
const searchRoot = await confinement.resolveExisting(requestedPath, true, budget);
|
||||
const entries = await confinement.enumerate(requestedPath, signal, budget);
|
||||
const names = new Set<string>();
|
||||
for (const entry of entries) {
|
||||
confinement.checkBudget(budget);
|
||||
const relativePath = pathRelative(searchRoot, entry.absolutePath);
|
||||
const [first, ...remaining] = relativePath.split('/');
|
||||
if (first) names.add(remaining.length > 0 ? `${first}/` : first);
|
||||
}
|
||||
|
||||
const limit = parameters.limit ?? DEFAULT_LIST_RESULTS;
|
||||
const output = [...names].sort().slice(0, limit);
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: boundedText(output.join('\n') || 'No entries found.') }],
|
||||
details: { count: output.length, truncated: names.size > output.length },
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function pathRelative(root: string, candidate: string): string {
|
||||
const relativePath = path.relative(root, candidate);
|
||||
if (
|
||||
!relativePath ||
|
||||
relativePath.startsWith(`..${path.sep}`) ||
|
||||
relativePath === '..' ||
|
||||
path.isAbsolute(relativePath)
|
||||
) {
|
||||
throw new TaskFormationExecutorError({
|
||||
code: 'TOOL_PATH_RACE',
|
||||
message: 'Task-formation source path changed during access.',
|
||||
retryable: false,
|
||||
failureKind: 'confinement',
|
||||
});
|
||||
}
|
||||
return relativePath.split(path.sep).join('/');
|
||||
}
|
||||
|
||||
function createGlobTool(confinement: RepositoryConfinement): ToolDefinition {
|
||||
return defineTool({
|
||||
name: 'glob',
|
||||
label: 'Glob source files',
|
||||
description: 'Match bounded file globs from the source-jail root without following symlinks.',
|
||||
promptSnippet: 'glob: match source files from the jail root',
|
||||
promptGuidelines: ['Patterns are always rooted in the source jail.'],
|
||||
parameters: Type.Object(
|
||||
{
|
||||
pattern: Type.String({ minLength: 1, maxLength: 256 }),
|
||||
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: MAX_LIST_RESULTS })),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
async execute(_toolCallId, parameters, signal) {
|
||||
const budget = confinement.createBudget(signal);
|
||||
const matcher = compileRepositoryGlob(parameters.pattern);
|
||||
const entries = await confinement.enumerate('.', signal, budget);
|
||||
const limit = parameters.limit ?? DEFAULT_LIST_RESULTS;
|
||||
const matches: string[] = [];
|
||||
let truncated = false;
|
||||
for (const entry of entries) {
|
||||
confinement.checkBudget(budget);
|
||||
if (!matcher.test(entry.path)) continue;
|
||||
if (matches.length >= limit) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
matches.push(entry.path);
|
||||
}
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: boundedText(matches.join('\n') || 'No files found.') }],
|
||||
details: { count: matches.length, truncated },
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Create the five code-owned source tools that share one canonical jail policy. */
|
||||
export async function createTaskFormationSourceTools(options: ToolFactoryOptions): Promise<readonly ToolDefinition[]> {
|
||||
const deniedPaths = uniqueDeniedPaths(options.deniedPaths);
|
||||
const capellaTools = await createCapellaRepositoryTools({
|
||||
repositoryRoot: options.cwd,
|
||||
deniedPaths,
|
||||
});
|
||||
const confinement = await RepositoryConfinement.create({
|
||||
repositoryRoot: options.cwd,
|
||||
deniedPaths,
|
||||
});
|
||||
const byName = new Map(capellaTools.map((tool) => [tool.name, tool]));
|
||||
const tools = [
|
||||
byName.get('read'),
|
||||
byName.get('grep'),
|
||||
byName.get('find'),
|
||||
createListTool(confinement),
|
||||
createGlobTool(confinement),
|
||||
];
|
||||
if (tools.some((tool) => tool === undefined)) {
|
||||
throw new TaskFormationExecutorError({
|
||||
code: 'TOOL_FACTORY_MISMATCH',
|
||||
message: 'Task-formation source tool factory returned an incomplete set.',
|
||||
retryable: false,
|
||||
failureKind: 'confinement',
|
||||
});
|
||||
}
|
||||
return Object.freeze(tools as ToolDefinition[]);
|
||||
}
|
||||
|
||||
function cancellationError(signal: AbortSignal): Error {
|
||||
if (signal.reason instanceof Error) return signal.reason;
|
||||
return new DOMException('Task formation was cancelled.', 'AbortError');
|
||||
}
|
||||
|
||||
function raceWithAbort<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
|
||||
if (signal.aborted) return Promise.reject(cancellationError(signal));
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const onAbort = (): void => reject(cancellationError(signal));
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
promise.then(
|
||||
(value) => {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
resolve(value);
|
||||
},
|
||||
(error: unknown) => {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
reject(error);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function validateRequest(request: TaskFormationExecutorRequest): { timeoutMs: number; maxTurns: number } {
|
||||
const timeoutMs = request.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
const maxTurns = request.maxTurns ?? DEFAULT_MAX_TURNS;
|
||||
if (!request.cwd || !request.systemPrompt || !request.modelContext) {
|
||||
throw new TaskFormationExecutorError({
|
||||
code: 'INVALID_REQUEST',
|
||||
message: 'Task-formation executor input is incomplete.',
|
||||
retryable: false,
|
||||
failureKind: 'input',
|
||||
});
|
||||
}
|
||||
if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > MAX_TIMEOUT_MS) {
|
||||
throw new TaskFormationExecutorError({
|
||||
code: 'INVALID_TIMEOUT',
|
||||
message: `Task-formation timeout must be a positive integer no greater than ${MAX_TIMEOUT_MS} milliseconds.`,
|
||||
retryable: false,
|
||||
failureKind: 'input',
|
||||
});
|
||||
}
|
||||
if (!Number.isInteger(maxTurns) || maxTurns < 1 || maxTurns > MAX_TURNS) {
|
||||
throw new TaskFormationExecutorError({
|
||||
code: 'INVALID_TURN_LIMIT',
|
||||
message: 'Task-formation turn limit is outside its bounded range.',
|
||||
retryable: false,
|
||||
failureKind: 'input',
|
||||
});
|
||||
}
|
||||
return { timeoutMs, maxTurns };
|
||||
}
|
||||
|
||||
function isAbortLike(error: unknown): boolean {
|
||||
return error instanceof Error && (error.name === 'AbortError' || error.name === 'TimeoutError');
|
||||
}
|
||||
|
||||
function classifyModelFailure(host: ModelHost, error: unknown): ProviderFailure {
|
||||
if (isAbortLike(error)) {
|
||||
return {
|
||||
type: 'AgentExecutionError',
|
||||
category: 'transport',
|
||||
retryable: true,
|
||||
message: 'The provider request ended before task formation completed.',
|
||||
};
|
||||
}
|
||||
return host.classify(error);
|
||||
}
|
||||
|
||||
function executorLog(level: 'info' | 'warn', fields: Readonly<Record<string, unknown>>): void {
|
||||
console[level](JSON.stringify({ component: 'task-formation-executor', ...fields }));
|
||||
}
|
||||
|
||||
class StandaloneTaskFormationExecutor implements TaskFormationExecutor {
|
||||
private readonly host: ModelHost;
|
||||
|
||||
constructor(host: ModelHost) {
|
||||
this.host = host;
|
||||
}
|
||||
|
||||
async run(request: TaskFormationExecutorRequest): Promise<TaskFormationExecutorResult> {
|
||||
const logContext = executionLogContext(request.correlation);
|
||||
let timeoutMs: number;
|
||||
let maxTurns: number;
|
||||
try {
|
||||
({ timeoutMs, maxTurns } = validateRequest(request));
|
||||
} catch (error) {
|
||||
const failure = this.normalizeFailure(error);
|
||||
executorLog('warn', {
|
||||
...logContext,
|
||||
event: 'finished',
|
||||
outcome: 'failed',
|
||||
code: failure.code,
|
||||
failureKind: failure.failureKind,
|
||||
retryable: failure.retryable,
|
||||
});
|
||||
throw failure;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
let termination: 'cancellation' | 'timeout' | 'turn-limit' | undefined;
|
||||
let session: AgentSession | undefined;
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
let timeout: NodeJS.Timeout | undefined;
|
||||
let requestStarted = false;
|
||||
let turnCount = 0;
|
||||
|
||||
const terminate = (reason: 'cancellation' | 'timeout' | 'turn-limit'): void => {
|
||||
if (termination !== undefined) return;
|
||||
termination = reason;
|
||||
controller.abort(new DOMException(`Task-formation session ${reason}.`, 'AbortError'));
|
||||
void session?.abort().catch(() => undefined);
|
||||
};
|
||||
const onCancellation = (): void => terminate('cancellation');
|
||||
|
||||
if (request.signal.aborted) {
|
||||
executorLog('info', { ...logContext, event: 'finished', outcome: 'cancelled' });
|
||||
throw cancellationError(request.signal);
|
||||
}
|
||||
request.signal.addEventListener('abort', onCancellation, { once: true });
|
||||
timeout = setTimeout(() => terminate('timeout'), timeoutMs);
|
||||
|
||||
try {
|
||||
let selection: ModelSelection;
|
||||
try {
|
||||
selection = await raceWithAbort(this.host.resolve('medium'), controller.signal);
|
||||
} catch (error) {
|
||||
if (termination === 'cancellation') throw cancellationError(request.signal);
|
||||
if (termination === 'timeout') throw this.timeoutError(zeroUsage(), 0);
|
||||
const failure = classifyModelFailure(this.host, error);
|
||||
throw new TaskFormationExecutorError({
|
||||
code: 'MODEL_SELECTION_FAILURE',
|
||||
message: providerFailureSentence(failure),
|
||||
retryable: failure.retryable,
|
||||
failureKind: 'model',
|
||||
...(failure.retryable && { fallbackReason: 'retryable_model_failure' }),
|
||||
});
|
||||
}
|
||||
|
||||
const sourceTools = await raceWithAbort(
|
||||
createTaskFormationSourceTools({ cwd: request.cwd, deniedPaths: request.deniedPaths }),
|
||||
controller.signal,
|
||||
);
|
||||
const customTools = [...sourceTools, request.submitTool.tool];
|
||||
const toolNames = customTools.map((tool) => tool.name);
|
||||
if (!hasExactToolSet(toolNames)) {
|
||||
throw new TaskFormationExecutorError({
|
||||
code: 'TOOL_POLICY_MISMATCH',
|
||||
message: 'Task-formation source tool policy does not match the exact allowlist.',
|
||||
retryable: false,
|
||||
failureKind: 'confinement',
|
||||
});
|
||||
}
|
||||
|
||||
const agentDir = getAgentDir();
|
||||
const settingsManager = SettingsManager.inMemory({
|
||||
retry: PI_RETRY_SETTINGS,
|
||||
compaction: { enabled: true },
|
||||
});
|
||||
const resourceLoader = new DefaultResourceLoader({
|
||||
cwd: request.cwd,
|
||||
agentDir,
|
||||
settingsManager,
|
||||
systemPrompt: `${request.systemPrompt}${request.submitTool.directive ?? ''}`,
|
||||
appendSystemPrompt: [],
|
||||
noExtensions: true,
|
||||
noSkills: true,
|
||||
noPromptTemplates: true,
|
||||
noThemes: true,
|
||||
noContextFiles: true,
|
||||
});
|
||||
await raceWithAbort(resourceLoader.reload(), controller.signal);
|
||||
|
||||
const sessionPromise = createAgentSession({
|
||||
cwd: request.cwd,
|
||||
agentDir,
|
||||
model: selection.model,
|
||||
modelRuntime: selection.modelRuntime,
|
||||
noTools: 'all',
|
||||
tools: toolNames,
|
||||
customTools,
|
||||
resourceLoader,
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
settingsManager,
|
||||
});
|
||||
try {
|
||||
({ session } = await raceWithAbort(sessionPromise, controller.signal));
|
||||
} catch (error) {
|
||||
void sessionPromise.then(
|
||||
async ({ session: lateSession }) => {
|
||||
await lateSession.abort().catch(() => undefined);
|
||||
lateSession.dispose();
|
||||
},
|
||||
() => undefined,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (controller.signal.aborted) {
|
||||
await session.abort().catch(() => undefined);
|
||||
} else {
|
||||
controller.signal.addEventListener('abort', () => void session?.abort().catch(() => undefined), {
|
||||
once: true,
|
||||
});
|
||||
}
|
||||
|
||||
const registeredTools = session.getAllTools().map((tool) => tool.name);
|
||||
if (!hasExactToolSet(registeredTools)) {
|
||||
throw new TaskFormationExecutorError({
|
||||
code: 'LIVE_TOOL_POLICY_MISMATCH',
|
||||
message: 'The live task-formation session registered a tool outside the exact allowlist.',
|
||||
retryable: false,
|
||||
failureKind: 'confinement',
|
||||
});
|
||||
}
|
||||
|
||||
executorLog('info', {
|
||||
...logContext,
|
||||
event: 'started',
|
||||
provider: selection.providerId,
|
||||
model: selection.modelId,
|
||||
tools: registeredTools,
|
||||
resources: { context: false, extensions: false, prompts: false, skills: false },
|
||||
});
|
||||
|
||||
let pendingProviderError: unknown;
|
||||
unsubscribe = session.subscribe((event: AgentSessionEvent) => {
|
||||
if (event.type !== 'turn_end') return;
|
||||
turnCount += 1;
|
||||
const message: AgentMessage = event.message;
|
||||
if (message.role === 'assistant' && message.stopReason === 'error') {
|
||||
pendingProviderError ??= message;
|
||||
}
|
||||
const needsAnotherTurn = message.role === 'assistant' && message.stopReason === 'toolUse';
|
||||
if (turnCount >= maxTurns && needsAnotherTurn && request.submitTool.getAcceptedCount() === 0) {
|
||||
terminate('turn-limit');
|
||||
}
|
||||
});
|
||||
|
||||
let promptError: unknown;
|
||||
requestStarted = true;
|
||||
try {
|
||||
await raceWithAbort(session.prompt(request.modelContext, { expandPromptTemplates: false }), controller.signal);
|
||||
} catch (error) {
|
||||
promptError = error;
|
||||
}
|
||||
|
||||
const outcome: SessionOutcome = {
|
||||
pendingProviderError,
|
||||
promptError,
|
||||
usage: sessionUsage(session),
|
||||
};
|
||||
const output = this.resolveOutcome(request, outcome, termination, requestStarted);
|
||||
executorLog('info', { ...logContext, event: 'finished', outcome: 'succeeded', usage: outcome.usage });
|
||||
return {
|
||||
output,
|
||||
usage: outcome.usage,
|
||||
providerId: selection.providerId,
|
||||
modelId: selection.modelId,
|
||||
modelCalls: 1,
|
||||
registeredTools: Object.freeze([...registeredTools]),
|
||||
};
|
||||
} catch (error) {
|
||||
// Termination reason wins over whatever error surfaced. A local timeout or an abort aborts the
|
||||
// in-flight provider call, so the caught error is usually that induced abort; reporting it as a
|
||||
// model failure would erase the real cause. Cancellation keeps its own identity ahead of timeout.
|
||||
if (termination === 'cancellation') {
|
||||
executorLog('info', { ...logContext, event: 'finished', outcome: 'cancelled' });
|
||||
throw cancellationError(request.signal);
|
||||
}
|
||||
|
||||
let failure: TaskFormationExecutorError;
|
||||
if (termination === 'timeout') {
|
||||
const usage = session ? sessionUsage(session) : zeroUsage();
|
||||
const modelCalls = requestStarted ? 1 : 0;
|
||||
failure = this.timeoutError(usage, modelCalls);
|
||||
} else {
|
||||
failure = this.normalizeFailure(error);
|
||||
}
|
||||
executorLog('warn', {
|
||||
...logContext,
|
||||
event: 'finished',
|
||||
outcome: 'failed',
|
||||
code: failure.code,
|
||||
failureKind: failure.failureKind,
|
||||
retryable: failure.retryable,
|
||||
...(failure.fallbackReason !== undefined && { fallbackReason: failure.fallbackReason }),
|
||||
usage: failure.usage,
|
||||
modelCalls: failure.modelCalls,
|
||||
});
|
||||
throw failure;
|
||||
} finally {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
request.signal.removeEventListener('abort', onCancellation);
|
||||
unsubscribe?.();
|
||||
try {
|
||||
session?.dispose();
|
||||
} catch {
|
||||
executorLog('warn', { ...logContext, event: 'cleanup-failed' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeFailure(error: unknown): TaskFormationExecutorError {
|
||||
if (error instanceof TaskFormationExecutorError) return error;
|
||||
if (error instanceof ConfinementError) {
|
||||
return new TaskFormationExecutorError({
|
||||
code: `CONFINEMENT_${error.code}`,
|
||||
message: error.message,
|
||||
retryable: false,
|
||||
failureKind: 'confinement',
|
||||
});
|
||||
}
|
||||
if (isTransientIoFailure(error)) {
|
||||
return new TaskFormationExecutorError({
|
||||
code: 'SESSION_INFRASTRUCTURE_FAILURE',
|
||||
message: 'Task-formation session setup encountered a retryable infrastructure failure.',
|
||||
retryable: true,
|
||||
failureKind: 'infrastructure',
|
||||
});
|
||||
}
|
||||
|
||||
const failure = classifyModelFailure(this.host, error);
|
||||
if (failure.type === 'ConfigurationError') {
|
||||
return new TaskFormationExecutorError({
|
||||
code: 'MODEL_CONFIGURATION_FAILURE',
|
||||
message: providerFailureSentence(failure),
|
||||
retryable: false,
|
||||
failureKind: 'input',
|
||||
});
|
||||
}
|
||||
return new TaskFormationExecutorError({
|
||||
code: failure.type === 'AuthenticationError' ? 'PROVIDER_AUTHENTICATION_FAILURE' : 'MODEL_SESSION_FAILURE',
|
||||
message: providerFailureSentence(failure),
|
||||
retryable: failure.retryable,
|
||||
failureKind: 'model',
|
||||
...(failure.retryable && { fallbackReason: 'retryable_model_failure' }),
|
||||
});
|
||||
}
|
||||
|
||||
private timeoutError(usage: TaskFormationUsage, modelCalls: number): TaskFormationExecutorError {
|
||||
return new TaskFormationExecutorError({
|
||||
code: 'MODEL_STAGE_TIMEOUT',
|
||||
message: 'Task formation exceeded its model-stage timeout.',
|
||||
retryable: true,
|
||||
failureKind: 'model',
|
||||
fallbackReason: 'model_stage_timeout',
|
||||
usage,
|
||||
modelCalls,
|
||||
});
|
||||
}
|
||||
|
||||
private resolveOutcome(
|
||||
request: TaskFormationExecutorRequest,
|
||||
outcome: SessionOutcome,
|
||||
termination: 'cancellation' | 'timeout' | 'turn-limit' | undefined,
|
||||
requestStarted: boolean,
|
||||
): unknown {
|
||||
const modelCalls = requestStarted ? 1 : 0;
|
||||
if (termination === 'cancellation') throw cancellationError(request.signal);
|
||||
if (termination === 'timeout') throw this.timeoutError(outcome.usage, modelCalls);
|
||||
if (termination === 'turn-limit') {
|
||||
throw new TaskFormationExecutorError({
|
||||
code: 'TURN_LIMIT',
|
||||
message: 'Task formation exhausted its bounded model turn limit.',
|
||||
retryable: true,
|
||||
failureKind: 'model',
|
||||
fallbackReason: 'retryable_model_failure',
|
||||
usage: outcome.usage,
|
||||
modelCalls,
|
||||
});
|
||||
}
|
||||
if (request.submitTool.getAcceptedCount() > 1) {
|
||||
throw new TaskFormationExecutorError({
|
||||
code: 'DUPLICATE_ACCEPTED_SUBMISSION',
|
||||
message: 'Task formation accepted more than one submission.',
|
||||
retryable: true,
|
||||
failureKind: 'model',
|
||||
fallbackReason: 'retryable_model_failure',
|
||||
usage: outcome.usage,
|
||||
modelCalls,
|
||||
});
|
||||
}
|
||||
if (outcome.pendingProviderError !== undefined) {
|
||||
const failure = classifyModelFailure(this.host, outcome.pendingProviderError);
|
||||
throw new TaskFormationExecutorError({
|
||||
code: 'PROVIDER_FAILURE',
|
||||
message: providerFailureSentence(failure),
|
||||
retryable: failure.retryable,
|
||||
failureKind: 'model',
|
||||
...(failure.retryable && { fallbackReason: 'retryable_model_failure' }),
|
||||
usage: outcome.usage,
|
||||
modelCalls,
|
||||
});
|
||||
}
|
||||
// A prompt error is a real failure unless exactly one submission was already accepted and the
|
||||
// error is an abort: the submit tool terminates the session, so that abort is the expected end of
|
||||
// a successful run, not a fault.
|
||||
if (
|
||||
outcome.promptError !== undefined &&
|
||||
!(request.submitTool.getAcceptedCount() === 1 && isAbortLike(outcome.promptError))
|
||||
) {
|
||||
const failure = classifyModelFailure(this.host, outcome.promptError);
|
||||
throw new TaskFormationExecutorError({
|
||||
code: 'MODEL_SESSION_FAILURE',
|
||||
message: providerFailureSentence(failure),
|
||||
retryable: failure.retryable,
|
||||
failureKind: 'model',
|
||||
...(failure.retryable && { fallbackReason: 'retryable_model_failure' }),
|
||||
usage: outcome.usage,
|
||||
modelCalls,
|
||||
});
|
||||
}
|
||||
|
||||
const output = request.submitTool.getCaptured();
|
||||
if (request.submitTool.getAcceptedCount() !== 1 || output === undefined) {
|
||||
throw new TaskFormationExecutorError({
|
||||
code: 'MISSING_ACCEPTED_SUBMISSION',
|
||||
message: 'Task formation ended without one accepted submission.',
|
||||
retryable: true,
|
||||
failureKind: 'model',
|
||||
fallbackReason: 'missing_accepted_submission',
|
||||
usage: outcome.usage,
|
||||
modelCalls,
|
||||
});
|
||||
}
|
||||
return output;
|
||||
}
|
||||
}
|
||||
|
||||
export function createTaskFormationExecutor(host: ModelHost = modelHost): TaskFormationExecutor {
|
||||
return new StandaloneTaskFormationExecutor(host);
|
||||
}
|
||||
|
||||
export const taskFormationExecutor: TaskFormationExecutor = createTaskFormationExecutor();
|
||||
@@ -1,20 +1,10 @@
|
||||
// Copyright (C) 2025 Keygraph, Inc.
|
||||
// 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.
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -1,44 +1,35 @@
|
||||
// Copyright (C) 2025 Keygraph, Inc.
|
||||
// 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 { type AssistantMessage, isContextOverflow, isRetryableAssistantError } from '@earendil-works/pi-ai';
|
||||
import { PentestError } from '../../services/error-handling.js';
|
||||
import type { AssistantMessage } from '@earendil-works/pi-ai';
|
||||
import { classifyProviderFailure, PentestError } from '../../services/error-handling.js';
|
||||
import { ErrorCode } from '../../types/errors.js';
|
||||
|
||||
/**
|
||||
* Wrap a failed assistant turn, taking the verdict from pi.
|
||||
* Wrap a failed assistant turn, taking the retry verdict from pi.
|
||||
*
|
||||
* Overflow is separated first, as pi's retry contract requires: it means the
|
||||
* request was too large, not that the provider faltered, so an identical retry
|
||||
* would overflow again. Everything else goes to pi's classifier, which treats
|
||||
* quota, billing, and auth exhaustion as terminal and load, throttling, and
|
||||
* transport faults as transient — those were already retried in-session, so
|
||||
* reaching here means the attempts were exhausted.
|
||||
* There is one decision point: the shared classifier. It defers retryability to pi's own
|
||||
* helper (load, throttling, and transport faults are transient; quota, billing, and context
|
||||
* overflow are terminal — the transient ones were already retried in-session, so reaching here
|
||||
* means the attempts were exhausted) and derives a separate observational category.
|
||||
*
|
||||
* `contextWindow` is omitted where overflow cannot apply, such as a one-word
|
||||
* credential probe.
|
||||
* A raw provider message never carries an auth/config ErrorCode — only the observational
|
||||
* category may say so — so it stays AGENT_EXECUTION_FAILED and cannot trip Temporal's
|
||||
* non-retryable type gate on a guess. `contextWindow` lets the classifier detect overflow;
|
||||
* it is omitted where overflow cannot apply, such as a one-word credential probe.
|
||||
*/
|
||||
export function providerTurnError(message: AssistantMessage, label: string, contextWindow?: number): PentestError {
|
||||
const detail = (message.errorMessage ?? 'unknown provider error').slice(0, 300);
|
||||
|
||||
if (contextWindow !== undefined && isContextOverflow(message, contextWindow)) {
|
||||
return new PentestError(
|
||||
`${label}: context window exceeded after compaction: ${detail}`,
|
||||
'unknown',
|
||||
false,
|
||||
{ contextWindow },
|
||||
ErrorCode.AGENT_EXECUTION_FAILED,
|
||||
);
|
||||
}
|
||||
|
||||
return new PentestError(
|
||||
`${label}: ${detail}`,
|
||||
const failure = classifyProviderFailure(message, contextWindow);
|
||||
const error = new PentestError(
|
||||
`${label}: ${failure.message}`,
|
||||
'unknown',
|
||||
isRetryableAssistantError(message),
|
||||
failure.retryable,
|
||||
{},
|
||||
ErrorCode.AGENT_EXECUTION_FAILED,
|
||||
);
|
||||
error.providerCategory = failure.category;
|
||||
return error;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (C) 2025 Keygraph, Inc.
|
||||
// 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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (C) 2025 Keygraph, Inc.
|
||||
// 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
|
||||
@@ -9,6 +9,13 @@
|
||||
import { ProgressIndicator } from '../progress-indicator.js';
|
||||
import { extractAgentType } from '../utils/formatting.js';
|
||||
|
||||
/**
|
||||
* `useCleanOutput` marks the phases that use the friendly "Running X..."
|
||||
* spinner plus a one-line completion message (pre-recon, recon, report, and
|
||||
* the vuln/exploit agents) as opposed to the verbose turn-by-turn fallback
|
||||
* formatting used elsewhere. `createProgressManager` reads it to decide
|
||||
* between a real spinner and the silent null one.
|
||||
*/
|
||||
export interface ProgressContext {
|
||||
description: string;
|
||||
useCleanOutput: boolean;
|
||||
@@ -62,7 +69,8 @@ class NullProgressManager implements ProgressManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Returns no-op when disabled
|
||||
// Returns no-op when disabled. `disableLoader` lets a caller force the silent manager regardless
|
||||
// of useCleanOutput, for a context where an animated spinner would be unwanted no matter the phase.
|
||||
export function createProgressManager(context: ProgressContext, disableLoader: boolean): ProgressManager {
|
||||
if (!context.useCleanOutput || disableLoader) {
|
||||
return new NullProgressManager();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (C) 2025 Keygraph, Inc.
|
||||
// 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
|
||||
@@ -16,6 +16,9 @@ import { defineTool } from '@earendil-works/pi-coding-agent';
|
||||
import { type Static, type TObject, Type } from 'typebox';
|
||||
import { stringEnum } from '../collectors/schema.js';
|
||||
import type { AgentName } from '../types/agents.js';
|
||||
import type { VulnClass } from '../types/config.js';
|
||||
import type { ReconciliationClass } from '../types/reconciliation.js';
|
||||
import { isProducerId, REF_PREFIX } from './reconciliation/refs.js';
|
||||
import type { CapturedSubmitTool } from './submit-tool.js';
|
||||
|
||||
const ANALYSIS_NOTES_DESCRIPTION = 'Plain context for defenders (caveats, scope, what is at risk). Not attack steps.';
|
||||
@@ -24,6 +27,18 @@ function optStr(description?: string) {
|
||||
return Type.Optional(Type.String(description === undefined ? {} : { description }));
|
||||
}
|
||||
|
||||
const analysisCodeLocationSchema = Type.Object({
|
||||
file: Type.String({ description: 'Repository-relative path, no leading slash.' }),
|
||||
start_line: Type.Optional(Type.Integer({ minimum: 1 })),
|
||||
end_line: Type.Optional(Type.Integer({ minimum: 1, description: 'Set when the flaw spans a range.' })),
|
||||
role: stringEnum(['sink', 'source', 'guard'], {
|
||||
description:
|
||||
'sink where the flaw manifests, source where untrusted input enters, guard for a check ' +
|
||||
'that is missing or misplaced.',
|
||||
}),
|
||||
symbol: Type.Optional(Type.String({ description: 'Enclosing function or method, named as written in the code.' })),
|
||||
});
|
||||
|
||||
/**
|
||||
* Base fields shared by every queue entry. `notes` gains guidance in analysis mode.
|
||||
*
|
||||
@@ -39,22 +54,9 @@ function baseFields(exploit: boolean) {
|
||||
description: 'Confidence that this is a real, reachable vulnerability.',
|
||||
}),
|
||||
code_locations: Type.Optional(
|
||||
Type.Array(
|
||||
Type.Object({
|
||||
file: Type.String({ description: 'Repository-relative path, no leading slash.' }),
|
||||
start_line: Type.Optional(Type.Integer({ minimum: 1 })),
|
||||
end_line: Type.Optional(Type.Integer({ minimum: 1, description: 'Set when the flaw spans a range.' })),
|
||||
role: stringEnum(['sink', 'source', 'guard'], {
|
||||
description:
|
||||
'sink where the flaw manifests, source where untrusted input enters, guard for a check ' +
|
||||
'that is missing or misplaced.',
|
||||
}),
|
||||
symbol: Type.Optional(
|
||||
Type.String({ description: 'Enclosing function or method, named as written in the code.' }),
|
||||
),
|
||||
}),
|
||||
{ description: 'Every code site this finding touches, sink first.' },
|
||||
),
|
||||
Type.Array(analysisCodeLocationSchema, {
|
||||
description: 'Every code site this finding touches, sink first.',
|
||||
}),
|
||||
),
|
||||
notes: exploit ? optStr() : optStr(ANALYSIS_NOTES_DESCRIPTION),
|
||||
};
|
||||
@@ -102,6 +104,17 @@ const ssrfFields = {
|
||||
suggested_exploit_technique: optStr(),
|
||||
};
|
||||
|
||||
const miscellaneousFields = {
|
||||
cwe: optStr(),
|
||||
source_endpoint: optStr(),
|
||||
vulnerable_code_location: optStr(),
|
||||
missing_defense: optStr(),
|
||||
observable_signal: optStr(),
|
||||
exploitation_hypothesis: optStr(),
|
||||
suggested_exploit_technique: optStr(),
|
||||
proof_criterion: optStr(),
|
||||
};
|
||||
|
||||
const authzFields = {
|
||||
endpoint: optStr(),
|
||||
vulnerable_code_location: optStr(),
|
||||
@@ -119,14 +132,44 @@ const xssEntry = () => Type.Object({ ...baseFields(true), ...xssFields });
|
||||
const authEntry = () => Type.Object({ ...baseFields(true), ...authFields });
|
||||
const ssrfEntry = () => Type.Object({ ...baseFields(true), ...ssrfFields });
|
||||
const authzEntry = () => Type.Object({ ...baseFields(true), ...authzFields });
|
||||
const miscellaneousEntry = () => Type.Object({ ...baseFields(true), ...miscellaneousFields });
|
||||
|
||||
export type QueueCodeLocation = NonNullable<Static<ReturnType<typeof injectionEntry>>['code_locations']>[number];
|
||||
export type AnalysisCodeLocation = Static<typeof analysisCodeLocationSchema>;
|
||||
|
||||
/** Queue-specific name retained for the existing analysis-location report join. */
|
||||
export type QueueCodeLocation = AnalysisCodeLocation;
|
||||
|
||||
export type InjectionFinding = Static<ReturnType<typeof injectionEntry>>;
|
||||
export type XssFinding = Static<ReturnType<typeof xssEntry>>;
|
||||
export type AuthFinding = Static<ReturnType<typeof authEntry>>;
|
||||
export type SsrfFinding = Static<ReturnType<typeof ssrfEntry>>;
|
||||
export type AuthzFinding = Static<ReturnType<typeof authzEntry>>;
|
||||
export type MiscellaneousFinding = Static<ReturnType<typeof miscellaneousEntry>>;
|
||||
|
||||
// The exact field names each class's queue entry carries. Reconciliation reads this to know which
|
||||
// keys a class produces, so it must list the same base and per-class fields the schemas above build.
|
||||
export const QUEUE_ENTRY_FIELD_NAMES: Readonly<Record<ReconciliationClass, readonly string[]>> = Object.freeze({
|
||||
injection: [...Object.keys(baseFields(true)), ...Object.keys(injectionFields)],
|
||||
xss: [...Object.keys(baseFields(true)), ...Object.keys(xssFields)],
|
||||
auth: [...Object.keys(baseFields(true)), ...Object.keys(authFields)],
|
||||
authz: [...Object.keys(baseFields(true)), ...Object.keys(authzFields)],
|
||||
ssrf: [...Object.keys(baseFields(true)), ...Object.keys(ssrfFields)],
|
||||
miscellaneous: [...Object.keys(baseFields(true)), ...Object.keys(miscellaneousFields)],
|
||||
});
|
||||
|
||||
const ENTRY_SCHEMAS: Readonly<Record<ReconciliationClass, TObject>> = Object.freeze({
|
||||
injection: injectionEntry(),
|
||||
xss: xssEntry(),
|
||||
auth: authEntry(),
|
||||
authz: authzEntry(),
|
||||
ssrf: ssrfEntry(),
|
||||
miscellaneous: miscellaneousEntry(),
|
||||
});
|
||||
|
||||
/** The complete queue-entry schema for one internal class. */
|
||||
export function classEntrySchema(vulnClass: ReconciliationClass): TObject {
|
||||
return ENTRY_SCHEMAS[vulnClass];
|
||||
}
|
||||
|
||||
const PER_TYPE_FIELDS: Partial<Record<AgentName, Record<string, ReturnType<typeof optStr>>>> = {
|
||||
'injection-vuln': injectionFields,
|
||||
@@ -144,12 +187,102 @@ const VULN_AGENT_QUEUE_FILENAMES: Partial<Record<AgentName, string>> = {
|
||||
'authz-vuln': 'authz_exploitation_queue.json',
|
||||
};
|
||||
|
||||
/** Build the TypeBox submit-tool parameters for a vuln agent, or undefined for non-vuln agents. */
|
||||
const VULN_AGENT_CLASSES: Partial<Record<AgentName, VulnClass>> = {
|
||||
'injection-vuln': 'injection',
|
||||
'xss-vuln': 'xss',
|
||||
'auth-vuln': 'auth',
|
||||
'authz-vuln': 'authz',
|
||||
'ssrf-vuln': 'ssrf',
|
||||
};
|
||||
|
||||
function producerIdFormat(vulnClass: VulnClass): string {
|
||||
return `${REF_PREFIX[vulnClass]}-VULN-NN`;
|
||||
}
|
||||
|
||||
function outOfNamespaceIds(vulnerabilities: readonly unknown[], vulnClass: VulnClass): string[] {
|
||||
const rejected: string[] = [];
|
||||
for (const entry of vulnerabilities) {
|
||||
if (entry === null || typeof entry !== 'object') continue;
|
||||
const id = (entry as { ID?: unknown }).ID;
|
||||
if (typeof id !== 'string' || !isProducerId(id, vulnClass, 'VULN')) {
|
||||
rejected.push(typeof id === 'string' ? id : String(id));
|
||||
}
|
||||
}
|
||||
return rejected;
|
||||
}
|
||||
|
||||
const ID_PREVIEW_LIMIT = 6;
|
||||
|
||||
function previewIds(ids: readonly string[]): string {
|
||||
const shown = ids.slice(0, ID_PREVIEW_LIMIT).join(', ');
|
||||
const remainder = ids.length - ID_PREVIEW_LIMIT;
|
||||
return remainder > 0 ? `${shown} (+${remainder} more)` : shown;
|
||||
}
|
||||
|
||||
// A rejected submission is returned as a retryable tool error rather than thrown: that hands the
|
||||
// message back to the model to correct within the same session instead of failing the whole agent.
|
||||
// `terminate` is deliberately left unset so the session survives to receive the corrected call.
|
||||
function retryableRejection(message: string) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify({ status: 'error', errorType: 'ValidationError', retryable: true, message }, null, 2),
|
||||
},
|
||||
],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function idNamespaceRejection(vulnClass: VulnClass, rejected: readonly string[]) {
|
||||
const message =
|
||||
`Every entry ID must be ${producerIdFormat(vulnClass)} ` +
|
||||
`(for example ${REF_PREFIX[vulnClass]}-VULN-01). Outside that namespace: ${previewIds(rejected)}.`;
|
||||
return retryableRejection(message);
|
||||
}
|
||||
|
||||
// Exact-string repeats, the same comparison reconciliation makes when it re-reads the committed
|
||||
// queue. Each offending ID is named once, in the order it first repeats.
|
||||
function repeatedProducerIds(vulnerabilities: readonly unknown[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const repeated: string[] = [];
|
||||
for (const entry of vulnerabilities) {
|
||||
if (entry === null || typeof entry !== 'object') continue;
|
||||
const id = (entry as { ID?: unknown }).ID;
|
||||
if (typeof id !== 'string') continue;
|
||||
if (seen.has(id) && !repeated.includes(id)) repeated.push(id);
|
||||
seen.add(id);
|
||||
}
|
||||
return repeated;
|
||||
}
|
||||
|
||||
function duplicateIdRejection(vulnClass: VulnClass, repeated: readonly string[]) {
|
||||
const message =
|
||||
`Each entry needs its own ${producerIdFormat(vulnClass)} ID; these appear more than once: ` +
|
||||
`${previewIds(repeated)}. Renumber the repeated entries — or drop the ones that describe the same ` +
|
||||
'vulnerability — and call submit_exploitation_queue again.';
|
||||
return retryableRejection(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the TypeBox submit-tool parameters for a vuln agent, or undefined for non-vuln agents.
|
||||
*
|
||||
* The `ID` this schema requires (`INJ-VULN-01` and so on) is an internal producer token, meant
|
||||
* only to let reconciliation join a finding back to the agent and class that raised it. It is not
|
||||
* the identifier a downstream exploit agent should ever see; reconciliation is responsible for
|
||||
* translating it into the exploitation-task identity the published queue carries instead.
|
||||
*/
|
||||
function queueSchema(agentName: AgentName, exploit: boolean): TObject | undefined {
|
||||
const extra = PER_TYPE_FIELDS[agentName];
|
||||
if (!extra) return undefined;
|
||||
const vulnClass = VULN_AGENT_CLASSES[agentName];
|
||||
if (!extra || !vulnClass) return undefined;
|
||||
const idField = Type.String({
|
||||
description:
|
||||
`Producer identifier formatted ${producerIdFormat(vulnClass)} ` +
|
||||
`(for example ${REF_PREFIX[vulnClass]}-VULN-01).`,
|
||||
});
|
||||
return Type.Object({
|
||||
vulnerabilities: Type.Array(Type.Object({ ...baseFields(exploit), ...extra })),
|
||||
vulnerabilities: Type.Array(Type.Object({ ...baseFields(exploit), ID: idField, ...extra })),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -161,7 +294,8 @@ export function getQueueFilename(agentName: AgentName): string | undefined {
|
||||
/** Build the pi submit tool that captures the exploitation queue for vuln agents. */
|
||||
export function createQueueSubmitTool(agentName: AgentName, exploit = true): CapturedSubmitTool | undefined {
|
||||
const schema = queueSchema(agentName, exploit);
|
||||
if (!schema) return undefined;
|
||||
const vulnClass = VULN_AGENT_CLASSES[agentName];
|
||||
if (!schema || !vulnClass) return undefined;
|
||||
|
||||
let captured: unknown | undefined;
|
||||
return {
|
||||
@@ -174,21 +308,39 @@ export function createQueueSubmitTool(agentName: AgentName, exploit = true): Cap
|
||||
promptGuidelines: [
|
||||
'You MUST call submit_exploitation_queue exactly once as your final action.',
|
||||
'Include every analyzed finding in the vulnerabilities array.',
|
||||
`Give every entry an ID in the ${producerIdFormat(vulnClass)} namespace.`,
|
||||
],
|
||||
parameters: schema,
|
||||
async execute(_toolCallId, params) {
|
||||
const vulnerabilities = Array.isArray((params as { vulnerabilities?: unknown }).vulnerabilities)
|
||||
? (params as { vulnerabilities: unknown[] }).vulnerabilities
|
||||
: [];
|
||||
// Every entry ID must sit in this class's producer namespace, and no two entries may share
|
||||
// one, so reconciliation refs stay unique both across classes and within this queue. Both
|
||||
// rules are the ones reconciliation applies to the committed queue; enforcing them here
|
||||
// turns a permanent post-commit failure into an in-session correction. Nothing is captured,
|
||||
// written, or committed until every ID passes.
|
||||
const rejected = outOfNamespaceIds(vulnerabilities, vulnClass);
|
||||
if (rejected.length > 0) {
|
||||
return idNamespaceRejection(vulnClass, rejected);
|
||||
}
|
||||
const repeated = repeatedProducerIds(vulnerabilities);
|
||||
if (repeated.length > 0) {
|
||||
return duplicateIdRejection(vulnClass, repeated);
|
||||
}
|
||||
captured = params;
|
||||
const count = Array.isArray((params as { vulnerabilities?: unknown }).vulnerabilities)
|
||||
? (params as { vulnerabilities: unknown[] }).vulnerabilities.length
|
||||
: 0;
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Recorded ${count} findings.` }],
|
||||
content: [{ type: 'text' as const, text: `Recorded ${vulnerabilities.length} findings.` }],
|
||||
details: params,
|
||||
terminate: true,
|
||||
};
|
||||
},
|
||||
}),
|
||||
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,518 @@
|
||||
/** Content-addressed, no-replace storage for reconciliation intermediates. */
|
||||
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import type { Stats } from 'node:fs';
|
||||
import { link, lstat, mkdir, open, readFile, realpath, unlink } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { WORKSPACES_DIR } from '../../paths.js';
|
||||
import { ALL_RECONCILIATION_CLASSES, type ReconciliationClass } from '../../types/reconciliation.js';
|
||||
import type { ArtifactInputDigest, ArtifactKind, ArtifactRef } from './contracts.js';
|
||||
import { RECONCILIATION_SCHEMA_VERSION } from './schema-version.js';
|
||||
import type { ArtifactBodyMap } from './stage-contracts.js';
|
||||
|
||||
export { RECONCILIATION_SCHEMA_VERSION };
|
||||
|
||||
const KIND_SEQUENCE: Readonly<Record<ArtifactKind, string>> = Object.freeze({
|
||||
'producer-observations': '00',
|
||||
'supplemental-observations': '01',
|
||||
'task-formation': '02',
|
||||
'fixed-tasks': '03',
|
||||
});
|
||||
|
||||
const ARTIFACT_KINDS = Object.freeze(Object.keys(KIND_SEQUENCE) as ArtifactKind[]);
|
||||
const SHA256_PATTERN = /^[a-f0-9]{64}$/;
|
||||
|
||||
export type ReconciliationFailureType =
|
||||
| 'ReconciliationArtifactNotFound'
|
||||
| 'ReconciliationIoError'
|
||||
| 'ArtifactIntegrityError'
|
||||
| 'PublicationConflict'
|
||||
| 'SastEnrichmentInputError'
|
||||
| 'SastEnrichmentModelError';
|
||||
|
||||
/** Typed reconciliation failure normalized by the later Temporal activity boundary. */
|
||||
export class ReconciliationError extends Error {
|
||||
readonly retryable: boolean;
|
||||
readonly failureType: ReconciliationFailureType;
|
||||
|
||||
// The default failure type follows `retryable`: a retryable error reads as transient I/O,
|
||||
// a non-retryable one as an integrity violation. Temporal keeps retrying the former and
|
||||
// fails fast on the latter, so the two must stay aligned.
|
||||
constructor(
|
||||
message: string,
|
||||
retryable: boolean,
|
||||
failureType: ReconciliationFailureType = retryable ? 'ReconciliationIoError' : 'ArtifactIntegrityError',
|
||||
) {
|
||||
super(message);
|
||||
this.name = failureType;
|
||||
this.retryable = retryable;
|
||||
this.failureType = failureType;
|
||||
}
|
||||
}
|
||||
|
||||
export class ReconciliationArtifactNotFoundError extends ReconciliationError {
|
||||
constructor(message: string) {
|
||||
super(message, true, 'ReconciliationArtifactNotFound');
|
||||
}
|
||||
}
|
||||
|
||||
export class ReconciliationIoError extends ReconciliationError {
|
||||
constructor(message: string) {
|
||||
super(message, true, 'ReconciliationIoError');
|
||||
}
|
||||
}
|
||||
|
||||
export class ArtifactIntegrityError extends ReconciliationError {
|
||||
constructor(message: string) {
|
||||
super(message, false, 'ArtifactIntegrityError');
|
||||
}
|
||||
}
|
||||
|
||||
export class PublicationConflictError extends ReconciliationError {
|
||||
constructor(message: string) {
|
||||
super(message, false, 'PublicationConflict');
|
||||
}
|
||||
}
|
||||
|
||||
interface ArtifactEnvelope {
|
||||
artifactKind: ArtifactKind;
|
||||
vulnerabilityClass: ReconciliationClass;
|
||||
schemaVersion: 1;
|
||||
inputs: ArtifactInputDigest[];
|
||||
counts: Record<string, number>;
|
||||
body: unknown;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isErrno(error: unknown, code: string): boolean {
|
||||
return error instanceof Error && (error as NodeJS.ErrnoException).code === code;
|
||||
}
|
||||
|
||||
function validateSessionId(sessionId: string): void {
|
||||
const valid =
|
||||
sessionId.length > 0 &&
|
||||
sessionId !== '.' &&
|
||||
sessionId !== '..' &&
|
||||
!sessionId.includes('/') &&
|
||||
!sessionId.includes('\\') &&
|
||||
!sessionId.includes('\0');
|
||||
if (!valid) {
|
||||
throw new ArtifactIntegrityError('Invalid reconciliation session identifier');
|
||||
}
|
||||
}
|
||||
|
||||
function validateClass(vulnerabilityClass: ReconciliationClass): void {
|
||||
if (!ALL_RECONCILIATION_CLASSES.includes(vulnerabilityClass)) {
|
||||
throw new ArtifactIntegrityError('Invalid reconciliation class');
|
||||
}
|
||||
}
|
||||
|
||||
function validateDigest(sha256: string, label: string): void {
|
||||
if (!SHA256_PATTERN.test(sha256)) {
|
||||
throw new ArtifactIntegrityError(`${label} is not a lowercase SHA-256 digest`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateInputs(inputs: readonly ArtifactInputDigest[]): void {
|
||||
if (!Array.isArray(inputs)) {
|
||||
throw new ArtifactIntegrityError('Artifact lineage is not an array');
|
||||
}
|
||||
for (const input of inputs) {
|
||||
if (!isRecord(input) || !ARTIFACT_KINDS.includes(input.artifactKind as ArtifactKind)) {
|
||||
throw new ArtifactIntegrityError('Artifact lineage contains an invalid kind');
|
||||
}
|
||||
validateDigest(input.sha256 as string, 'Artifact lineage digest');
|
||||
if (Object.keys(input).sort().join(',') !== 'artifactKind,sha256') {
|
||||
throw new ArtifactIntegrityError('Artifact lineage contains unexpected metadata');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateCounts(counts: Record<string, number>): void {
|
||||
if (!isRecord(counts)) {
|
||||
throw new ArtifactIntegrityError('Artifact counts are not an object');
|
||||
}
|
||||
for (const [name, value] of Object.entries(counts)) {
|
||||
if (name.length === 0 || !Number.isSafeInteger(value) || value < 0) {
|
||||
throw new ArtifactIntegrityError('Artifact counts contain an invalid entry');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sha256Hex(bytes: Buffer): string {
|
||||
return createHash('sha256').update(bytes).digest('hex');
|
||||
}
|
||||
|
||||
function artifactFilename(kind: ArtifactKind, sha256: string): string {
|
||||
return `${KIND_SEQUENCE[kind]}-${kind}-${sha256}.json`;
|
||||
}
|
||||
|
||||
function serializeEnvelope(envelope: ArtifactEnvelope): Buffer {
|
||||
try {
|
||||
return Buffer.from(JSON.stringify(envelope), 'utf8');
|
||||
} catch {
|
||||
throw new ArtifactIntegrityError('Reconciliation artifact body is not JSON serializable');
|
||||
}
|
||||
}
|
||||
|
||||
/** Stable root for one class, independent of customer output destinations. */
|
||||
export function reconciliationDir(
|
||||
sessionId: string,
|
||||
vulnerabilityClass: ReconciliationClass,
|
||||
workspacesDir: string = WORKSPACES_DIR,
|
||||
): string {
|
||||
validateSessionId(sessionId);
|
||||
validateClass(vulnerabilityClass);
|
||||
return path.resolve(workspacesDir, sessionId, '.shannon', 'reconciliation', vulnerabilityClass);
|
||||
}
|
||||
|
||||
async function ensureDirectory(parent: string, segment: string): Promise<string> {
|
||||
const next = path.join(parent, segment);
|
||||
try {
|
||||
await mkdir(next);
|
||||
} catch (error) {
|
||||
if (!isErrno(error, 'EEXIST')) {
|
||||
throw new ReconciliationIoError('Unable to create reconciliation artifact directory');
|
||||
}
|
||||
}
|
||||
|
||||
let stat: Stats;
|
||||
try {
|
||||
stat = await lstat(next);
|
||||
} catch {
|
||||
throw new ReconciliationIoError('Unable to inspect reconciliation artifact directory');
|
||||
}
|
||||
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
||||
throw new ArtifactIntegrityError('Reconciliation artifact root contains a symlink or non-directory');
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
// `ensureArtifactRoot` (write path) and `resolveArtifactRoot` (read path) are kept as separate
|
||||
// functions rather than one with a "create if missing" flag: a read for a session/class that never
|
||||
// wrote anything must fail as not-found, not silently materialize an empty directory chain that
|
||||
// would then make an absent artifact look like a not-yet-written one.
|
||||
async function ensureArtifactRoot(
|
||||
sessionId: string,
|
||||
vulnerabilityClass: ReconciliationClass,
|
||||
workspacesDir: string,
|
||||
): Promise<string> {
|
||||
validateSessionId(sessionId);
|
||||
validateClass(vulnerabilityClass);
|
||||
try {
|
||||
await mkdir(workspacesDir, { recursive: true });
|
||||
} catch {
|
||||
throw new ReconciliationIoError('Unable to create the reconciliation workspace root');
|
||||
}
|
||||
|
||||
let realWorkspaces: string;
|
||||
try {
|
||||
realWorkspaces = await realpath(workspacesDir);
|
||||
} catch {
|
||||
throw new ReconciliationIoError('Unable to resolve the reconciliation workspace root');
|
||||
}
|
||||
|
||||
let current = realWorkspaces;
|
||||
for (const segment of [sessionId, '.shannon', 'reconciliation', vulnerabilityClass]) {
|
||||
current = await ensureDirectory(current, segment);
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
async function resolveArtifactRoot(
|
||||
sessionId: string,
|
||||
vulnerabilityClass: ReconciliationClass,
|
||||
workspacesDir: string,
|
||||
): Promise<string> {
|
||||
validateSessionId(sessionId);
|
||||
validateClass(vulnerabilityClass);
|
||||
|
||||
let current: string;
|
||||
try {
|
||||
current = await realpath(workspacesDir);
|
||||
} catch {
|
||||
throw new ReconciliationArtifactNotFoundError('Reconciliation workspace root is not visible');
|
||||
}
|
||||
|
||||
for (const segment of [sessionId, '.shannon', 'reconciliation', vulnerabilityClass]) {
|
||||
const next = path.join(current, segment);
|
||||
let stat: Stats;
|
||||
try {
|
||||
stat = await lstat(next);
|
||||
} catch (error) {
|
||||
if (isErrno(error, 'ENOENT')) {
|
||||
throw new ReconciliationArtifactNotFoundError('Reconciliation artifact root is not visible');
|
||||
}
|
||||
throw new ReconciliationIoError('Unable to inspect reconciliation artifact root');
|
||||
}
|
||||
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
||||
throw new ArtifactIntegrityError('Reconciliation artifact root contains a symlink or non-directory');
|
||||
}
|
||||
current = next;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
async function writeDurableTemporaryFile(tempPath: string, bytes: Buffer): Promise<void> {
|
||||
try {
|
||||
// `wx` fails if the attempt-unique temp path already exists, and the fsync forces the bytes
|
||||
// to disk before the later `link` publishes them. Publishing an unsynced file would let a
|
||||
// crash leave a linked-but-empty artifact that its digest no longer matches.
|
||||
const handle = await open(tempPath, 'wx');
|
||||
try {
|
||||
await handle.writeFile(bytes);
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
} catch {
|
||||
await unlink(tempPath).catch(() => undefined);
|
||||
throw new ReconciliationIoError('Unable to durably create an attempt-unique reconciliation artifact');
|
||||
}
|
||||
}
|
||||
|
||||
// Some filesystems reject fsync on a directory handle. Those codes mean the durability barrier
|
||||
// is unavailable, not that publication failed, so the caller treats them as success.
|
||||
function directorySyncIsUnsupported(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) return false;
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
return code === 'EINVAL' || code === 'ENOTSUP' || code === 'EOPNOTSUPP' || code === 'EBADF' || code === 'EISDIR';
|
||||
}
|
||||
|
||||
async function syncPublishedDirectory(directory: string): Promise<void> {
|
||||
try {
|
||||
const handle = await open(directory, 'r');
|
||||
try {
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
} catch (error) {
|
||||
if (directorySyncIsUnsupported(error)) return;
|
||||
throw new ReconciliationIoError('Unable to make the reconciliation artifact directory durable');
|
||||
}
|
||||
}
|
||||
|
||||
export type WriteArtifactArgs<TKind extends ArtifactKind = ArtifactKind> = {
|
||||
[K in TKind]: {
|
||||
sessionId: string;
|
||||
workspacesDir?: string;
|
||||
artifactKind: K;
|
||||
vulnerabilityClass: ReconciliationClass;
|
||||
body: ArtifactBodyMap[K];
|
||||
inputs: ArtifactInputDigest[];
|
||||
counts: Record<string, number>;
|
||||
};
|
||||
}[TKind];
|
||||
|
||||
/** Serialize and publish one whole-envelope artifact without replacing an existing path. */
|
||||
export async function writeArtifact<TKind extends ArtifactKind>(
|
||||
args: WriteArtifactArgs<TKind>,
|
||||
): Promise<ArtifactRef<TKind>> {
|
||||
validateInputs(args.inputs);
|
||||
validateCounts(args.counts);
|
||||
|
||||
const envelope: ArtifactEnvelope = {
|
||||
artifactKind: args.artifactKind,
|
||||
vulnerabilityClass: args.vulnerabilityClass,
|
||||
schemaVersion: RECONCILIATION_SCHEMA_VERSION,
|
||||
inputs: args.inputs,
|
||||
counts: args.counts,
|
||||
body: args.body,
|
||||
};
|
||||
const bytes = serializeEnvelope(envelope);
|
||||
const sha256 = sha256Hex(bytes);
|
||||
const root = await ensureArtifactRoot(args.sessionId, args.vulnerabilityClass, args.workspacesDir ?? WORKSPACES_DIR);
|
||||
const filename = artifactFilename(args.artifactKind, sha256);
|
||||
const finalPath = path.join(root, filename);
|
||||
const tempPath = path.join(root, `.${filename}.tmp-${randomBytes(12).toString('hex')}`);
|
||||
|
||||
await writeDurableTemporaryFile(tempPath, bytes);
|
||||
|
||||
// The final path is content-addressed by digest, so an EEXIST link means a prior attempt already
|
||||
// published these exact bytes. Identical bytes are adopted as success (idempotent republish);
|
||||
// different bytes at the same digest path can only be corruption or a hash collision, so they
|
||||
// conflict. This is what makes a retried write after a lost acknowledgement safe.
|
||||
let failure: unknown;
|
||||
try {
|
||||
await link(tempPath, finalPath);
|
||||
} catch (error) {
|
||||
if (!isErrno(error, 'EEXIST')) {
|
||||
failure = new ReconciliationIoError('Unable to publish reconciliation artifact');
|
||||
} else {
|
||||
try {
|
||||
const stat = await lstat(finalPath);
|
||||
if (stat.isSymbolicLink() || !stat.isFile()) {
|
||||
failure = new ArtifactIntegrityError('Existing reconciliation artifact is not a regular file');
|
||||
} else {
|
||||
const existing = await readFile(finalPath);
|
||||
if (!existing.equals(bytes)) {
|
||||
failure = new PublicationConflictError('Existing reconciliation artifact has different exact bytes');
|
||||
}
|
||||
}
|
||||
} catch (readError) {
|
||||
failure =
|
||||
readError instanceof ReconciliationError
|
||||
? readError
|
||||
: new ReconciliationIoError('Unable to verify existing reconciliation artifact');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (failure === undefined) {
|
||||
try {
|
||||
await syncPublishedDirectory(root);
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the attempt-local temp link last. A cleanup failure is only reported when nothing
|
||||
// earlier failed, so temp-file noise never masks a real publication or conflict error.
|
||||
try {
|
||||
await unlink(tempPath);
|
||||
} catch (error) {
|
||||
if (!isErrno(error, 'ENOENT') && failure === undefined) {
|
||||
failure = new ReconciliationIoError('Unable to remove attempt-local reconciliation artifact');
|
||||
}
|
||||
}
|
||||
if (failure !== undefined) throw failure;
|
||||
|
||||
return {
|
||||
path: finalPath,
|
||||
artifactKind: args.artifactKind,
|
||||
vulnerabilityClass: args.vulnerabilityClass,
|
||||
schemaVersion: RECONCILIATION_SCHEMA_VERSION,
|
||||
sha256,
|
||||
inputs: args.inputs,
|
||||
counts: args.counts,
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether two ordered artifact lineages contain identical kinds and digests. */
|
||||
export function artifactInputsMatch(
|
||||
first: readonly ArtifactInputDigest[],
|
||||
second: readonly ArtifactInputDigest[],
|
||||
): boolean {
|
||||
return (
|
||||
first.length === second.length &&
|
||||
first.every(
|
||||
(entry, index) => entry.artifactKind === second[index]?.artifactKind && entry.sha256 === second[index]?.sha256,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function countsMatch(first: Record<string, number>, second: Record<string, number>): boolean {
|
||||
const firstKeys = Object.keys(first).sort();
|
||||
const secondKeys = Object.keys(second).sort();
|
||||
return (
|
||||
firstKeys.length === secondKeys.length &&
|
||||
firstKeys.every((key, index) => key === secondKeys[index] && first[key] === second[key])
|
||||
);
|
||||
}
|
||||
|
||||
function parseEnvelope(bytes: Buffer): ArtifactEnvelope {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(bytes.toString('utf8'));
|
||||
} catch {
|
||||
throw new ArtifactIntegrityError('Reconciliation artifact is not valid JSON');
|
||||
}
|
||||
if (!isRecord(parsed)) {
|
||||
throw new ArtifactIntegrityError('Reconciliation artifact envelope is not an object');
|
||||
}
|
||||
const expectedKeys = ['artifactKind', 'body', 'counts', 'inputs', 'schemaVersion', 'vulnerabilityClass'];
|
||||
if (Object.keys(parsed).sort().join(',') !== expectedKeys.join(',')) {
|
||||
throw new ArtifactIntegrityError('Reconciliation artifact envelope has unexpected fields');
|
||||
}
|
||||
if (!ARTIFACT_KINDS.includes(parsed.artifactKind as ArtifactKind)) {
|
||||
throw new ArtifactIntegrityError('Reconciliation artifact kind is invalid');
|
||||
}
|
||||
if (!ALL_RECONCILIATION_CLASSES.includes(parsed.vulnerabilityClass as ReconciliationClass)) {
|
||||
throw new ArtifactIntegrityError('Reconciliation artifact class is invalid');
|
||||
}
|
||||
if (parsed.schemaVersion !== RECONCILIATION_SCHEMA_VERSION) {
|
||||
throw new ArtifactIntegrityError('Reconciliation artifact schema version is invalid');
|
||||
}
|
||||
validateInputs(parsed.inputs as ArtifactInputDigest[]);
|
||||
validateCounts(parsed.counts as Record<string, number>);
|
||||
return parsed as unknown as ArtifactEnvelope;
|
||||
}
|
||||
|
||||
function validateRef(ref: ArtifactRef): void {
|
||||
if (!ARTIFACT_KINDS.includes(ref.artifactKind)) {
|
||||
throw new ArtifactIntegrityError('Artifact reference kind is invalid');
|
||||
}
|
||||
validateClass(ref.vulnerabilityClass);
|
||||
if (ref.schemaVersion !== RECONCILIATION_SCHEMA_VERSION) {
|
||||
throw new ArtifactIntegrityError('Artifact reference schema version is invalid');
|
||||
}
|
||||
validateDigest(ref.sha256, 'Artifact reference digest');
|
||||
validateInputs(ref.inputs);
|
||||
validateCounts(ref.counts);
|
||||
}
|
||||
|
||||
/** Read and verify one artifact's path, bytes, envelope metadata, and ordered lineage. */
|
||||
export async function readArtifact<TKind extends ArtifactKind>(
|
||||
ref: ArtifactRef<TKind>,
|
||||
sessionId: string,
|
||||
workspacesDir: string = WORKSPACES_DIR,
|
||||
): Promise<ArtifactBodyMap[TKind]> {
|
||||
validateRef(ref);
|
||||
const root = await resolveArtifactRoot(sessionId, ref.vulnerabilityClass, workspacesDir);
|
||||
// A reference carries its own path through Temporal history. Recompute the only path this
|
||||
// kind and digest may occupy and demand an exact match, so a tampered or stale ref cannot
|
||||
// point a read at an arbitrary file outside the class artifact root.
|
||||
const expectedPath = path.join(root, artifactFilename(ref.artifactKind, ref.sha256));
|
||||
if (!path.isAbsolute(ref.path) || path.normalize(ref.path) !== ref.path || ref.path !== expectedPath) {
|
||||
throw new ArtifactIntegrityError('Artifact reference path is not the expected contained path');
|
||||
}
|
||||
|
||||
let stat: Stats;
|
||||
try {
|
||||
stat = await lstat(ref.path);
|
||||
} catch (error) {
|
||||
if (isErrno(error, 'ENOENT')) {
|
||||
throw new ReconciliationArtifactNotFoundError('Referenced reconciliation artifact is not visible');
|
||||
}
|
||||
throw new ReconciliationIoError('Unable to inspect referenced reconciliation artifact');
|
||||
}
|
||||
if (stat.isSymbolicLink() || !stat.isFile()) {
|
||||
throw new ArtifactIntegrityError('Referenced reconciliation artifact is not a regular file');
|
||||
}
|
||||
|
||||
let resolvedPath: string;
|
||||
try {
|
||||
resolvedPath = await realpath(ref.path);
|
||||
} catch {
|
||||
throw new ReconciliationArtifactNotFoundError('Referenced reconciliation artifact is not visible');
|
||||
}
|
||||
if (resolvedPath !== expectedPath) {
|
||||
throw new ArtifactIntegrityError('Referenced reconciliation artifact resolves outside its expected path');
|
||||
}
|
||||
|
||||
let bytes: Buffer;
|
||||
try {
|
||||
bytes = await readFile(resolvedPath);
|
||||
} catch {
|
||||
throw new ReconciliationIoError('Unable to read referenced reconciliation artifact');
|
||||
}
|
||||
if (sha256Hex(bytes) !== ref.sha256) {
|
||||
throw new ArtifactIntegrityError('Reconciliation artifact digest does not match its reference');
|
||||
}
|
||||
|
||||
const envelope = parseEnvelope(bytes);
|
||||
if (
|
||||
envelope.artifactKind !== ref.artifactKind ||
|
||||
envelope.vulnerabilityClass !== ref.vulnerabilityClass ||
|
||||
envelope.schemaVersion !== ref.schemaVersion ||
|
||||
!artifactInputsMatch(envelope.inputs, ref.inputs) ||
|
||||
!countsMatch(envelope.counts, ref.counts)
|
||||
) {
|
||||
throw new ArtifactIntegrityError('Reconciliation artifact metadata or lineage does not match its reference');
|
||||
}
|
||||
return envelope.body as ArtifactBodyMap[TKind];
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/** Shared contracts for the single-scan reconciliation pipeline. */
|
||||
|
||||
import type { ReconciliationClass } from '../../types/reconciliation.js';
|
||||
import type {
|
||||
AuthFinding,
|
||||
AuthzFinding,
|
||||
InjectionFinding,
|
||||
MiscellaneousFinding,
|
||||
SsrfFinding,
|
||||
XssFinding,
|
||||
} from '../queue-schemas.js';
|
||||
|
||||
/** Which producer emitted an observation. */
|
||||
export type ScanSource = 'vulnerability_analysis' | 'sast';
|
||||
|
||||
/** Internal primary-selection preference. Never exposed to a model or consumer queue. */
|
||||
export type PrimaryPreference = 'default' | 'preferred';
|
||||
|
||||
/** Priority supplied by the SAST bridge. */
|
||||
export type Priority = 'P1' | 'P2' | 'P3';
|
||||
|
||||
/**
|
||||
* Authoritative SAST source location copied from validated SARIF.
|
||||
*
|
||||
* This is the exact file/line/column the static analysis engine pinned its finding to, carried
|
||||
* through reconciliation unchanged so a task's reported location always traces back to real
|
||||
* evidence rather than something reconstructed or guessed downstream.
|
||||
*/
|
||||
export interface SastSourceLocation {
|
||||
file: string;
|
||||
line: number;
|
||||
column: number;
|
||||
rule_id: string;
|
||||
}
|
||||
|
||||
// Widen each member of a union so the keys unique to its siblings are typed `never`. This lets one
|
||||
// evidence value be discriminated by which class's fields it carries, and makes assigning a foreign
|
||||
// class's field a compile error rather than a silently accepted extra property.
|
||||
type ExclusiveUnion<T, TAll = T> = T extends unknown
|
||||
? T & Partial<Record<Exclude<TAll extends unknown ? keyof TAll : never, keyof T>, never>>
|
||||
: never;
|
||||
|
||||
/** Class-specific evidence with the producer-owned `ID` removed. */
|
||||
export type ClassEvidence = ExclusiveUnion<
|
||||
| Omit<InjectionFinding, 'ID'>
|
||||
| Omit<XssFinding, 'ID'>
|
||||
| Omit<AuthFinding, 'ID'>
|
||||
| Omit<AuthzFinding, 'ID'>
|
||||
| Omit<SsrfFinding, 'ID'>
|
||||
| Omit<MiscellaneousFinding, 'ID'>
|
||||
>;
|
||||
|
||||
// A SAST-origin observation always declares `preferred`. This is the dedupe contract: when
|
||||
// reconciliation merges a SAST finding with a pentest finding for the same underlying bug, the
|
||||
// SAST evidence becomes the task's primary record (it carries an exact file/line/rule, while a
|
||||
// pentest finding does not), and the pentest observation survives only as a merged member.
|
||||
export interface SastProducerFields {
|
||||
producer_id: string;
|
||||
scan_source: 'sast';
|
||||
primary_preference: 'preferred';
|
||||
priority: Priority;
|
||||
sast_source_location: SastSourceLocation;
|
||||
}
|
||||
|
||||
// Pentest-origin observations always declare `default`, the losing side of the preference above.
|
||||
export interface VulnAnalysisProducerFields {
|
||||
producer_id: string;
|
||||
scan_source: 'vulnerability_analysis';
|
||||
primary_preference: 'default';
|
||||
}
|
||||
|
||||
export type ProducerFields = SastProducerFields | VulnAnalysisProducerFields;
|
||||
|
||||
// Once a group is collapsed into a task the primary is already chosen, so `primary_preference`
|
||||
// has done its job and is dropped. It must not survive into materialized tasks or published output.
|
||||
export type MaterializedProducerFields =
|
||||
| Omit<SastProducerFields, 'primary_preference'>
|
||||
| Omit<VulnAnalysisProducerFields, 'primary_preference'>;
|
||||
|
||||
/** One current observation before task formation. */
|
||||
export type ReconciliationObservation<E extends ClassEvidence = ClassEvidence> = E & ProducerFields;
|
||||
|
||||
/** One non-primary observation retained under a materialized task. */
|
||||
export type MergedObservation<E extends ClassEvidence = ClassEvidence> = E & MaterializedProducerFields;
|
||||
|
||||
/** One stable exploitation task before publication removes internal producer fields. */
|
||||
export type ReconciliationTask<E extends ClassEvidence = ClassEvidence> = E &
|
||||
MaterializedProducerFields & {
|
||||
ID: string;
|
||||
merged_from?: MergedObservation<E>[];
|
||||
};
|
||||
|
||||
/** The exact OSS intermediate artifact vocabulary, in stage order. */
|
||||
export type ArtifactKind = 'producer-observations' | 'supplemental-observations' | 'task-formation' | 'fixed-tasks';
|
||||
|
||||
// One entry in an artifact's lineage: which prior-stage artifact (by kind and exact content
|
||||
// digest) it was built from. A later stage checks these digests against the refs it was actually
|
||||
// handed, so it can refuse to proceed if its inputs were regenerated or swapped out from under it.
|
||||
export interface ArtifactInputDigest {
|
||||
artifactKind: ArtifactKind;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
/** Safe content-addressed metadata carried through Temporal history. */
|
||||
export interface ArtifactRef<TKind extends ArtifactKind = ArtifactKind> {
|
||||
path: string;
|
||||
artifactKind: TKind;
|
||||
vulnerabilityClass: ReconciliationClass;
|
||||
schemaVersion: 1;
|
||||
sha256: string;
|
||||
inputs: ArtifactInputDigest[];
|
||||
counts: Record<string, number>;
|
||||
}
|
||||
|
||||
/** Exact durable output set for one class publication. */
|
||||
export interface PublicationContract {
|
||||
publicationKind: 'class-reconciliation';
|
||||
schemaVersion: 1;
|
||||
manifestPath: string;
|
||||
requiredOutputPaths: readonly string[];
|
||||
}
|
||||
|
||||
/** History-safe aggregate metrics for one reconciled class. */
|
||||
export interface ReconciliationMetrics {
|
||||
alreadyPublished: boolean;
|
||||
durationMs: number;
|
||||
costUsd: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
modelCalls: number;
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
/** Strict per-class SAST enrichment through the shared one-shot generation port. */
|
||||
|
||||
import { WORKSPACES_DIR } from '../../paths.js';
|
||||
import { loadPrompt } from '../../services/prompt-manager.js';
|
||||
import type { ActivityLogger } from '../../types/activity-logger.js';
|
||||
import type { ReconciliationClass } from '../../types/reconciliation.js';
|
||||
import type { SarifRef } from '../sast/types.js';
|
||||
import type { StructuredGenerationPort } from '../structured-generation.js';
|
||||
import { ArtifactIntegrityError, ReconciliationError, ReconciliationIoError, writeArtifact } from './artifact-store.js';
|
||||
import type { ReconciliationObservation } from './contracts.js';
|
||||
import { extractContext } from './sast/context-extractor.js';
|
||||
import { CWE_TO_CATEGORY, unmappedMapping, vulnerabilityClassToCategory } from './sast/cwe-mapper.js';
|
||||
import { runSastEnrichmentBatch, type SastEnrichmentBatchOutcome } from './sast/enrichment/batch.js';
|
||||
import { buildSastObservation, mintSastProducerId, sourceLocationFromContext } from './sast/enrichment/policy.js';
|
||||
import { enrichmentPromptName } from './sast/enrichment/schema.js';
|
||||
import {
|
||||
EnrichmentAttemptError,
|
||||
pairEnrichedVulnerabilities,
|
||||
type ValidationResult,
|
||||
} from './sast/enrichment/validate.js';
|
||||
import { readPinnedSarif, SarifIntakeError } from './sast/intake.js';
|
||||
import { parseSarifContent, SarifDocumentError } from './sast/sarif-parser.js';
|
||||
import type { ClassifiedFinding, DroppedSarifFinding, ParsedSarif, ParsedSarifFinding } from './sast/types.js';
|
||||
import type {
|
||||
EnrichSuccess,
|
||||
StageMetrics,
|
||||
SupplementalDropCounts,
|
||||
SupplementalDroppedFinding,
|
||||
SupplementalObservationsBody,
|
||||
} from './stage-contracts.js';
|
||||
|
||||
const ENRICHMENT_MAX_TOKENS = 32768;
|
||||
|
||||
export interface EnrichClassSastObservationsInput {
|
||||
sessionId: string;
|
||||
vulnerabilityClass: ReconciliationClass;
|
||||
sarif?: SarifRef;
|
||||
}
|
||||
|
||||
export interface EnrichClassSastObservationsDeps<TModelContext> {
|
||||
generation: StructuredGenerationPort<TModelContext>;
|
||||
modelContextFor: (input: EnrichClassSastObservationsInput) => TModelContext;
|
||||
workspacesDir?: string;
|
||||
signalFor?: () => AbortSignal | undefined;
|
||||
promptLoader?: (vulnerabilityClass: ReconciliationClass) => Promise<string>;
|
||||
onMetrics?: (metrics: StageMetrics) => void;
|
||||
logger?: ActivityLogger;
|
||||
}
|
||||
|
||||
export class SastEnrichmentInputError extends ReconciliationError {
|
||||
constructor(message: string) {
|
||||
super(message, false, 'SastEnrichmentInputError');
|
||||
}
|
||||
}
|
||||
|
||||
export class SastEnrichmentModelError extends ReconciliationError {
|
||||
readonly metrics: StageMetrics;
|
||||
|
||||
constructor(message: string, metrics: StageMetrics, retryable = true) {
|
||||
super(message, retryable, 'SastEnrichmentModelError');
|
||||
this.metrics = metrics;
|
||||
}
|
||||
}
|
||||
|
||||
/** Cancellation marker translated into Temporal cancellation by the activity wrapper. */
|
||||
export class SastEnrichmentCancelledError extends Error {
|
||||
constructor() {
|
||||
super('SAST enrichment was cancelled');
|
||||
this.name = 'AbortError';
|
||||
}
|
||||
}
|
||||
|
||||
const NOOP_LOGGER: ActivityLogger = {
|
||||
info() {},
|
||||
warn() {},
|
||||
error() {},
|
||||
};
|
||||
|
||||
function zeroDrops(): SupplementalDropCounts {
|
||||
return {
|
||||
unknown_cwe: 0,
|
||||
other_category: 0,
|
||||
malformed: 0,
|
||||
orphaned: 0,
|
||||
duplicate_sast_id: 0,
|
||||
enrichment_dropped: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function zeroMetrics(): StageMetrics {
|
||||
return { costUsd: 0, modelCalls: 0, inputTokens: 0, outputTokens: 0 };
|
||||
}
|
||||
|
||||
interface SupplementalCounts {
|
||||
sarif_findings: number;
|
||||
sarif_dropped: number;
|
||||
sent: number;
|
||||
returned: number;
|
||||
accepted: number;
|
||||
}
|
||||
|
||||
function emptySupplementalCounts(): SupplementalCounts {
|
||||
return { sarif_findings: 0, sarif_dropped: 0, sent: 0, returned: 0, accepted: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Names every finding that was sent for enrichment and did not come back paired.
|
||||
*
|
||||
* A count alone cannot say what was lost. Identity is recovered from the sent side by set
|
||||
* difference rather than from the response, because a malformed response may carry no usable
|
||||
* id at all — which is exactly what made it malformed.
|
||||
*/
|
||||
export function computeDroppedFindings(
|
||||
findingsById: ReadonlyMap<number, ClassifiedFinding>,
|
||||
paired: readonly { sastId: number }[],
|
||||
vulnerabilityClass: ReconciliationClass,
|
||||
): SupplementalDroppedFinding[] {
|
||||
const pairedSastIds = new Set(paired.map(({ sastId }) => sastId));
|
||||
const dropped = [...findingsById.entries()]
|
||||
.filter(([sastId]) => !pairedSastIds.has(sastId))
|
||||
.sort(([first], [second]) => first - second)
|
||||
.map(([sastId, finding]) => ({
|
||||
producer_id: mintSastProducerId(vulnerabilityClass, sastId),
|
||||
sast_id: sastId,
|
||||
sast_source_location: sourceLocationFromContext(finding.context),
|
||||
}));
|
||||
// Every sent finding is either accepted or named as dropped. A shortfall means the validator
|
||||
// paired an id that was never sent, or paired one twice, which is an integrity fault rather
|
||||
// than model output to accept.
|
||||
if (dropped.length + paired.length !== findingsById.size) {
|
||||
throw new ArtifactIntegrityError('SAST enrichment dropped-identity accounting failed');
|
||||
}
|
||||
return dropped;
|
||||
}
|
||||
|
||||
async function writeSupplemental(
|
||||
input: EnrichClassSastObservationsInput,
|
||||
workspacesDir: string,
|
||||
observations: ReconciliationObservation[],
|
||||
drops: SupplementalDropCounts,
|
||||
droppedFindings: SupplementalDroppedFinding[],
|
||||
counts: SupplementalCounts,
|
||||
metrics: StageMetrics,
|
||||
): Promise<EnrichSuccess> {
|
||||
const body: SupplementalObservationsBody = {
|
||||
observations,
|
||||
provenance: [],
|
||||
...(input.sarif !== undefined && { sarif: input.sarif }),
|
||||
drops,
|
||||
dropped_findings: droppedFindings,
|
||||
};
|
||||
const ref = await writeArtifact({
|
||||
sessionId: input.sessionId,
|
||||
workspacesDir,
|
||||
artifactKind: 'supplemental-observations',
|
||||
vulnerabilityClass: input.vulnerabilityClass,
|
||||
body,
|
||||
inputs: [],
|
||||
counts: { observations: observations.length, ...counts, ...drops },
|
||||
});
|
||||
return { ref, metrics };
|
||||
}
|
||||
|
||||
function droppedFindingBelongsToClass(finding: DroppedSarifFinding, vulnerabilityClass: ReconciliationClass): boolean {
|
||||
const mapping =
|
||||
finding.ruleId === undefined
|
||||
? unmappedMapping('malformed')
|
||||
: (CWE_TO_CATEGORY[finding.ruleId] ?? unmappedMapping(finding.ruleId));
|
||||
return mapping.category === vulnerabilityClassToCategory(vulnerabilityClass);
|
||||
}
|
||||
|
||||
function classifyFindings(
|
||||
findings: readonly ParsedSarifFinding[],
|
||||
vulnerabilityClass: ReconciliationClass,
|
||||
drops: SupplementalDropCounts,
|
||||
): ClassifiedFinding[] {
|
||||
const target = vulnerabilityClassToCategory(vulnerabilityClass);
|
||||
const classified: ClassifiedFinding[] = [];
|
||||
for (const finding of findings) {
|
||||
const known = CWE_TO_CATEGORY[finding.result.ruleId];
|
||||
const mapping = known ?? unmappedMapping(finding.result.ruleId);
|
||||
if (mapping.category !== target) {
|
||||
drops.other_category++;
|
||||
continue;
|
||||
}
|
||||
if (known === undefined) drops.unknown_cwe++;
|
||||
classified.push({ context: extractContext(finding.result), mapping, phase: finding.phase });
|
||||
}
|
||||
return classified;
|
||||
}
|
||||
|
||||
async function defaultPromptLoader(vulnerabilityClass: ReconciliationClass, logger: ActivityLogger): Promise<string> {
|
||||
return loadPrompt(
|
||||
enrichmentPromptName(vulnerabilityClass),
|
||||
{
|
||||
webUrl: 'https://not-applicable.invalid',
|
||||
repoPath: '/repo',
|
||||
AUTH_STATE_FILE: '/tmp/auth-state.json',
|
||||
},
|
||||
null,
|
||||
false,
|
||||
logger,
|
||||
);
|
||||
}
|
||||
|
||||
function isCancellation(error: unknown, signal: AbortSignal | undefined): boolean {
|
||||
if (signal?.aborted !== true) return false;
|
||||
|
||||
let current: unknown = error;
|
||||
const seen = new Set<unknown>();
|
||||
for (let depth = 0; depth < 8 && current !== undefined && current !== null && !seen.has(current); depth++) {
|
||||
if (current === signal.reason) return true;
|
||||
seen.add(current);
|
||||
const errorName = current instanceof Error ? current.name : undefined;
|
||||
if (errorName === 'AbortError' || errorName === 'CancelledFailure') return true;
|
||||
current = current instanceof Error ? current.cause : undefined;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isRetryableFileSystemError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) return false;
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (
|
||||
code !== undefined &&
|
||||
['EACCES', 'EAGAIN', 'EBUSY', 'EIO', 'EMFILE', 'ENFILE', 'ENOMEM', 'ENOSPC', 'EPERM', 'EROFS', 'ESTALE'].includes(
|
||||
code,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return /\b(?:EACCES|EAGAIN|EBUSY|EIO|EMFILE|ENFILE|ENOMEM|ENOSPC|EPERM|EROFS|ESTALE)\b/u.test(error.message);
|
||||
}
|
||||
|
||||
function metricsFailure(
|
||||
onMetrics: ((metrics: StageMetrics) => void) | undefined,
|
||||
metrics: StageMetrics,
|
||||
): ReconciliationIoError | undefined {
|
||||
try {
|
||||
onMetrics?.({ ...metrics });
|
||||
return undefined;
|
||||
} catch {
|
||||
return new ReconciliationIoError('Unable to record SAST enrichment usage');
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the pure stage with explicit model, cancellation, metrics, and logging bindings. */
|
||||
export function createEnrichClassSastObservations<TModelContext>(
|
||||
deps: EnrichClassSastObservationsDeps<TModelContext>,
|
||||
): (input: EnrichClassSastObservationsInput) => Promise<EnrichSuccess> {
|
||||
return async function enrichClassSastObservations(input: EnrichClassSastObservationsInput): Promise<EnrichSuccess> {
|
||||
const logger = deps.logger ?? NOOP_LOGGER;
|
||||
const workspacesDir = deps.workspacesDir ?? WORKSPACES_DIR;
|
||||
const drops = zeroDrops();
|
||||
|
||||
// 1. Absence is a successful, exact empty artifact and never resolves a model.
|
||||
if (input.sarif === undefined) {
|
||||
return writeSupplemental(input, workspacesDir, [], drops, [], emptySupplementalCounts(), zeroMetrics());
|
||||
}
|
||||
|
||||
// 2. The reference is contained and rehashed before any JSON parsing.
|
||||
let bytes: Buffer;
|
||||
try {
|
||||
bytes = await readPinnedSarif(input.sessionId, input.sarif, workspacesDir);
|
||||
} catch (error) {
|
||||
if (error instanceof SarifIntakeError) {
|
||||
if (error.kind === 'io') {
|
||||
throw new ReconciliationIoError('Unable to read the pinned SARIF input');
|
||||
}
|
||||
throw new SastEnrichmentInputError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// 3. Run/document violations fail the class. Per-finding violations drop only that finding.
|
||||
let parsed: ParsedSarif;
|
||||
try {
|
||||
parsed = parseSarifContent(bytes.toString('utf8'));
|
||||
} catch (error) {
|
||||
if (error instanceof SarifDocumentError) throw new SastEnrichmentInputError(error.message);
|
||||
throw error;
|
||||
}
|
||||
const classDropped = parsed.droppedFindings.filter((finding) =>
|
||||
droppedFindingBelongsToClass(finding, input.vulnerabilityClass),
|
||||
).length;
|
||||
drops.malformed = classDropped;
|
||||
const classified = classifyFindings(parsed.findings, input.vulnerabilityClass, drops);
|
||||
const counts: SupplementalCounts = {
|
||||
sarif_findings: classified.length,
|
||||
sarif_dropped: classDropped,
|
||||
sent: 0,
|
||||
returned: 0,
|
||||
accepted: 0,
|
||||
};
|
||||
|
||||
// 4. A schema-valid empty current class batch is another zero-request path.
|
||||
if (classified.length === 0) {
|
||||
return writeSupplemental(input, workspacesDir, [], drops, [], counts, zeroMetrics());
|
||||
}
|
||||
|
||||
const signal = deps.signalFor?.();
|
||||
if (signal?.aborted === true) throw new SastEnrichmentCancelledError();
|
||||
|
||||
let prompt: string;
|
||||
try {
|
||||
prompt = deps.promptLoader
|
||||
? await deps.promptLoader(input.vulnerabilityClass)
|
||||
: await defaultPromptLoader(input.vulnerabilityClass, logger);
|
||||
} catch (error) {
|
||||
if (isRetryableFileSystemError(error)) {
|
||||
throw new ReconciliationIoError('Unable to read the SAST enrichment prompt');
|
||||
}
|
||||
throw new SastEnrichmentInputError('SAST enrichment prompt is unavailable');
|
||||
}
|
||||
|
||||
// `sastId` is a plain 0-based index into this batch, not a producer ID: the model only ever sees
|
||||
// this small integer (as `_sastId` below), never the eventual SAST-namespaced producer ID that
|
||||
// `mintSastProducerId` derives from it after a response comes back paired.
|
||||
const idAssigned = classified.map((finding, index) => ({ sastId: index, finding }));
|
||||
const findingsJson = JSON.stringify(
|
||||
idAssigned.map(({ sastId, finding }) => ({ _sastId: sastId, ...finding.context })),
|
||||
null,
|
||||
2,
|
||||
);
|
||||
const findingsById = new Map(idAssigned.map(({ sastId, finding }) => [sastId, finding]));
|
||||
const metrics = zeroMetrics();
|
||||
counts.sent = classified.length;
|
||||
|
||||
// 5. One nonempty class batch makes exactly one billable request.
|
||||
let outcome: SastEnrichmentBatchOutcome;
|
||||
metrics.modelCalls = 1;
|
||||
try {
|
||||
outcome = await runSastEnrichmentBatch(deps.generation, deps.modelContextFor(input), {
|
||||
vulnerabilityClass: input.vulnerabilityClass,
|
||||
prompt,
|
||||
findingsJson,
|
||||
maxTokens: ENRICHMENT_MAX_TOKENS,
|
||||
...(signal !== undefined && { signal }),
|
||||
});
|
||||
} catch (error) {
|
||||
if (isCancellation(error, signal)) throw new SastEnrichmentCancelledError();
|
||||
metricsFailure(deps.onMetrics, metrics);
|
||||
throw new SastEnrichmentModelError('SAST enrichment request failed', { ...metrics });
|
||||
}
|
||||
metrics.costUsd = outcome.usage.costUsd;
|
||||
metrics.inputTokens = outcome.usage.inputTokens;
|
||||
metrics.outputTokens = outcome.usage.outputTokens;
|
||||
// Record usage now so spend is captured even on a later abort or integrity failure, but hold any
|
||||
// ledger error and rethrow it only after the model-outcome and accounting checks below, so a
|
||||
// metrics-write fault never masks a real enrichment failure.
|
||||
const usageLedgerFailure = metricsFailure(deps.onMetrics, metrics);
|
||||
|
||||
if (outcome.status === 'aborted') throw new SastEnrichmentCancelledError();
|
||||
if (outcome.status === 'failed') {
|
||||
throw new SastEnrichmentModelError(outcome.message, { ...metrics }, !outcome.terminal);
|
||||
}
|
||||
|
||||
// 6. Pair by the code-owned id and account for every returned and sent element.
|
||||
let validated: ValidationResult;
|
||||
try {
|
||||
validated = pairEnrichedVulnerabilities(outcome.vulnerabilities, findingsById, input.vulnerabilityClass);
|
||||
} catch (error) {
|
||||
if (error instanceof EnrichmentAttemptError) {
|
||||
throw new SastEnrichmentModelError(error.message, { ...metrics });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Every returned element must land in exactly one bucket: paired, malformed, orphaned, or
|
||||
// duplicate. If the buckets do not sum to the returned count, the validator dropped or
|
||||
// double-counted something, which is an integrity fault rather than model output to accept.
|
||||
const { paired, counts: validationCounts } = validated;
|
||||
const accountedReturned =
|
||||
paired.length + validationCounts.malformed + validationCounts.orphaned + validationCounts.duplicate_sast_id;
|
||||
if (accountedReturned !== validationCounts.returned) {
|
||||
throw new ArtifactIntegrityError('SAST enrichment returned-output accounting failed');
|
||||
}
|
||||
if (paired.length > classified.length) {
|
||||
throw new ArtifactIntegrityError('SAST enrichment accepted more findings than were sent');
|
||||
}
|
||||
if (usageLedgerFailure !== undefined) throw usageLedgerFailure;
|
||||
|
||||
drops.malformed += validationCounts.malformed;
|
||||
drops.orphaned = validationCounts.orphaned;
|
||||
drops.duplicate_sast_id = validationCounts.duplicate_sast_id;
|
||||
drops.enrichment_dropped = classified.length - paired.length;
|
||||
counts.returned = validationCounts.returned;
|
||||
counts.accepted = paired.length;
|
||||
|
||||
const droppedFindings = computeDroppedFindings(findingsById, paired, input.vulnerabilityClass);
|
||||
|
||||
const pairedInSarifOrder = [...paired].sort((first, second) => first.sastId - second.sastId);
|
||||
const observations = pairedInSarifOrder.map(({ finding, sastId, evidence }) =>
|
||||
buildSastObservation(mintSastProducerId(input.vulnerabilityClass, sastId), evidence, finding),
|
||||
);
|
||||
|
||||
if (drops.enrichment_dropped > 0) {
|
||||
const droppedSummary = droppedFindings
|
||||
.map(
|
||||
({ producer_id, sast_source_location }) =>
|
||||
`${producer_id} (${sast_source_location.rule_id} at ${sast_source_location.file}:${sast_source_location.line})`,
|
||||
)
|
||||
.join(', ');
|
||||
logger.warn(
|
||||
`Static-analysis enrichment: ${drops.enrichment_dropped} of ${counts.sent} findings could not be enriched and were used as-is: ${droppedSummary}.`,
|
||||
);
|
||||
}
|
||||
if (drops.unknown_cwe > 0) {
|
||||
logger.info(
|
||||
`Static-analysis enrichment: ${drops.unknown_cwe} findings had an unrecognised CWE and were grouped under "miscellaneous".`,
|
||||
);
|
||||
}
|
||||
return writeSupplemental(input, workspacesDir, observations, drops, droppedFindings, counts, metrics);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
// 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.
|
||||
|
||||
/** Pass 1 task formation over current observations only. */
|
||||
|
||||
import path from 'node:path';
|
||||
import { DEFAULT_DELIVERABLES_SUBDIR, WORKSPACES_DIR } from '../../paths.js';
|
||||
import { loadPrompt } from '../../services/prompt-manager.js';
|
||||
import type { ActivityLogger } from '../../types/activity-logger.js';
|
||||
import type { ReconciliationClass } from '../../types/reconciliation.js';
|
||||
import { materializeSourceJail } from '../pi/source-jail.js';
|
||||
import {
|
||||
isTaskFormationFallbackReason,
|
||||
type TaskFormationExecutionContext,
|
||||
type TaskFormationExecutor,
|
||||
TaskFormationExecutorError,
|
||||
type TaskFormationExecutorResult,
|
||||
type TaskFormationFallbackReason,
|
||||
type TaskFormationUsage,
|
||||
taskFormationExecutor,
|
||||
} from '../pi/task-formation-executor.js';
|
||||
import { ArtifactIntegrityError, ReconciliationIoError, readArtifact, writeArtifact } from './artifact-store.js';
|
||||
import type { ArtifactRef, ReconciliationObservation } from './contracts.js';
|
||||
import { mintLabels } from './labels.js';
|
||||
import { findLeakedProducerIds, toObservationView } from './observation-view.js';
|
||||
import { combineObservations } from './observations.js';
|
||||
import type { FormSuccess, StageMetrics, TaskFormationBody, TaskFormationInput } from './stage-contracts.js';
|
||||
import { SINGLETON_FALLBACK } from './stage-contracts.js';
|
||||
import { createValidatingSubmitTool } from './submit-validation.js';
|
||||
import { acceptTaskGroups, buildTaskFormationSchema, findTaskFormationProblems } from './task-formation-schema.js';
|
||||
|
||||
// Two copies of the same pattern, not one shared regex: the global-flagged one is used with
|
||||
// .replace() below, while the non-global one is used with .test(). A global regex carries a
|
||||
// stateful lastIndex across calls to .test(), so reusing one instance for both would make a later
|
||||
// leak check silently start scanning mid-string instead of from the beginning.
|
||||
const INTERNAL_REFERENCE_PATTERN = /\b(?:AUTHZ|MISC|AUTH|INJ|XSS|SSRF)(?:-(?:VULN|SAST))?-[0-9]+\b/gu;
|
||||
const INTERNAL_REFERENCE_LEAK_PATTERN = /\b(?:AUTHZ|MISC|AUTH|INJ|XSS|SSRF)(?:-(?:VULN|SAST))?-[0-9]+\b/u;
|
||||
const TRANSIENT_IO_CODE_PATTERN = /\b(?:EAGAIN|EBUSY|EIO|EMFILE|ENFILE|ENOMEM|ENOSPC|EROFS|ETIMEDOUT)\b/u;
|
||||
// The model-facing forbidden-key set for the task-formation boundary. It is a sibling of, but not
|
||||
// identical to, `FORBIDDEN_PUBLISHED_KEYS` in publish.ts and prepare.ts: this one guards the
|
||||
// pre-grouping observation view (which can still carry an `ID` or `merged_from` from an earlier
|
||||
// stage's shape), while the published-queue set guards the post-materialization task shape. Each
|
||||
// must independently list every internal-only key for its own boundary; neither can be derived from
|
||||
// the other.
|
||||
const FORBIDDEN_MODEL_KEYS = new Set([
|
||||
'ID',
|
||||
'_sastId',
|
||||
'merged_from',
|
||||
'novelty',
|
||||
'observation_key',
|
||||
'primary_preference',
|
||||
'producer_id',
|
||||
]);
|
||||
|
||||
export interface FormClassExploitTasksInput {
|
||||
readonly sessionId: string;
|
||||
readonly vulnerabilityClass: ReconciliationClass;
|
||||
readonly repositoryPath: string;
|
||||
readonly producerRef: ArtifactRef<'producer-observations'>;
|
||||
readonly supplementalRef: ArtifactRef<'supplemental-observations'>;
|
||||
readonly deliverablesSubdir?: string;
|
||||
readonly webUrl?: string;
|
||||
}
|
||||
|
||||
export interface FormClassExploitTasksResult extends FormSuccess {
|
||||
readonly model?: string;
|
||||
}
|
||||
|
||||
export interface FormClassExploitTasksDeps {
|
||||
readonly executor?: TaskFormationExecutor;
|
||||
readonly workspacesDir?: string;
|
||||
readonly signalFor?: () => AbortSignal | undefined;
|
||||
readonly executionContextFor?: () => TaskFormationExecutionContext | undefined;
|
||||
readonly executorTimeoutMsFor?: () => number | undefined;
|
||||
readonly onMetrics?: (metrics: StageMetrics) => void;
|
||||
readonly logger?: ActivityLogger;
|
||||
}
|
||||
|
||||
/** Failure that Temporal may retry and, only after exhaustion, classify for singleton fallback. */
|
||||
export class TaskFormationModelError extends Error {
|
||||
override readonly name = 'TaskFormationModelError';
|
||||
readonly failureType = 'TaskFormationModelError' as const;
|
||||
readonly retryable: boolean;
|
||||
readonly fallbackReason: TaskFormationFallbackReason | undefined;
|
||||
readonly metrics: StageMetrics;
|
||||
|
||||
constructor(options: {
|
||||
message: string;
|
||||
retryable: boolean;
|
||||
fallbackReason?: TaskFormationFallbackReason;
|
||||
metrics: StageMetrics;
|
||||
}) {
|
||||
super(options.message);
|
||||
this.retryable = options.retryable;
|
||||
this.fallbackReason = options.fallbackReason;
|
||||
this.metrics = options.metrics;
|
||||
}
|
||||
}
|
||||
|
||||
const NOOP_LOGGER: ActivityLogger = {
|
||||
info() {},
|
||||
warn() {},
|
||||
error() {},
|
||||
};
|
||||
|
||||
function zeroMetrics(): StageMetrics {
|
||||
return { costUsd: 0, modelCalls: 0, inputTokens: 0, outputTokens: 0 };
|
||||
}
|
||||
|
||||
function metricsFromUsage(usage: TaskFormationUsage, modelCalls: number): StageMetrics {
|
||||
return {
|
||||
costUsd: usage.costUsd,
|
||||
modelCalls,
|
||||
inputTokens: usage.inputTokens,
|
||||
outputTokens: usage.outputTokens,
|
||||
};
|
||||
}
|
||||
|
||||
function cancellationError(signal: AbortSignal): Error {
|
||||
if (signal.reason instanceof Error) return signal.reason;
|
||||
return new DOMException('Task formation was cancelled.', 'AbortError');
|
||||
}
|
||||
|
||||
function checkCancellation(signal: AbortSignal | undefined): void {
|
||||
if (signal?.aborted === true) throw cancellationError(signal);
|
||||
}
|
||||
|
||||
// The depth cap bounds how far this walks an error's `cause`/`context`/`originalError` chain
|
||||
// looking for a transient I/O code. It exists only to stop a pathological or circular chain from
|
||||
// recursing forever; ordinary wrapped errors are a handful of layers deep at most.
|
||||
function isTransientPromptIoFailure(error: unknown, depth = 0): boolean {
|
||||
if (depth > 4) return false;
|
||||
if (typeof error === 'string') return TRANSIENT_IO_CODE_PATTERN.test(error);
|
||||
if (typeof error !== 'object' || error === null) return false;
|
||||
|
||||
if ('code' in error && typeof error.code === 'string' && TRANSIENT_IO_CODE_PATTERN.test(error.code)) return true;
|
||||
if ('cause' in error && isTransientPromptIoFailure(error.cause, depth + 1)) return true;
|
||||
if ('context' in error && isTransientPromptIoFailure(error.context, depth + 1)) return true;
|
||||
if ('originalError' in error && isTransientPromptIoFailure(error.originalError, depth + 1)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// This is a distinct pass from the producer-ID redaction in observation-view.ts: that one redacts
|
||||
// producer IDs it already knows about (because they were passed in), while this one redacts any
|
||||
// string that merely looks like an internal class/reference token (e.g. an INJ-VULN-03-shaped
|
||||
// substring), including one that might appear inside free-text evidence rather than as an ID field.
|
||||
function scrubInternalReferences(value: unknown): unknown {
|
||||
if (typeof value === 'string') return value.replace(INTERNAL_REFERENCE_PATTERN, '[redacted]');
|
||||
if (Array.isArray(value)) return value.map(scrubInternalReferences);
|
||||
if (value !== null && typeof value === 'object') {
|
||||
const output: Record<string, unknown> = {};
|
||||
for (const [key, item] of Object.entries(value)) output[key] = scrubInternalReferences(item);
|
||||
return output;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function findForbiddenKeys(value: unknown, found: Set<string> = new Set()): Set<string> {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) findForbiddenKeys(item, found);
|
||||
return found;
|
||||
}
|
||||
if (value !== null && typeof value === 'object') {
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
if (FORBIDDEN_MODEL_KEYS.has(key)) found.add(key);
|
||||
findForbiddenKeys(item, found);
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
function taskFormationPromptName(vulnerabilityClass: ReconciliationClass): string {
|
||||
return `task-formation-${vulnerabilityClass}`;
|
||||
}
|
||||
|
||||
// A filesystem fault reading the prompt is retryable (the prompt file is expected to exist and a
|
||||
// transient read failure should not fail the class outright), while any other failure means the
|
||||
// prompt itself is missing or unreadable content, which Temporal should not spend retries on.
|
||||
async function loadClassPolicy(
|
||||
vulnerabilityClass: ReconciliationClass,
|
||||
jailPath: string,
|
||||
webUrl: string,
|
||||
logger: ActivityLogger,
|
||||
): Promise<string> {
|
||||
try {
|
||||
return await loadPrompt(
|
||||
taskFormationPromptName(vulnerabilityClass),
|
||||
{ webUrl, repoPath: jailPath, AUTH_STATE_FILE: '' },
|
||||
null,
|
||||
false,
|
||||
logger,
|
||||
);
|
||||
} catch (error) {
|
||||
if (isTransientPromptIoFailure(error)) {
|
||||
throw new ReconciliationIoError('The fixed task-formation class policy prompt could not be read');
|
||||
}
|
||||
throw new ArtifactIntegrityError('The fixed task-formation class policy prompt is unavailable');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the exact prompt-facing input for task formation, so that the model that groups
|
||||
* observations into exploitation tasks never sees a producer ID or other internal identity.
|
||||
*
|
||||
* Each observation gets a short opaque label (minted disjoint from every producer ID in this
|
||||
* batch) that the model uses to refer to it instead of its real identity; the label-to-ID mapping
|
||||
* stays code-side. This is the point in the pipeline where the internal-identity boundary is
|
||||
* actually built, not merely checked: if this function stopped minting labels and passed producer
|
||||
* IDs through instead, a downstream exploit agent reading the eventual published queue would learn
|
||||
* exactly which scan producer (and by extension, which internal class/source combination) found
|
||||
* each vulnerability, which the reconciliation contract exists to prevent.
|
||||
*/
|
||||
function buildModelInput(
|
||||
observations: readonly ReconciliationObservation[],
|
||||
vulnerabilityClass: ReconciliationClass,
|
||||
): {
|
||||
readonly input: TaskFormationInput;
|
||||
readonly labelToProducerId: ReadonlyMap<string, string>;
|
||||
readonly serialized: string;
|
||||
} {
|
||||
const producerIds = observations.map((observation) => observation.producer_id);
|
||||
const labels = mintLabels(observations.length, { taken: new Set(producerIds) });
|
||||
const labelToProducerId = new Map<string, string>();
|
||||
const queued_findings = observations.map((observation, index) => {
|
||||
const label = labels[index] as string;
|
||||
labelToProducerId.set(label, observation.producer_id);
|
||||
const projected = toObservationView(observation, vulnerabilityClass, producerIds);
|
||||
return { label, entry: scrubInternalReferences(projected) };
|
||||
}) as TaskFormationInput['queued_findings'];
|
||||
const input: TaskFormationInput = { queued_findings };
|
||||
|
||||
// First gate: a structural check that no forbidden key name made it into the projected shape at
|
||||
// all. The serialized-string check below is the second, independent gate against the same
|
||||
// failure mode, catching a producer ID or reference token that leaked as a value rather than a key.
|
||||
const forbiddenKeys = findForbiddenKeys(input);
|
||||
if (forbiddenKeys.size > 0) {
|
||||
throw new ArtifactIntegrityError('An internal key survived the task-formation positive projection');
|
||||
}
|
||||
|
||||
// Final gate before the prompt bytes are built: fail closed if any producer ID or internal
|
||||
// reference token survived projection and scrubbing, so the model never sees internal identity.
|
||||
const serialized = JSON.stringify(input, null, 2);
|
||||
if (findLeakedProducerIds(serialized, producerIds).length > 0 || INTERNAL_REFERENCE_LEAK_PATTERN.test(serialized)) {
|
||||
throw new ArtifactIntegrityError('An internal reference survived the task-formation positive projection');
|
||||
}
|
||||
return { input, labelToProducerId, serialized };
|
||||
}
|
||||
|
||||
function validateInputRefs(input: FormClassExploitTasksInput): void {
|
||||
if (
|
||||
input.producerRef.vulnerabilityClass !== input.vulnerabilityClass ||
|
||||
input.supplementalRef.vulnerabilityClass !== input.vulnerabilityClass
|
||||
) {
|
||||
throw new ArtifactIntegrityError('Task formation received a cross-class artifact reference');
|
||||
}
|
||||
}
|
||||
|
||||
async function writeFormationArtifact(
|
||||
input: FormClassExploitTasksInput,
|
||||
workspacesDir: string,
|
||||
body: TaskFormationBody,
|
||||
): Promise<ArtifactRef<'task-formation'>> {
|
||||
return writeArtifact({
|
||||
sessionId: input.sessionId,
|
||||
workspacesDir,
|
||||
artifactKind: 'task-formation',
|
||||
vulnerabilityClass: input.vulnerabilityClass,
|
||||
body,
|
||||
inputs: [
|
||||
{ artifactKind: 'producer-observations', sha256: input.producerRef.sha256 },
|
||||
{ artifactKind: 'supplemental-observations', sha256: input.supplementalRef.sha256 },
|
||||
],
|
||||
counts: {
|
||||
accepted_groups: body.groups.length,
|
||||
rejected_groups: body.rejected_group_count,
|
||||
dropped_unknown_labels: body.dropped_unknown_label_count,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Whether an exhausted error is one of the three locked semantic-fallback cases. */
|
||||
export function isSingletonFallbackEligible(error: unknown): error is TaskFormationModelError {
|
||||
return (
|
||||
error instanceof TaskFormationModelError && error.retryable && isTaskFormationFallbackReason(error.fallbackReason)
|
||||
);
|
||||
}
|
||||
|
||||
/** Return the sentinel only for an exhausted eligible model-stage failure; otherwise rethrow. */
|
||||
export function singletonFallbackAfterExhaustion(error: unknown): typeof SINGLETON_FALLBACK {
|
||||
if (isSingletonFallbackEligible(error)) return SINGLETON_FALLBACK;
|
||||
throw error;
|
||||
}
|
||||
|
||||
/** Build the Pass 1 stage with explicit executor, workspace, cancellation, and metric bindings. */
|
||||
export function createFormClassExploitTasks(
|
||||
deps: FormClassExploitTasksDeps = {},
|
||||
): (input: FormClassExploitTasksInput) => Promise<FormClassExploitTasksResult> {
|
||||
const executor = deps.executor ?? taskFormationExecutor;
|
||||
const workspacesDir = deps.workspacesDir ?? WORKSPACES_DIR;
|
||||
const logger = deps.logger ?? NOOP_LOGGER;
|
||||
|
||||
return async function formClassExploitTasks(input: FormClassExploitTasksInput): Promise<FormClassExploitTasksResult> {
|
||||
validateInputRefs(input);
|
||||
const signal = deps.signalFor?.();
|
||||
checkCancellation(signal);
|
||||
|
||||
const producer = await readArtifact(input.producerRef, input.sessionId, workspacesDir);
|
||||
const supplemental = await readArtifact(input.supplementalRef, input.sessionId, workspacesDir);
|
||||
checkCancellation(signal);
|
||||
const observations = combineObservations(producer.observations, supplemental.observations);
|
||||
|
||||
// Fewer than two observations can form no group, so skip the model entirely and write an empty
|
||||
// formation with zero cost. This is one of the paths that leaves `model_ran` false.
|
||||
if (observations.length < 2) {
|
||||
const body: TaskFormationBody = {
|
||||
model_ran: false,
|
||||
groups: [],
|
||||
rejected_group_count: 0,
|
||||
dropped_unknown_label_count: 0,
|
||||
};
|
||||
const metrics = zeroMetrics();
|
||||
const ref = await writeFormationArtifact(input, workspacesDir, body);
|
||||
deps.onMetrics?.(metrics);
|
||||
return { ref, metrics };
|
||||
}
|
||||
|
||||
const modelInput = buildModelInput(observations, input.vulnerabilityClass);
|
||||
const labelSet = new Set(modelInput.input.queued_findings.map(({ label }) => label));
|
||||
const submitTool = createValidatingSubmitTool(buildTaskFormationSchema([...labelSet]), (parameters) =>
|
||||
findTaskFormationProblems(parameters, labelSet),
|
||||
);
|
||||
const deliverablesPath = path.resolve(
|
||||
input.repositoryPath,
|
||||
input.deliverablesSubdir ?? DEFAULT_DELIVERABLES_SUBDIR,
|
||||
);
|
||||
const reconciliationWorkspacePath = path.resolve(workspacesDir, input.sessionId, '.shannon', 'reconciliation');
|
||||
// Task formation runs against a disposable copy of the source tree rather than the live
|
||||
// repository or the deliverables directory, so the model's tool calls during this stage cannot
|
||||
// read or modify anything outside what it was actually given to reason about.
|
||||
const jail = await materializeSourceJail({
|
||||
sourceRoot: input.repositoryPath,
|
||||
deliverablesPath,
|
||||
reconciliationWorkspacePath,
|
||||
...(signal !== undefined && { signal }),
|
||||
});
|
||||
|
||||
let formation: FormClassExploitTasksResult;
|
||||
try {
|
||||
const classPolicy = await loadClassPolicy(
|
||||
input.vulnerabilityClass,
|
||||
jail.dir,
|
||||
input.webUrl ?? 'https://not-applicable.invalid',
|
||||
logger,
|
||||
);
|
||||
checkCancellation(signal);
|
||||
|
||||
let modelResult: TaskFormationExecutorResult;
|
||||
try {
|
||||
const executorTimeoutMs = deps.executorTimeoutMsFor?.();
|
||||
modelResult = await executor.run({
|
||||
cwd: jail.dir,
|
||||
systemPrompt: classPolicy,
|
||||
modelContext: modelInput.serialized,
|
||||
deniedPaths: jail.deniedPaths,
|
||||
submitTool,
|
||||
signal: signal ?? new AbortController().signal,
|
||||
...(executorTimeoutMs !== undefined && { timeoutMs: executorTimeoutMs }),
|
||||
correlation: {
|
||||
...deps.executionContextFor?.(),
|
||||
stage: 'task-formation',
|
||||
vulnerabilityClass: input.vulnerabilityClass,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (!(error instanceof TaskFormationExecutorError)) throw error;
|
||||
if (error.failureKind === 'infrastructure') {
|
||||
throw new ReconciliationIoError(
|
||||
'Task-formation executor setup encountered a retryable infrastructure failure',
|
||||
);
|
||||
}
|
||||
if (error.failureKind !== 'model') throw error;
|
||||
const metrics = metricsFromUsage(error.usage, error.modelCalls);
|
||||
deps.onMetrics?.(metrics);
|
||||
throw new TaskFormationModelError({
|
||||
message: error.message,
|
||||
retryable: error.retryable,
|
||||
...(error.fallbackReason !== undefined && { fallbackReason: error.fallbackReason }),
|
||||
metrics,
|
||||
});
|
||||
}
|
||||
|
||||
const metrics = metricsFromUsage(modelResult.usage, modelResult.modelCalls);
|
||||
deps.onMetrics?.(metrics);
|
||||
checkCancellation(signal);
|
||||
const accepted = acceptTaskGroups(modelResult.output, labelSet);
|
||||
const groups = accepted.groups.map((group) => ({
|
||||
producer_ids: group.queue_labels.map((label) => {
|
||||
const producerId = modelInput.labelToProducerId.get(label);
|
||||
if (producerId === undefined) {
|
||||
throw new ArtifactIntegrityError('An accepted task-formation label has no observation mapping');
|
||||
}
|
||||
return producerId;
|
||||
}),
|
||||
reasoning: group.reasoning,
|
||||
}));
|
||||
const body: TaskFormationBody = {
|
||||
model_ran: true,
|
||||
groups,
|
||||
rejected_group_count: accepted.rejectedGroupCount,
|
||||
dropped_unknown_label_count: accepted.droppedUnknownLabelCount,
|
||||
};
|
||||
const ref = await writeFormationArtifact(input, workspacesDir, body);
|
||||
formation = { ref, metrics, model: `${modelResult.providerId}:${modelResult.modelId}` };
|
||||
} catch (error) {
|
||||
// A primary error — including cancellation — already owns the outcome, so a cleanup failure
|
||||
// is logged and swallowed rather than replacing that error's type or cause chain.
|
||||
try {
|
||||
await jail.cleanup();
|
||||
} catch {
|
||||
logger.error(
|
||||
'A temporary copy of your source code could not be removed after analysis. It is inside the scan workspace and is safe to delete.',
|
||||
{
|
||||
stage: 'task-formation',
|
||||
vulnerabilityClass: input.vulnerabilityClass,
|
||||
},
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Nothing else is in flight after a successful formation, so an unremoved or unverifiable jail
|
||||
// is the stage's outcome: it leaves a full copy of the scanned tree on disk and fails here.
|
||||
await jail.cleanup();
|
||||
return formation;
|
||||
};
|
||||
}
|
||||
|
||||
export const formClassExploitTasks = createFormClassExploitTasks();
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Opaque, call-local labels used at reconciliation model boundaries.
|
||||
*
|
||||
* The model groups observations by these labels instead of by producer ID, so no internal producer
|
||||
* identity has to cross into the prompt. The alphabet is consonants only, which keeps labels short
|
||||
* and avoids accidentally spelling real words.
|
||||
*/
|
||||
|
||||
export const LABEL_ALPHABET = 'bcdfghjkmnpqrstvwxz';
|
||||
export const LABEL_LENGTH = 4;
|
||||
|
||||
export interface MintLabelsOptions {
|
||||
taken?: ReadonlySet<string>;
|
||||
rng?: () => number;
|
||||
}
|
||||
|
||||
/** Mint distinct four-consonant labels disjoint from any supplied label space. */
|
||||
export function mintLabels(count: number, options: MintLabelsOptions = {}): string[] {
|
||||
if (!Number.isSafeInteger(count) || count < 0) {
|
||||
throw new Error(`mintLabels: count must be a non-negative safe integer, received ${count}`);
|
||||
}
|
||||
|
||||
// `taken` reserves label strings that must not be minted (for one class, the producer IDs
|
||||
// themselves), so a minted label can never collide with a value already meaningful to the caller.
|
||||
const used = new Set(options.taken);
|
||||
const capacity = LABEL_ALPHABET.length ** LABEL_LENGTH;
|
||||
if (count + used.size > capacity) {
|
||||
throw new Error(`mintLabels: cannot mint ${count} labels; alphabet space is ${capacity}`);
|
||||
}
|
||||
|
||||
const rng = options.rng ?? Math.random;
|
||||
const labels: string[] = [];
|
||||
while (labels.length < count) {
|
||||
let label = '';
|
||||
for (let index = 0; index < LABEL_LENGTH; index++) {
|
||||
const sample = rng();
|
||||
if (!Number.isFinite(sample) || sample < 0 || sample >= 1) {
|
||||
throw new Error('mintLabels: rng must return a finite value in [0, 1)');
|
||||
}
|
||||
label += LABEL_ALPHABET[Math.floor(sample * LABEL_ALPHABET.length)];
|
||||
}
|
||||
if (used.has(label)) continue;
|
||||
used.add(label);
|
||||
labels.push(label);
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
// 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.
|
||||
|
||||
/** Validation and committed reads for the durable class-publication manifest. */
|
||||
|
||||
import { readCommittedFile } from '../../services/git-manager.js';
|
||||
import { ALL_RECONCILIATION_CLASSES, type ReconciliationClass } from '../../types/reconciliation.js';
|
||||
import type { PublicationContract } from './contracts.js';
|
||||
import { mintTaskReferences } from './materialize-core.js';
|
||||
import { isProducerId, isTaskReference } from './refs.js';
|
||||
import { RECONCILIATION_SCHEMA_VERSION } from './schema-version.js';
|
||||
|
||||
const SHA256_PATTERN = /^[a-f0-9]{64}$/;
|
||||
const GIT_BLOB_PATTERN = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/;
|
||||
|
||||
/** One committed consumer file and the digest the manifest vouches for. */
|
||||
export interface ManifestConsumerFile {
|
||||
path: string;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
/** One stable task's producer lineage. OSS omits `novelty`. */
|
||||
export interface ManifestLineageEntry {
|
||||
primary: string;
|
||||
absorbed: string[];
|
||||
novelty?: 'new' | 'recurring';
|
||||
}
|
||||
|
||||
/** The schema-v1 durable completion marker for one class publication. */
|
||||
export interface PublicationManifest {
|
||||
session_id: string;
|
||||
vulnerability_class: ReconciliationClass;
|
||||
schema_version: 1;
|
||||
producer_queue: { path: string; blob_sha: string };
|
||||
consumer_files: ManifestConsumerFile[];
|
||||
input_digests: Array<{ artifactKind: string; sha256: string }>;
|
||||
lineage: Record<string, ManifestLineageEntry>;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function hasExactKeys(
|
||||
value: Record<string, unknown>,
|
||||
required: readonly string[],
|
||||
optional: readonly string[] = [],
|
||||
): boolean {
|
||||
const allowed = new Set([...required, ...optional]);
|
||||
const actual = Object.keys(value);
|
||||
return required.every((key) => key in value) && actual.every((key) => allowed.has(key));
|
||||
}
|
||||
|
||||
function isSafeRelativePath(value: unknown): value is string {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
value.length > 0 &&
|
||||
!value.startsWith('/') &&
|
||||
!value.startsWith('\\') &&
|
||||
!value.includes('\0') &&
|
||||
!value.split(/[\\/]/).some((segment) => segment === '' || segment === '.' || segment === '..')
|
||||
);
|
||||
}
|
||||
|
||||
function isProducerQueueIdentity(value: unknown): value is PublicationManifest['producer_queue'] {
|
||||
if (!isRecord(value) || !hasExactKeys(value, ['path', 'blob_sha'])) return false;
|
||||
return isSafeRelativePath(value.path) && typeof value.blob_sha === 'string' && GIT_BLOB_PATTERN.test(value.blob_sha);
|
||||
}
|
||||
|
||||
function isConsumerFile(value: unknown): value is ManifestConsumerFile {
|
||||
if (!isRecord(value) || !hasExactKeys(value, ['path', 'sha256'])) return false;
|
||||
return isSafeRelativePath(value.path) && typeof value.sha256 === 'string' && SHA256_PATTERN.test(value.sha256);
|
||||
}
|
||||
|
||||
function isInputDigest(value: unknown): value is PublicationManifest['input_digests'][number] {
|
||||
if (!isRecord(value) || !hasExactKeys(value, ['artifactKind', 'sha256'])) return false;
|
||||
return (
|
||||
typeof value.artifactKind === 'string' &&
|
||||
value.artifactKind.length > 0 &&
|
||||
typeof value.sha256 === 'string' &&
|
||||
SHA256_PATTERN.test(value.sha256)
|
||||
);
|
||||
}
|
||||
|
||||
function isProducerIdForClass(value: string, vulnerabilityClass: ReconciliationClass): boolean {
|
||||
return isProducerId(value, vulnerabilityClass, 'VULN') || isProducerId(value, vulnerabilityClass, 'SAST');
|
||||
}
|
||||
|
||||
function isLineageEntry(value: unknown, vulnerabilityClass: ReconciliationClass): value is ManifestLineageEntry {
|
||||
if (!isRecord(value) || !hasExactKeys(value, ['primary', 'absorbed'], ['novelty'])) return false;
|
||||
if (typeof value.primary !== 'string' || !isProducerIdForClass(value.primary, vulnerabilityClass)) return false;
|
||||
if (
|
||||
!Array.isArray(value.absorbed) ||
|
||||
!value.absorbed.every(
|
||||
(producerId) => typeof producerId === 'string' && isProducerIdForClass(producerId, vulnerabilityClass),
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return value.novelty === undefined || value.novelty === 'new' || value.novelty === 'recurring';
|
||||
}
|
||||
|
||||
/** Whether a decoded value is a complete schema-v1 publication manifest. */
|
||||
export function isManifest(value: unknown): value is PublicationManifest {
|
||||
if (
|
||||
!isRecord(value) ||
|
||||
!hasExactKeys(value, [
|
||||
'session_id',
|
||||
'vulnerability_class',
|
||||
'schema_version',
|
||||
'producer_queue',
|
||||
'consumer_files',
|
||||
'input_digests',
|
||||
'lineage',
|
||||
])
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
typeof value.session_id !== 'string' ||
|
||||
value.session_id.length === 0 ||
|
||||
!ALL_RECONCILIATION_CLASSES.includes(value.vulnerability_class as ReconciliationClass) ||
|
||||
value.schema_version !== RECONCILIATION_SCHEMA_VERSION ||
|
||||
!isProducerQueueIdentity(value.producer_queue) ||
|
||||
!Array.isArray(value.consumer_files) ||
|
||||
!value.consumer_files.every(isConsumerFile) ||
|
||||
!Array.isArray(value.input_digests) ||
|
||||
!value.input_digests.every(isInputDigest) ||
|
||||
!isRecord(value.lineage)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const vulnerabilityClass = value.vulnerability_class as ReconciliationClass;
|
||||
const consumerPaths = value.consumer_files.map((consumer) => consumer.path);
|
||||
if (new Set(consumerPaths).size !== consumerPaths.length) return false;
|
||||
|
||||
// A coherent publication is derived from exactly the three stage artifacts, one of each kind.
|
||||
// A different count or a repeated kind means the manifest was not built from a complete lineage.
|
||||
const inputKinds = value.input_digests.map((input) => input.artifactKind);
|
||||
if (inputKinds.length !== 3 || new Set(inputKinds).size !== inputKinds.length) return false;
|
||||
if (
|
||||
!['producer-observations', 'supplemental-observations', 'fixed-tasks'].every((kind) => inputKinds.includes(kind))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Lineage keys must be the dense minted references PREFIX-01..PREFIX-NN in order, and every
|
||||
// producer ID across all entries must be unique. This is the same task numbering the published
|
||||
// queue carries, so a manifest that renumbers or repeats a producer cannot pass.
|
||||
const lineageEntries = Object.entries(value.lineage);
|
||||
const expectedTaskReferences = mintTaskReferences(lineageEntries.length, vulnerabilityClass);
|
||||
const producerIds = new Set<string>();
|
||||
for (const [index, [taskReference, entry]] of lineageEntries.entries()) {
|
||||
if (
|
||||
!isTaskReference(taskReference, vulnerabilityClass) ||
|
||||
taskReference !== expectedTaskReferences[index] ||
|
||||
!isLineageEntry(entry, vulnerabilityClass)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const typedEntry = entry as ManifestLineageEntry;
|
||||
for (const producerId of [typedEntry.primary, ...typedEntry.absorbed]) {
|
||||
if (producerIds.has(producerId)) return false;
|
||||
producerIds.add(producerId);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Classified outcome of reading a class manifest from Git `HEAD`. */
|
||||
export type ManifestRead =
|
||||
| { state: 'absent' }
|
||||
| { state: 'invalid'; reason: string }
|
||||
| { state: 'present'; manifest: PublicationManifest; contents: string };
|
||||
|
||||
/** Read and strictly validate one committed manifest. */
|
||||
export async function readPublishedManifest(
|
||||
deliverablesDirPath: string,
|
||||
manifestRelPath: string,
|
||||
): Promise<ManifestRead> {
|
||||
const read = await readCommittedFile(deliverablesDirPath, manifestRelPath);
|
||||
if (read.state === 'absent') return { state: 'absent' };
|
||||
if (read.state === 'corrupt') {
|
||||
return { state: 'invalid', reason: 'manifest object is unreadable' };
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(read.contents);
|
||||
} catch {
|
||||
return { state: 'invalid', reason: 'manifest is not valid JSON' };
|
||||
}
|
||||
if (!isManifest(parsed)) {
|
||||
return { state: 'invalid', reason: 'manifest is truncated, malformed, or contains unexpected fields' };
|
||||
}
|
||||
return { state: 'present', manifest: parsed, contents: read.contents };
|
||||
}
|
||||
|
||||
function samePathSet(actual: readonly string[], expected: readonly string[]): boolean {
|
||||
if (actual.length !== expected.length) return false;
|
||||
const actualSet = new Set(actual);
|
||||
return actualSet.size === actual.length && expected.every((path) => actualSet.has(path));
|
||||
}
|
||||
|
||||
/** Whether a manifest exactly matches the expected OSS publication identity and path set. */
|
||||
export function isManifestCoherent(args: {
|
||||
manifest: PublicationManifest;
|
||||
sessionId: string;
|
||||
vulnerabilityClass: ReconciliationClass;
|
||||
contract: PublicationContract;
|
||||
producerQueuePath: string;
|
||||
producerBlobSha?: string;
|
||||
}): boolean {
|
||||
const { manifest, sessionId, vulnerabilityClass, contract, producerQueuePath, producerBlobSha } = args;
|
||||
if (contract.publicationKind !== 'class-reconciliation') return false;
|
||||
if (contract.schemaVersion !== RECONCILIATION_SCHEMA_VERSION) return false;
|
||||
if (manifest.session_id !== sessionId) return false;
|
||||
if (manifest.vulnerability_class !== vulnerabilityClass) return false;
|
||||
if (manifest.schema_version !== contract.schemaVersion) return false;
|
||||
if (manifest.producer_queue.path !== producerQueuePath) return false;
|
||||
if (producerBlobSha !== undefined && manifest.producer_queue.blob_sha !== producerBlobSha) return false;
|
||||
return samePathSet(
|
||||
manifest.consumer_files.map((consumer) => consumer.path),
|
||||
contract.requiredOutputPaths,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
// 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.
|
||||
|
||||
/** Pure deterministic collapse, ordering, and reference assignment for class tasks. */
|
||||
|
||||
import type { ReconciliationClass } from '../../types/reconciliation.js';
|
||||
import { ArtifactIntegrityError } from './artifact-store.js';
|
||||
import type {
|
||||
MergedObservation,
|
||||
PrimaryPreference,
|
||||
ReconciliationObservation,
|
||||
ReconciliationTask,
|
||||
} from './contracts.js';
|
||||
import { REF_PREFIX } from './refs.js';
|
||||
|
||||
const PRIORITY_ORDER: Readonly<Record<string, number>> = Object.freeze({ P1: 0, P2: 1, P3: 2 });
|
||||
const CONFIDENCE_ORDER: Readonly<Record<string, number>> = Object.freeze({ high: 0, medium: 1, low: 2 });
|
||||
const MISSING_PRIORITY_RANK = 2;
|
||||
const MISSING_CONFIDENCE_RANK = 2;
|
||||
|
||||
type PreTask = Omit<ReconciliationTask, 'ID'>;
|
||||
|
||||
/** Ordered tasks, complete lineage, and merged-member accounting. */
|
||||
export interface FixedTasks {
|
||||
tasks: ReconciliationTask[];
|
||||
observationToTask: Record<string, string>;
|
||||
mergedFromTotal: number;
|
||||
}
|
||||
|
||||
/** Mint dense references `PREFIX-01..PREFIX-NN`, continuing unpadded past 99. */
|
||||
export function mintTaskReferences(count: number, vulnerabilityClass: ReconciliationClass): string[] {
|
||||
const references: string[] = [];
|
||||
for (let index = 1; index <= count; index++) {
|
||||
references.push(`${REF_PREFIX[vulnerabilityClass]}-${String(index).padStart(2, '0')}`);
|
||||
}
|
||||
return references;
|
||||
}
|
||||
|
||||
function rank(value: unknown, order: Readonly<Record<string, number>>, fallback: number): number {
|
||||
if (typeof value !== 'string') return fallback;
|
||||
return order[value] ?? fallback;
|
||||
}
|
||||
|
||||
// The strongest priority or confidence across a merged group of observations wins for the task,
|
||||
// rather than the primary observation's own value. A weaker duplicate finding should not water
|
||||
// down a stronger signal one of the other producers already established for the same vulnerability.
|
||||
function strongest(
|
||||
members: readonly ReconciliationObservation[],
|
||||
field: 'priority' | 'confidence',
|
||||
order: Readonly<Record<string, number>>,
|
||||
): string | undefined {
|
||||
let best: string | undefined;
|
||||
let bestRank = Number.POSITIVE_INFINITY;
|
||||
for (const member of members) {
|
||||
const value = (member as unknown as Record<string, unknown>)[field];
|
||||
if (typeof value !== 'string') continue;
|
||||
const valueRank = rank(value, order, Number.POSITIVE_INFINITY);
|
||||
if (valueRank < bestRank) {
|
||||
best = value;
|
||||
bestRank = valueRank;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
// A `preferred` member (a SAST producer) outranks a `default` one, so a group that pairs a SAST
|
||||
// finding with a vulnerability-analysis finding keeps the SAST observation as the task primary.
|
||||
function preferenceRank(preference: PrimaryPreference): number {
|
||||
return preference === 'preferred' ? 0 : 1;
|
||||
}
|
||||
|
||||
function primaryIndex(members: readonly ReconciliationObservation[]): number {
|
||||
if (members.length === 0) {
|
||||
throw new ArtifactIntegrityError('Cannot materialize an empty observation group');
|
||||
}
|
||||
let selected = 0;
|
||||
let selectedRank = preferenceRank(members[0]?.primary_preference ?? 'default');
|
||||
for (let index = 1; index < members.length; index++) {
|
||||
const member = members[index];
|
||||
if (member === undefined) continue;
|
||||
const memberRank = preferenceRank(member.primary_preference);
|
||||
if (memberRank < selectedRank) {
|
||||
selected = index;
|
||||
selectedRank = memberRank;
|
||||
}
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
// `primary_preference` has already done its job by the time a group reaches this function: it
|
||||
// picked the primary observation via `primaryIndex` above. Dropping it here, rather than carrying
|
||||
// it into the task, is what the `MaterializedProducerFields` type in contracts.ts enforces at
|
||||
// compile time; nothing downstream of materialization is allowed to see or re-derive this preference.
|
||||
function toMaterialized(observation: ReconciliationObservation): MergedObservation {
|
||||
const { primary_preference: _primaryPreference, ...materialized } = observation;
|
||||
return materialized as MergedObservation;
|
||||
}
|
||||
|
||||
/** Collapse one member group into a single pre-task: pick the primary, fold the rest as `merged_from`. */
|
||||
function buildTask(members: readonly ReconciliationObservation[]): PreTask {
|
||||
const selectedIndex = primaryIndex(members);
|
||||
const primary = members[selectedIndex];
|
||||
if (primary === undefined) {
|
||||
throw new ArtifactIntegrityError('Materialization selected a missing primary observation');
|
||||
}
|
||||
const merged = members.filter((_member, index) => index !== selectedIndex);
|
||||
const priority = strongest(members, 'priority', PRIORITY_ORDER);
|
||||
const confidence = strongest(members, 'confidence', CONFIDENCE_ORDER);
|
||||
|
||||
const task: Record<string, unknown> = { ...toMaterialized(primary) };
|
||||
if (priority !== undefined) task.priority = priority;
|
||||
if (confidence !== undefined) task.confidence = confidence;
|
||||
if (merged.length > 0) task.merged_from = merged.map(toMaterialized);
|
||||
return task as PreTask;
|
||||
}
|
||||
|
||||
function compareCodeUnits(first: string, second: string): number {
|
||||
if (first < second) return -1;
|
||||
if (first > second) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function taskField(task: PreTask, field: string): unknown {
|
||||
return (task as unknown as Record<string, unknown>)[field];
|
||||
}
|
||||
|
||||
// Total order over tasks: priority, then confidence, then producer ID as a final tie-break. The
|
||||
// producer-ID comparison guarantees no two tasks ever compare equal, so the sort is fully
|
||||
// deterministic and the dense reference assignment below is reproducible across retries.
|
||||
function compareTasks(first: PreTask, second: PreTask): number {
|
||||
const priorityDifference =
|
||||
rank(taskField(first, 'priority'), PRIORITY_ORDER, MISSING_PRIORITY_RANK) -
|
||||
rank(taskField(second, 'priority'), PRIORITY_ORDER, MISSING_PRIORITY_RANK);
|
||||
if (priorityDifference !== 0) return priorityDifference;
|
||||
|
||||
const confidenceDifference =
|
||||
rank(taskField(first, 'confidence'), CONFIDENCE_ORDER, MISSING_CONFIDENCE_RANK) -
|
||||
rank(taskField(second, 'confidence'), CONFIDENCE_ORDER, MISSING_CONFIDENCE_RANK);
|
||||
if (confidenceDifference !== 0) return confidenceDifference;
|
||||
|
||||
const firstProducer = taskField(first, 'producer_id');
|
||||
const secondProducer = taskField(second, 'producer_id');
|
||||
return compareCodeUnits(
|
||||
typeof firstProducer === 'string' ? firstProducer : '',
|
||||
typeof secondProducer === 'string' ? secondProducer : '',
|
||||
);
|
||||
}
|
||||
|
||||
/** Collapse member groups, sort by the locked total order, and assign dense stable references. */
|
||||
export function buildFixedTasks(
|
||||
memberGroups: ReadonlyArray<readonly ReconciliationObservation[]>,
|
||||
vulnerabilityClass: ReconciliationClass,
|
||||
): FixedTasks {
|
||||
const preTasks = memberGroups.map(buildTask);
|
||||
preTasks.sort(compareTasks);
|
||||
|
||||
const references = mintTaskReferences(preTasks.length, vulnerabilityClass);
|
||||
const tasks = preTasks.map(
|
||||
(task, index) =>
|
||||
({
|
||||
...(task as unknown as Record<string, unknown>),
|
||||
ID: references[index] as string,
|
||||
}) as ReconciliationTask,
|
||||
);
|
||||
|
||||
const observationToTask: Record<string, string> = Object.create(null);
|
||||
let mergedFromTotal = 0;
|
||||
for (const task of tasks) {
|
||||
observationToTask[task.producer_id] = task.ID;
|
||||
for (const member of task.merged_from ?? []) {
|
||||
observationToTask[member.producer_id] = task.ID;
|
||||
mergedFromTotal++;
|
||||
}
|
||||
}
|
||||
return { tasks, observationToTask, mergedFromTotal };
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
// 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.
|
||||
|
||||
/** Revalidate stage artifacts and materialize every observation into one fixed task. */
|
||||
|
||||
import { Check } from 'typebox/value';
|
||||
import type { ReconciliationClass } from '../../types/reconciliation.js';
|
||||
import { classEntrySchema, QUEUE_ENTRY_FIELD_NAMES } from '../queue-schemas.js';
|
||||
import { ArtifactIntegrityError, artifactInputsMatch, readArtifact, writeArtifact } from './artifact-store.js';
|
||||
import type { ArtifactInputDigest, ArtifactRef, ReconciliationObservation } from './contracts.js';
|
||||
import { buildFixedTasks } from './materialize-core.js';
|
||||
import { combineObservations } from './observations.js';
|
||||
import { isProducerId } from './refs.js';
|
||||
import { type FixedTasksBody, type FormResult, type MaterializeResult, SINGLETON_FALLBACK } from './stage-contracts.js';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function validateSastLocation(value: unknown): boolean {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
typeof value.file === 'string' &&
|
||||
value.file.length > 0 &&
|
||||
Number.isSafeInteger(value.line) &&
|
||||
(value.line as number) > 0 &&
|
||||
Number.isSafeInteger(value.column) &&
|
||||
(value.column as number) > 0 &&
|
||||
typeof value.rule_id === 'string' &&
|
||||
value.rule_id.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
// Defense in depth: an earlier stage already validated this observation's shape, but materialization
|
||||
// is the last stop before publication, so it revalidates independently rather than trusting an
|
||||
// artifact read back off disk. Re-derives the evidence subset and schema-checks it, then confirms no
|
||||
// field outside the declared class/source contract survived (including any internal key a bug in an
|
||||
// earlier stage might have let through).
|
||||
function validateEvidenceShape(observation: ReconciliationObservation, vulnerabilityClass: ReconciliationClass): void {
|
||||
const record = observation as unknown as Record<string, unknown>;
|
||||
const evidence: Record<string, unknown> = { ID: observation.producer_id };
|
||||
for (const key of QUEUE_ENTRY_FIELD_NAMES[vulnerabilityClass]) {
|
||||
if (key !== 'ID' && key in record) evidence[key] = record[key];
|
||||
}
|
||||
if (!Check(classEntrySchema(vulnerabilityClass), evidence)) {
|
||||
throw new ArtifactIntegrityError('Observation evidence does not match its declared class schema');
|
||||
}
|
||||
|
||||
const allowed = new Set<string>([
|
||||
...QUEUE_ENTRY_FIELD_NAMES[vulnerabilityClass].filter((key) => key !== 'ID'),
|
||||
'producer_id',
|
||||
'scan_source',
|
||||
'primary_preference',
|
||||
...(observation.scan_source === 'sast' ? ['priority', 'sast_source_location'] : []),
|
||||
]);
|
||||
if (Object.keys(record).some((key) => !allowed.has(key))) {
|
||||
throw new ArtifactIntegrityError('Observation contains fields outside its class/source contract');
|
||||
}
|
||||
}
|
||||
|
||||
/** Reconfirm producer identity and evidence shape match the observation's declared source and class. */
|
||||
function validateObservation(observation: ReconciliationObservation, vulnerabilityClass: ReconciliationClass): void {
|
||||
validateEvidenceShape(observation, vulnerabilityClass);
|
||||
if (observation.scan_source === 'vulnerability_analysis') {
|
||||
if (
|
||||
observation.primary_preference !== 'default' ||
|
||||
!isProducerId(observation.producer_id, vulnerabilityClass, 'VULN')
|
||||
) {
|
||||
throw new ArtifactIntegrityError('Vulnerability-analysis observation has an invalid class/source identity');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (observation.scan_source !== 'sast') {
|
||||
throw new ArtifactIntegrityError('Observation has an unknown producer source');
|
||||
}
|
||||
if (
|
||||
observation.primary_preference !== 'preferred' ||
|
||||
!isProducerId(observation.producer_id, vulnerabilityClass, 'SAST') ||
|
||||
!['P1', 'P2', 'P3'].includes(observation.priority) ||
|
||||
!validateSastLocation(observation.sast_source_location)
|
||||
) {
|
||||
throw new ArtifactIntegrityError('SAST observation has an invalid class/source identity or annotation');
|
||||
}
|
||||
}
|
||||
|
||||
function partitionMembers(
|
||||
observations: readonly ReconciliationObservation[],
|
||||
groups: readonly unknown[],
|
||||
): ReconciliationObservation[][] {
|
||||
const byProducerId = new Map(observations.map((observation) => [observation.producer_id, observation]));
|
||||
const assigned = new Set<string>();
|
||||
const memberGroups: ReconciliationObservation[][] = [];
|
||||
|
||||
for (const rawGroup of groups) {
|
||||
if (
|
||||
!isRecord(rawGroup) ||
|
||||
Object.keys(rawGroup).some((key) => key !== 'producer_ids' && key !== 'reasoning') ||
|
||||
!Array.isArray(rawGroup.producer_ids) ||
|
||||
!rawGroup.producer_ids.every((producerId) => typeof producerId === 'string') ||
|
||||
rawGroup.producer_ids.length < 2 ||
|
||||
typeof rawGroup.reasoning !== 'string' ||
|
||||
rawGroup.reasoning.length === 0
|
||||
) {
|
||||
throw new ArtifactIntegrityError('Accepted task formation contains a malformed group');
|
||||
}
|
||||
const group = rawGroup as { producer_ids: string[]; reasoning: string };
|
||||
const members: ReconciliationObservation[] = [];
|
||||
const groupIds = new Set<string>();
|
||||
for (const producerId of group.producer_ids) {
|
||||
const observation = byProducerId.get(producerId);
|
||||
if (observation === undefined || groupIds.has(producerId) || assigned.has(producerId)) {
|
||||
throw new ArtifactIntegrityError('Accepted task formation has unknown, duplicate, or reused membership');
|
||||
}
|
||||
groupIds.add(producerId);
|
||||
members.push(observation);
|
||||
}
|
||||
for (const producerId of groupIds) assigned.add(producerId);
|
||||
memberGroups.push(members);
|
||||
}
|
||||
|
||||
// Every observation the model did not place into a group becomes its own singleton task. This is
|
||||
// also exactly the whole-set result when task formation is skipped, so the fallback path and the
|
||||
// model path converge on the same shape: one task per unmerged observation.
|
||||
for (const observation of observations) {
|
||||
if (!assigned.has(observation.producer_id)) memberGroups.push([observation]);
|
||||
}
|
||||
return memberGroups;
|
||||
}
|
||||
|
||||
function assertRefClass(ref: ArtifactRef, vulnerabilityClass: ReconciliationClass): void {
|
||||
if (ref.vulnerabilityClass !== vulnerabilityClass) {
|
||||
throw new ArtifactIntegrityError('Artifact reference crosses the declared class boundary');
|
||||
}
|
||||
}
|
||||
|
||||
export interface MaterializeClassExploitTasksArgs {
|
||||
sessionId: string;
|
||||
workspacesDir?: string;
|
||||
vulnerabilityClass: ReconciliationClass;
|
||||
producerRef: ArtifactRef<'producer-observations'>;
|
||||
supplementalRef: ArtifactRef<'supplemental-observations'>;
|
||||
form: FormResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Materialize one class and publish its content-addressed `03-fixed-tasks` artifact.
|
||||
*
|
||||
* Combines the producer and supplemental observations, applies the accepted formation groups (or
|
||||
* treats every observation as its own singleton task when formation fell back), collapses each
|
||||
* group into one task via `buildFixedTasks`, and confirms the resulting observation-to-task map
|
||||
* covers every observation exactly once before returning.
|
||||
*/
|
||||
export async function materializeClassExploitTasks(args: MaterializeClassExploitTasksArgs): Promise<MaterializeResult> {
|
||||
assertRefClass(args.producerRef, args.vulnerabilityClass);
|
||||
assertRefClass(args.supplementalRef, args.vulnerabilityClass);
|
||||
if (args.form !== SINGLETON_FALLBACK) assertRefClass(args.form.ref, args.vulnerabilityClass);
|
||||
|
||||
const producer = await readArtifact(args.producerRef, args.sessionId, args.workspacesDir);
|
||||
const supplemental = await readArtifact(args.supplementalRef, args.sessionId, args.workspacesDir);
|
||||
if (!Array.isArray(producer.observations) || !Array.isArray(supplemental.observations)) {
|
||||
throw new ArtifactIntegrityError('Observation artifact body is truncated or malformed');
|
||||
}
|
||||
const observations = combineObservations(producer.observations, supplemental.observations);
|
||||
for (const observation of observations) validateObservation(observation, args.vulnerabilityClass);
|
||||
|
||||
const observationInputs: ArtifactInputDigest[] = [
|
||||
{ artifactKind: 'producer-observations', sha256: args.producerRef.sha256 },
|
||||
{ artifactKind: 'supplemental-observations', sha256: args.supplementalRef.sha256 },
|
||||
];
|
||||
// With the singleton fallback there are no groups, so every observation materializes alone. With a
|
||||
// real formation artifact, its lineage must name these exact observation digests, or it grouped a
|
||||
// different observation set than the one being materialized here.
|
||||
let groups: readonly unknown[] = [];
|
||||
if (args.form !== SINGLETON_FALLBACK) {
|
||||
if (!artifactInputsMatch(args.form.ref.inputs, observationInputs)) {
|
||||
throw new ArtifactIntegrityError('Task-formation lineage does not match the supplied observations');
|
||||
}
|
||||
const formation = await readArtifact(args.form.ref, args.sessionId, args.workspacesDir);
|
||||
if (!Array.isArray(formation.groups)) {
|
||||
throw new ArtifactIntegrityError('Accepted task formation has no groups array');
|
||||
}
|
||||
groups = formation.groups;
|
||||
}
|
||||
|
||||
const memberGroups = partitionMembers(observations, groups);
|
||||
const fixed = buildFixedTasks(memberGroups, args.vulnerabilityClass);
|
||||
if (Object.keys(fixed.observationToTask).length !== observations.length) {
|
||||
throw new ArtifactIntegrityError('Observation-to-task map is incomplete');
|
||||
}
|
||||
|
||||
const inputs: ArtifactInputDigest[] = [...observationInputs];
|
||||
if (args.form !== SINGLETON_FALLBACK) {
|
||||
inputs.push({ artifactKind: 'task-formation', sha256: args.form.ref.sha256 });
|
||||
}
|
||||
const body: FixedTasksBody = {
|
||||
tasks: fixed.tasks,
|
||||
observation_to_task: fixed.observationToTask,
|
||||
};
|
||||
const ref = await writeArtifact({
|
||||
sessionId: args.sessionId,
|
||||
...(args.workspacesDir !== undefined ? { workspacesDir: args.workspacesDir } : {}),
|
||||
artifactKind: 'fixed-tasks',
|
||||
vulnerabilityClass: args.vulnerabilityClass,
|
||||
body,
|
||||
inputs,
|
||||
counts: { tasks: fixed.tasks.length, merged_from_total: fixed.mergedFromTotal },
|
||||
});
|
||||
return { ref };
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/** Positive observation projection used at the task-formation model boundary. */
|
||||
|
||||
import type { ReconciliationClass } from '../../types/reconciliation.js';
|
||||
import { QUEUE_ENTRY_FIELD_NAMES } from '../queue-schemas.js';
|
||||
import type { ReconciliationObservation } from './contracts.js';
|
||||
import type { ObservationView } from './stage-contracts.js';
|
||||
|
||||
const BOOLEAN_EVIDENCE_KEY = 'externally_exploitable';
|
||||
const PRODUCER_ID_REDACTION = '[redacted]';
|
||||
|
||||
function redactString(value: string, producerIds: readonly string[]): string {
|
||||
let redacted = value;
|
||||
for (const producerId of producerIds) {
|
||||
if (redacted.includes(producerId)) {
|
||||
redacted = redacted.split(producerId).join(PRODUCER_ID_REDACTION);
|
||||
}
|
||||
}
|
||||
return redacted;
|
||||
}
|
||||
|
||||
/** Redact producer-ID tokens from arbitrary free text. */
|
||||
export function redactProducerIds(value: string, producerIds: Iterable<string>): string {
|
||||
const sorted = [...new Set(producerIds)].filter((id) => id.length > 0).sort((a, b) => b.length - a.length);
|
||||
return redactString(value, sorted);
|
||||
}
|
||||
|
||||
function redactDeep(value: unknown, producerIds: readonly string[]): unknown {
|
||||
if (typeof value === 'string') return redactString(value, producerIds);
|
||||
if (Array.isArray(value)) return value.map((item) => redactDeep(item, producerIds));
|
||||
if (value !== null && typeof value === 'object') {
|
||||
const output: Record<string, unknown> = {};
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
output[key] = redactDeep(item, producerIds);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// Rebuilds the location from scratch rather than passing the stored value through, so a malformed
|
||||
// or tampered `sast_source_location` on the underlying observation cannot cross into the model view
|
||||
// unnoticed; anything that fails this shape check (including a `rule_id` that isn't a real CWE
|
||||
// identifier) is silently dropped from the view rather than surfaced as-is.
|
||||
function rebuildSastSourceLocation(value: unknown): ObservationView['sast_source_location'] {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
||||
const location = value as Record<string, unknown>;
|
||||
if (
|
||||
typeof location.file === 'string' &&
|
||||
location.file.length > 0 &&
|
||||
Number.isSafeInteger(location.line) &&
|
||||
(location.line as number) > 0 &&
|
||||
Number.isSafeInteger(location.column) &&
|
||||
(location.column as number) > 0 &&
|
||||
typeof location.rule_id === 'string' &&
|
||||
/^CWE-[1-9][0-9]*$/.test(location.rule_id)
|
||||
) {
|
||||
return {
|
||||
file: location.file,
|
||||
line: location.line as number,
|
||||
column: location.column as number,
|
||||
rule_id: location.rule_id,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Return producer IDs still present in a fully serialized model context. */
|
||||
export function findLeakedProducerIds(modelContext: string, producerIds: Iterable<string>): string[] {
|
||||
const leaked: string[] = [];
|
||||
for (const producerId of new Set(producerIds)) {
|
||||
if (producerId.length > 0 && modelContext.includes(producerId)) leaked.push(producerId);
|
||||
}
|
||||
return leaked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild one model-visible observation from declared primitive evidence only.
|
||||
* Internal keys and non-primitive evidence never cross this boundary.
|
||||
*/
|
||||
export function toObservationView(
|
||||
observation: ReconciliationObservation,
|
||||
vulnerabilityClass: ReconciliationClass,
|
||||
producerIds: Iterable<string>,
|
||||
): ObservationView {
|
||||
const source = observation as unknown as Record<string, unknown>;
|
||||
const view: Record<string, unknown> = {};
|
||||
|
||||
for (const key of QUEUE_ENTRY_FIELD_NAMES[vulnerabilityClass]) {
|
||||
if (key === 'ID') continue;
|
||||
const value = source[key];
|
||||
if (key === BOOLEAN_EVIDENCE_KEY) {
|
||||
if (typeof value === 'boolean') view[key] = value;
|
||||
continue;
|
||||
}
|
||||
if (typeof value === 'string') view[key] = value;
|
||||
}
|
||||
|
||||
if (source.scan_source === 'vulnerability_analysis' || source.scan_source === 'sast') {
|
||||
view.scan_source = source.scan_source;
|
||||
}
|
||||
if (source.priority === 'P1' || source.priority === 'P2' || source.priority === 'P3') {
|
||||
view.priority = source.priority;
|
||||
}
|
||||
const location = rebuildSastSourceLocation(source.sast_source_location);
|
||||
if (location !== undefined) view.sast_source_location = location;
|
||||
|
||||
const sortedIds = [...new Set(producerIds)].filter((id) => id.length > 0).sort((a, b) => b.length - a.length);
|
||||
return redactDeep(view, sortedIds) as ObservationView;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/** Utilities shared by task formation and materialization observation intake. */
|
||||
|
||||
import { ArtifactIntegrityError } from './artifact-store.js';
|
||||
import type { ReconciliationObservation } from './contracts.js';
|
||||
|
||||
/** Concatenate producer and supplemental observations while enforcing one global producer-ID set. */
|
||||
export function combineObservations(
|
||||
producer: readonly ReconciliationObservation[],
|
||||
supplemental: readonly ReconciliationObservation[],
|
||||
): ReconciliationObservation[] {
|
||||
const combined = [...producer, ...supplemental];
|
||||
const seen = new Set<string>();
|
||||
for (const observation of combined) {
|
||||
if (seen.has(observation.producer_id)) {
|
||||
throw new ArtifactIntegrityError('Reconciliation observations contain a duplicate producer identifier');
|
||||
}
|
||||
seen.add(observation.producer_id);
|
||||
}
|
||||
return combined;
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
// 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.
|
||||
|
||||
/** Admit committed producer observations or a complete existing class publication. */
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { lstat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { Check } from 'typebox/value';
|
||||
import {
|
||||
blobShaFromHead,
|
||||
readCommittedFile,
|
||||
restorePathsFromHead,
|
||||
withGitRepoLock,
|
||||
} from '../../services/git-manager.js';
|
||||
import type { ReconciliationClass } from '../../types/reconciliation.js';
|
||||
import { classEntrySchema, QUEUE_ENTRY_FIELD_NAMES } from '../queue-schemas.js';
|
||||
import {
|
||||
ArtifactIntegrityError,
|
||||
PublicationConflictError,
|
||||
ReconciliationError,
|
||||
ReconciliationIoError,
|
||||
writeArtifact,
|
||||
} from './artifact-store.js';
|
||||
import type { PublicationContract, ReconciliationObservation } from './contracts.js';
|
||||
import { isManifestCoherent, type PublicationManifest, readPublishedManifest } from './manifest.js';
|
||||
import { isProducerId, isTaskReference } from './refs.js';
|
||||
import type { PrepareResult, ProducerObservationsBody } from './stage-contracts.js';
|
||||
|
||||
// Duplicated from the identical set in publish.ts, which guards the fresh-publish path; this copy
|
||||
// guards the lost-acknowledgement repair path below, where an already-published queue is read back
|
||||
// and re-verified before being trusted. Both sets must list the same internal-only keys, or a key
|
||||
// added to only one path could round-trip a leaked queue back into "coherent" on the other.
|
||||
const FORBIDDEN_PUBLISHED_KEYS: ReadonlySet<string> = new Set([
|
||||
'producer_id',
|
||||
'primary_preference',
|
||||
'observation_key',
|
||||
'novelty',
|
||||
'_sastId',
|
||||
'_sast_id',
|
||||
'repository_id',
|
||||
'scan_run_id',
|
||||
]);
|
||||
|
||||
function sha256Text(contents: string): string {
|
||||
return createHash('sha256').update(contents, 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
function isErrno(error: unknown, code: string): boolean {
|
||||
return error instanceof Error && (error as NodeJS.ErrnoException).code === code;
|
||||
}
|
||||
|
||||
// Same defense as `preflightSymlinks` in publish.ts: a symlinked destination could redirect a write
|
||||
// (here, the restore step below) outside the deliverables directory, so any symlink found is a
|
||||
// conflict rather than something to write through.
|
||||
async function rejectSymlinkDestinations(deliverablesDir: string, relativePaths: readonly string[]): Promise<void> {
|
||||
for (const relativePath of relativePaths) {
|
||||
try {
|
||||
const stat = await lstat(path.join(deliverablesDir, relativePath));
|
||||
if (stat.isSymbolicLink()) throw new PublicationConflictError('Refusing to repair a publication symlink');
|
||||
} catch (error) {
|
||||
if (isErrno(error, 'ENOENT')) continue;
|
||||
if (error instanceof ReconciliationError) throw error;
|
||||
throw new ReconciliationIoError('Unable to inspect a class publication destination');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The class-owned producer and published queue path. */
|
||||
export function exploitationQueuePath(vulnerabilityClass: ReconciliationClass): string {
|
||||
return `${vulnerabilityClass}_exploitation_queue.json`;
|
||||
}
|
||||
|
||||
/** The class-owned durable completion-marker path. */
|
||||
export function reconciliationManifestPath(vulnerabilityClass: ReconciliationClass): string {
|
||||
return `${vulnerabilityClass}_reconciliation_manifest.json`;
|
||||
}
|
||||
|
||||
/** The conditional standalone SAST-provenance path. */
|
||||
export function sastProvenancePath(vulnerabilityClass: ReconciliationClass): string {
|
||||
return `sast_provenance_${vulnerabilityClass}.json`;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
// A raw producer queue carries producer IDs and no merge structure. A task reference or a
|
||||
// `merged_from` array means the queue was already normalized by a prior publication whose manifest
|
||||
// is now missing, which is an incoherent state to be reported rather than reprocessed.
|
||||
function looksNormalized(entry: Record<string, unknown>, vulnerabilityClass: ReconciliationClass): boolean {
|
||||
const id = entry.ID;
|
||||
return (typeof id === 'string' && isTaskReference(id, vulnerabilityClass)) || Array.isArray(entry.merged_from);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and strictly validate a committed producer queue before reconciliation ever touches it.
|
||||
*
|
||||
* Every entry must match its class's declared evidence schema and carry an ID in the VULN producer
|
||||
* namespace for this exact class; a queue that already looks normalized (see `looksNormalized`) or
|
||||
* carries a duplicate ID is rejected outright. The producer IDs minted or validated here are
|
||||
* internal identity: they exist to let reconciliation reason about and dedupe observations, and are
|
||||
* scrubbed before anything derived from this queue is published.
|
||||
*/
|
||||
function parseProducerQueue(raw: string, vulnerabilityClass: ReconciliationClass): Record<string, unknown>[] {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
throw new ArtifactIntegrityError('Producer queue is not valid JSON');
|
||||
}
|
||||
if (!isRecord(parsed) || !Array.isArray(parsed.vulnerabilities)) {
|
||||
throw new ArtifactIntegrityError('Producer queue has no vulnerabilities array');
|
||||
}
|
||||
|
||||
const schema = classEntrySchema(vulnerabilityClass);
|
||||
const seenIds = new Set<string>();
|
||||
for (const entry of parsed.vulnerabilities) {
|
||||
if (!isRecord(entry) || typeof entry.ID !== 'string') {
|
||||
throw new ArtifactIntegrityError('Producer queue entry is missing a string ID');
|
||||
}
|
||||
if (looksNormalized(entry, vulnerabilityClass)) {
|
||||
throw new PublicationConflictError('Normalized queue in HEAD has no coherent publication manifest');
|
||||
}
|
||||
if (!Check(schema, entry)) {
|
||||
throw new ArtifactIntegrityError(`Producer queue entry does not match the ${vulnerabilityClass} schema`);
|
||||
}
|
||||
if (!isProducerId(entry.ID, vulnerabilityClass, 'VULN')) {
|
||||
throw new ArtifactIntegrityError('Producer queue entry is outside its declared class/source namespace');
|
||||
}
|
||||
if (seenIds.has(entry.ID)) {
|
||||
throw new ArtifactIntegrityError('Producer queue contains a duplicate producer identifier');
|
||||
}
|
||||
seenIds.add(entry.ID);
|
||||
}
|
||||
return parsed.vulnerabilities as Record<string, unknown>[];
|
||||
}
|
||||
|
||||
// Every producer-queue (pentest) observation is stamped `primary_preference: 'default'`, the losing
|
||||
// side of the dedupe contract: if this observation is later merged with a SAST observation for the
|
||||
// same vulnerability, the SAST evidence becomes the task's primary record instead of this one.
|
||||
function toObservation(entry: Record<string, unknown>, evidenceKeys: readonly string[]): ReconciliationObservation {
|
||||
const evidence: Record<string, unknown> = {};
|
||||
for (const key of evidenceKeys) {
|
||||
if (key in entry) evidence[key] = entry[key];
|
||||
}
|
||||
return {
|
||||
...evidence,
|
||||
producer_id: entry.ID as string,
|
||||
scan_source: 'vulnerability_analysis',
|
||||
primary_preference: 'default',
|
||||
} as ReconciliationObservation;
|
||||
}
|
||||
|
||||
async function verifyCommittedConsumers(
|
||||
deliverablesDir: string,
|
||||
consumerFiles: ReadonlyArray<{ path: string; sha256: string }>,
|
||||
): Promise<Map<string, string>> {
|
||||
const contentsByPath = new Map<string, string>();
|
||||
for (const consumer of consumerFiles) {
|
||||
const committed = await readCommittedFile(deliverablesDir, consumer.path);
|
||||
if (committed.state !== 'present') {
|
||||
throw new PublicationConflictError('Existing class publication is missing a committed consumer');
|
||||
}
|
||||
if (sha256Text(committed.contents) !== consumer.sha256) {
|
||||
throw new PublicationConflictError('Existing class publication consumer does not match its manifest');
|
||||
}
|
||||
contentsByPath.set(consumer.path, committed.contents);
|
||||
}
|
||||
return contentsByPath;
|
||||
}
|
||||
|
||||
// Used only on the repair path below, where a manifest already exists in HEAD and the previously
|
||||
// published queue is being read back rather than freshly built. Even a publication from a prior run
|
||||
// gets this same forbidden-key check before it is trusted and adopted.
|
||||
function containsForbiddenPublishedKey(value: unknown): boolean {
|
||||
if (Array.isArray(value)) return value.some(containsForbiddenPublishedKey);
|
||||
if (!isRecord(value)) return false;
|
||||
return Object.entries(value).some(
|
||||
([key, entry]) => FORBIDDEN_PUBLISHED_KEYS.has(key) || containsForbiddenPublishedKey(entry),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-verify the internal-identity boundary on a previously published queue before adopting it.
|
||||
*
|
||||
* A manifest existing in HEAD means some earlier run already published this class, but that
|
||||
* publication is only ever restored here, not blindly trusted: this confirms the queue's task IDs
|
||||
* match the manifest's lineage exactly, that no forbidden internal key survived, and that no
|
||||
* producer-ID token appears anywhere in the serialized queue. The lost-acknowledgement repair path
|
||||
* is a second place a boundary-violating queue could otherwise slip through, so it gets the same
|
||||
* fail-closed check as a fresh publish.
|
||||
*/
|
||||
function verifyPublishedQueue(
|
||||
contents: string,
|
||||
vulnerabilityClass: ReconciliationClass,
|
||||
lineage: Record<string, { primary: string; absorbed: string[] }>,
|
||||
): void {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(contents);
|
||||
} catch {
|
||||
throw new PublicationConflictError('Existing published queue is not valid JSON');
|
||||
}
|
||||
if (!isRecord(parsed) || Object.keys(parsed).length !== 1 || !Array.isArray(parsed.vulnerabilities)) {
|
||||
throw new PublicationConflictError('Existing published queue does not have the canonical envelope');
|
||||
}
|
||||
|
||||
const queueTaskIds: string[] = [];
|
||||
for (const task of parsed.vulnerabilities) {
|
||||
if (!isRecord(task) || typeof task.ID !== 'string' || !isTaskReference(task.ID, vulnerabilityClass)) {
|
||||
throw new PublicationConflictError('Existing published queue contains an invalid task reference');
|
||||
}
|
||||
queueTaskIds.push(task.ID);
|
||||
}
|
||||
const lineageTaskIds = Object.keys(lineage);
|
||||
if (
|
||||
queueTaskIds.length !== lineageTaskIds.length ||
|
||||
queueTaskIds.some((taskId, index) => taskId !== lineageTaskIds[index])
|
||||
) {
|
||||
throw new PublicationConflictError('Existing published queue and manifest lineage disagree');
|
||||
}
|
||||
if (containsForbiddenPublishedKey(parsed)) {
|
||||
throw new PublicationConflictError('Existing published queue retains an internal producer-only key');
|
||||
}
|
||||
const serialized = JSON.stringify(parsed);
|
||||
const producerIds = Object.values(lineage).flatMap((entry) => [entry.primary, ...entry.absorbed]);
|
||||
if (producerIds.some((producerId) => serialized.includes(producerId))) {
|
||||
throw new PublicationConflictError('Existing published queue retains an internal producer identifier');
|
||||
}
|
||||
}
|
||||
|
||||
function samePathSet(actual: readonly string[], expected: readonly string[]): boolean {
|
||||
if (actual.length !== expected.length) return false;
|
||||
const actualPaths = new Set(actual);
|
||||
return actualPaths.size === actual.length && expected.every((expectedPath) => actualPaths.has(expectedPath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve which optional output shape the committed manifest actually published.
|
||||
*
|
||||
* The standalone SAST-provenance file is the one publication member that legitimately differs
|
||||
* between runs: whether the static-analysis stage produced SARIF decides it. That is a property of
|
||||
* the run that published, not of the run resuming, so on the repair path the committed manifest is
|
||||
* the authority on which shape to expect — otherwise a resume whose SARIF outcome flipped would
|
||||
* conflict with an otherwise coherent publication. Only the two shapes a class can legally publish
|
||||
* are admitted; any other path set is handed back as this run's own contract and rejected by
|
||||
* `isManifestCoherent`.
|
||||
*/
|
||||
function contractForCommittedShape(
|
||||
contract: PublicationContract,
|
||||
manifest: PublicationManifest,
|
||||
vulnerabilityClass: ReconciliationClass,
|
||||
): PublicationContract {
|
||||
const committedPaths = manifest.consumer_files.map((consumer) => consumer.path);
|
||||
const withoutProvenance = [exploitationQueuePath(vulnerabilityClass)];
|
||||
const withProvenance = [...withoutProvenance, sastProvenancePath(vulnerabilityClass)];
|
||||
for (const shape of [withoutProvenance, withProvenance]) {
|
||||
if (samePathSet(committedPaths, shape)) return { ...contract, requiredOutputPaths: shape };
|
||||
}
|
||||
return contract;
|
||||
}
|
||||
|
||||
export interface PrepareClassReconciliationArgs {
|
||||
deliverablesDir: string;
|
||||
sessionId: string;
|
||||
vulnerabilityClass: ReconciliationClass;
|
||||
contract: PublicationContract;
|
||||
workspacesDir?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare one class from committed `HEAD`, repairing an existing coherent publication when present.
|
||||
*
|
||||
* Runs entirely inside the Git critical section. Three outcomes: a coherent published manifest is
|
||||
* restored from HEAD and reported as `already_published` (the lost-acknowledgement repair path); a
|
||||
* present-but-incoherent or corrupt manifest is a hard conflict; otherwise the committed producer
|
||||
* queue is parsed and written as the first content-addressed artifact and reported as `pending`.
|
||||
*/
|
||||
export async function prepareClassReconciliation(args: PrepareClassReconciliationArgs): Promise<PrepareResult> {
|
||||
const queuePath = exploitationQueuePath(args.vulnerabilityClass);
|
||||
const manifestPath = reconciliationManifestPath(args.vulnerabilityClass);
|
||||
if (args.contract.manifestPath !== manifestPath) {
|
||||
throw new ArtifactIntegrityError('Publication contract names the wrong class manifest');
|
||||
}
|
||||
|
||||
return withGitRepoLock(async (): Promise<PrepareResult> => {
|
||||
const manifestRead = await readPublishedManifest(args.deliverablesDir, manifestPath);
|
||||
if (manifestRead.state === 'invalid') {
|
||||
throw new PublicationConflictError(`Corrupt class manifest in HEAD: ${manifestRead.reason}`);
|
||||
}
|
||||
|
||||
if (manifestRead.state === 'present') {
|
||||
const committedContract = contractForCommittedShape(
|
||||
args.contract,
|
||||
manifestRead.manifest,
|
||||
args.vulnerabilityClass,
|
||||
);
|
||||
if (
|
||||
!isManifestCoherent({
|
||||
manifest: manifestRead.manifest,
|
||||
sessionId: args.sessionId,
|
||||
vulnerabilityClass: args.vulnerabilityClass,
|
||||
contract: committedContract,
|
||||
producerQueuePath: queuePath,
|
||||
})
|
||||
) {
|
||||
throw new PublicationConflictError('Class manifest does not cohere with the publication contract');
|
||||
}
|
||||
// Mirrors the same guard on the fresh-publish path: when the publication being repaired
|
||||
// declares no standalone provenance, a provenance file in HEAD is residue from an interrupted
|
||||
// publish that no manifest vouches for, so it is a conflict rather than something to adopt.
|
||||
const provenancePath = sastProvenancePath(args.vulnerabilityClass);
|
||||
if (!committedContract.requiredOutputPaths.includes(provenancePath)) {
|
||||
const unvouchedProvenance = await readCommittedFile(args.deliverablesDir, provenancePath);
|
||||
if (unvouchedProvenance.state !== 'absent') {
|
||||
throw new PublicationConflictError('Existing publication has provenance outside its exact manifest path set');
|
||||
}
|
||||
}
|
||||
const consumerContents = await verifyCommittedConsumers(
|
||||
args.deliverablesDir,
|
||||
manifestRead.manifest.consumer_files,
|
||||
);
|
||||
const publishedQueueContents = consumerContents.get(queuePath);
|
||||
if (publishedQueueContents === undefined) {
|
||||
throw new PublicationConflictError('Existing class publication manifest omits its queue consumer');
|
||||
}
|
||||
verifyPublishedQueue(publishedQueueContents, args.vulnerabilityClass, manifestRead.manifest.lineage);
|
||||
await rejectSymlinkDestinations(args.deliverablesDir, [...committedContract.requiredOutputPaths, manifestPath]);
|
||||
await restorePathsFromHead(args.deliverablesDir, [...committedContract.requiredOutputPaths, manifestPath]);
|
||||
return { outcome: 'already_published', manifestSha256: sha256Text(manifestRead.contents) };
|
||||
}
|
||||
|
||||
const unexpectedProvenance = await readCommittedFile(
|
||||
args.deliverablesDir,
|
||||
sastProvenancePath(args.vulnerabilityClass),
|
||||
);
|
||||
if (unexpectedProvenance.state !== 'absent') {
|
||||
throw new PublicationConflictError('Pre-manifest class state contains standalone provenance');
|
||||
}
|
||||
|
||||
const queueRead = await readCommittedFile(args.deliverablesDir, queuePath);
|
||||
if (queueRead.state === 'absent') {
|
||||
throw new PublicationConflictError('Producer queue is not committed in HEAD');
|
||||
}
|
||||
if (queueRead.state === 'corrupt') {
|
||||
throw new ArtifactIntegrityError('Producer queue is committed but unreadable');
|
||||
}
|
||||
const blobSha = await blobShaFromHead(args.deliverablesDir, queuePath);
|
||||
if (blobSha.state !== 'present') {
|
||||
throw new ArtifactIntegrityError('Producer queue has no committed blob identity');
|
||||
}
|
||||
|
||||
const entries = parseProducerQueue(queueRead.contents, args.vulnerabilityClass);
|
||||
const evidenceKeys = QUEUE_ENTRY_FIELD_NAMES[args.vulnerabilityClass].filter((key) => key !== 'ID');
|
||||
const observations = entries.map((entry) => toObservation(entry, evidenceKeys));
|
||||
const body: ProducerObservationsBody = {
|
||||
observations,
|
||||
producer_queue: {
|
||||
path: queuePath,
|
||||
blob_sha: blobSha.sha,
|
||||
digest: sha256Text(queueRead.contents),
|
||||
},
|
||||
};
|
||||
const ref = await writeArtifact({
|
||||
sessionId: args.sessionId,
|
||||
...(args.workspacesDir !== undefined ? { workspacesDir: args.workspacesDir } : {}),
|
||||
artifactKind: 'producer-observations',
|
||||
vulnerabilityClass: args.vulnerabilityClass,
|
||||
body,
|
||||
inputs: [],
|
||||
counts: { observations: observations.length },
|
||||
});
|
||||
return { outcome: 'pending', ref };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,647 @@
|
||||
// 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.
|
||||
|
||||
/** Deterministic OSS queue shaping and lost-acknowledgement-safe class publication. */
|
||||
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { lstat, rename, unlink, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
blobShaFromHead,
|
||||
commitExactPaths,
|
||||
ExactPathCommitMismatchError,
|
||||
lastCommitForPathAtHead,
|
||||
readCommittedFile,
|
||||
restorePathsFromHead,
|
||||
withGitRepoLock,
|
||||
} from '../../services/git-manager.js';
|
||||
import type { ActivityLogger } from '../../types/activity-logger.js';
|
||||
import type { ReconciliationClass } from '../../types/reconciliation.js';
|
||||
import {
|
||||
ArtifactIntegrityError,
|
||||
PublicationConflictError,
|
||||
ReconciliationError,
|
||||
ReconciliationIoError,
|
||||
readArtifact,
|
||||
} from './artifact-store.js';
|
||||
import type { ArtifactInputDigest, ArtifactRef, PublicationContract, ReconciliationObservation } from './contracts.js';
|
||||
import {
|
||||
isManifest,
|
||||
isManifestCoherent,
|
||||
type ManifestLineageEntry,
|
||||
type PublicationManifest,
|
||||
readPublishedManifest,
|
||||
} from './manifest.js';
|
||||
import { mintTaskReferences } from './materialize-core.js';
|
||||
import { exploitationQueuePath, reconciliationManifestPath, sastProvenancePath } from './prepare.js';
|
||||
import { isProducerId, isTaskReference } from './refs.js';
|
||||
import { RECONCILIATION_SCHEMA_VERSION } from './schema-version.js';
|
||||
import type { FixedTasksBody, ProducerObservationsBody, SupplementalObservationsBody } from './stage-contracts.js';
|
||||
|
||||
// Every key here is internal bookkeeping that must never reach the exploitation queue a downstream
|
||||
// exploit agent reads: a producer ID or source-adapter identifier would tell that agent exactly
|
||||
// which scan producer (and by extension, which internal class/source combination) found the
|
||||
// vulnerability. This exact set is duplicated in prepare.ts (guarding the lost-acknowledgement
|
||||
// repair path there); the two must be kept identical, since a key added to only one would let it
|
||||
// slip through whichever path was not updated.
|
||||
const FORBIDDEN_PUBLISHED_KEYS: ReadonlySet<string> = new Set([
|
||||
'producer_id',
|
||||
'primary_preference',
|
||||
'observation_key',
|
||||
'novelty',
|
||||
'_sastId',
|
||||
'_sast_id',
|
||||
'repository_id',
|
||||
'scan_run_id',
|
||||
]);
|
||||
|
||||
interface PublicationFile {
|
||||
path: string;
|
||||
contents: string;
|
||||
}
|
||||
|
||||
interface PreparedPublication {
|
||||
contract: PublicationContract;
|
||||
files: PublicationFile[];
|
||||
manifest: PublicationManifest;
|
||||
manifestContents: string;
|
||||
manifestSha256: string;
|
||||
producerBody: ProducerObservationsBody;
|
||||
includeSastProvenance: boolean;
|
||||
}
|
||||
|
||||
/** Result returned both for a fresh commit and an existing coherent publication. */
|
||||
export interface PublishClassReconciliationResult {
|
||||
alreadyPublished: boolean;
|
||||
manifestSha256: string;
|
||||
commitHash: string;
|
||||
}
|
||||
|
||||
export interface PublishClassReconciliationOssArgs {
|
||||
deliverablesDir: string;
|
||||
sessionId: string;
|
||||
workspacesDir?: string;
|
||||
vulnerabilityClass: ReconciliationClass;
|
||||
producerRef: ArtifactRef<'producer-observations'>;
|
||||
supplementalRef: ArtifactRef<'supplemental-observations'>;
|
||||
fixedTasksRef: ArtifactRef<'fixed-tasks'>;
|
||||
logger: ActivityLogger;
|
||||
}
|
||||
|
||||
function serialize(value: unknown): string {
|
||||
return `${JSON.stringify(value, null, 2)}\n`;
|
||||
}
|
||||
|
||||
function sha256Text(contents: string): string {
|
||||
return createHash('sha256').update(contents, 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function arraysEqual<T>(first: readonly T[], second: readonly T[]): boolean {
|
||||
return first.length === second.length && first.every((value, index) => value === second[index]);
|
||||
}
|
||||
|
||||
function sameStringSet(first: readonly string[], second: readonly string[]): boolean {
|
||||
return (
|
||||
first.length === second.length &&
|
||||
new Set(first).size === first.length &&
|
||||
first.every((value) => second.includes(value))
|
||||
);
|
||||
}
|
||||
|
||||
/** Derive the exact consumer and manifest paths for one OSS class publication. */
|
||||
export function publicationContractForClass(
|
||||
vulnerabilityClass: ReconciliationClass,
|
||||
includeSastProvenance: boolean,
|
||||
): PublicationContract {
|
||||
const requiredOutputPaths = [
|
||||
exploitationQueuePath(vulnerabilityClass),
|
||||
...(includeSastProvenance ? [sastProvenancePath(vulnerabilityClass)] : []),
|
||||
];
|
||||
return {
|
||||
publicationKind: 'class-reconciliation',
|
||||
schemaVersion: RECONCILIATION_SCHEMA_VERSION,
|
||||
manifestPath: reconciliationManifestPath(vulnerabilityClass),
|
||||
requiredOutputPaths,
|
||||
};
|
||||
}
|
||||
|
||||
function producerIdsFromFixed(fixed: FixedTasksBody): string[] {
|
||||
const producerIds: string[] = [];
|
||||
for (const task of fixed.tasks) {
|
||||
producerIds.push(task.producer_id);
|
||||
for (const member of task.merged_from ?? []) producerIds.push(member.producer_id);
|
||||
}
|
||||
return producerIds;
|
||||
}
|
||||
|
||||
// Redact longest IDs first so a short producer ID that is a substring of a longer one cannot
|
||||
// partially rewrite the longer token and leave a recognizable fragment behind.
|
||||
function redactProducerIds(value: string, producerIds: readonly string[]): string {
|
||||
let redacted = value;
|
||||
for (const producerId of [...producerIds].sort((first, second) => second.length - first.length)) {
|
||||
if (redacted.includes(producerId)) redacted = redacted.split(producerId).join('[redacted]');
|
||||
}
|
||||
return redacted;
|
||||
}
|
||||
|
||||
function scrubPublishedValue(value: unknown, producerIds: readonly string[]): unknown {
|
||||
if (typeof value === 'string') return redactProducerIds(value, producerIds);
|
||||
if (Array.isArray(value)) return value.map((entry) => scrubPublishedValue(entry, producerIds));
|
||||
if (value === null || typeof value !== 'object') return value;
|
||||
|
||||
const cleaned: Record<string, unknown> = {};
|
||||
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (FORBIDDEN_PUBLISHED_KEYS.has(key)) continue;
|
||||
const publicKey = redactProducerIds(key, producerIds);
|
||||
if (publicKey in cleaned) {
|
||||
throw new ArtifactIntegrityError('Producer-ID redaction would collide two published evidence keys');
|
||||
}
|
||||
cleaned[publicKey] = scrubPublishedValue(entry, producerIds);
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
function findForbiddenPublishedKey(value: unknown): string | null {
|
||||
if (Array.isArray(value)) {
|
||||
for (const entry of value) {
|
||||
const found = findForbiddenPublishedKey(entry);
|
||||
if (found !== null) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (value === null || typeof value !== 'object') return null;
|
||||
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (FORBIDDEN_PUBLISHED_KEYS.has(key)) return key;
|
||||
const found = findForbiddenPublishedKey(entry);
|
||||
if (found !== null) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the canonical consumer queue while retaining evidence and public source annotations.
|
||||
*
|
||||
* Internal producer identity must never reach the published queue that a downstream exploit agent
|
||||
* reads. Three independent passes enforce that: drop forbidden keys and redact ID tokens while
|
||||
* copying, then re-scan the result for any forbidden key, then serialize and fail if any producer
|
||||
* ID string survived. The second and third passes are belt-and-suspenders against a redaction miss.
|
||||
*/
|
||||
export function buildPublishedQueue(fixed: FixedTasksBody): { vulnerabilities: Record<string, unknown>[] } {
|
||||
const producerIds = producerIdsFromFixed(fixed);
|
||||
const scrubbed = scrubPublishedValue({ vulnerabilities: fixed.tasks }, producerIds) as {
|
||||
vulnerabilities: Record<string, unknown>[];
|
||||
};
|
||||
const forbiddenKey = findForbiddenPublishedKey(scrubbed);
|
||||
if (forbiddenKey !== null) {
|
||||
throw new ArtifactIntegrityError(`Published queue retained forbidden internal key ${forbiddenKey}`);
|
||||
}
|
||||
const serialized = JSON.stringify(scrubbed);
|
||||
if (producerIds.some((producerId) => serialized.includes(producerId))) {
|
||||
throw new ArtifactIntegrityError('Published queue retained a producer identifier token');
|
||||
}
|
||||
return scrubbed;
|
||||
}
|
||||
|
||||
function observationIds(
|
||||
producerBody: ProducerObservationsBody,
|
||||
supplementalBody: SupplementalObservationsBody,
|
||||
): string[] {
|
||||
return [...producerBody.observations, ...supplementalBody.observations].map((observation) => observation.producer_id);
|
||||
}
|
||||
|
||||
// Confirms a producer ID actually belongs to the namespace its own declared scan_source implies
|
||||
// (VULN for pentest, SAST for static analysis), for the declared class. This is what stops a
|
||||
// mislabeled or forged scan_source from having its identity validated against the wrong producer
|
||||
// namespace, which would otherwise let it dodge the class/source checks the rest of publication
|
||||
// relies on.
|
||||
function validateProducerIdentity(
|
||||
producerId: string,
|
||||
scanSource: ReconciliationObservation['scan_source'],
|
||||
vulnerabilityClass: ReconciliationClass,
|
||||
): boolean {
|
||||
if (scanSource === 'sast') return isProducerId(producerId, vulnerabilityClass, 'SAST');
|
||||
if (scanSource === 'vulnerability_analysis') return isProducerId(producerId, vulnerabilityClass, 'VULN');
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse to publish unless the fixed tasks are a complete, exact partition of the observation set.
|
||||
*
|
||||
* Checks, independently: every observation ID appears at most once across the two input bodies;
|
||||
* every task reference is the dense, class-namespaced value materialization should have minted for
|
||||
* its position, with no duplicates; every member's producer ID belongs to the class/source
|
||||
* namespace its own scan_source claims; and the observation-to-task map agrees with task membership
|
||||
* in both directions. Any one of these failing means either an observation was silently dropped or
|
||||
* duplicated, or a task references identity outside its declared boundary, either of which is a
|
||||
* fail-closed reason to abort the publish rather than commit an inconsistent queue.
|
||||
*/
|
||||
function validateFixedTasks(
|
||||
fixed: FixedTasksBody,
|
||||
producerBody: ProducerObservationsBody,
|
||||
supplementalBody: SupplementalObservationsBody,
|
||||
vulnerabilityClass: ReconciliationClass,
|
||||
): void {
|
||||
const expectedObservationIds = observationIds(producerBody, supplementalBody);
|
||||
if (new Set(expectedObservationIds).size !== expectedObservationIds.length) {
|
||||
throw new ArtifactIntegrityError('Publication inputs contain duplicate observation identifiers');
|
||||
}
|
||||
|
||||
const taskIds = new Set<string>();
|
||||
const materializedIds = new Set<string>();
|
||||
const expectedTaskIds = mintTaskReferences(fixed.tasks.length, vulnerabilityClass);
|
||||
for (const [index, task] of fixed.tasks.entries()) {
|
||||
if (!isTaskReference(task.ID, vulnerabilityClass) || task.ID !== expectedTaskIds[index] || taskIds.has(task.ID)) {
|
||||
throw new ArtifactIntegrityError('Fixed tasks contain an invalid or duplicate task reference');
|
||||
}
|
||||
taskIds.add(task.ID);
|
||||
const members = [task, ...(task.merged_from ?? [])];
|
||||
for (const member of members) {
|
||||
if (
|
||||
materializedIds.has(member.producer_id) ||
|
||||
!validateProducerIdentity(member.producer_id, member.scan_source, vulnerabilityClass)
|
||||
) {
|
||||
throw new ArtifactIntegrityError('Fixed tasks contain duplicate or cross-namespace producer identity');
|
||||
}
|
||||
materializedIds.add(member.producer_id);
|
||||
if (fixed.observation_to_task[member.producer_id] !== task.ID) {
|
||||
throw new ArtifactIntegrityError('Observation-to-task map disagrees with fixed task membership');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!sameStringSet([...materializedIds], expectedObservationIds)) {
|
||||
throw new ArtifactIntegrityError('Fixed tasks do not cover the complete observation set exactly once');
|
||||
}
|
||||
if (!sameStringSet(Object.keys(fixed.observation_to_task), expectedObservationIds)) {
|
||||
throw new ArtifactIntegrityError('Observation-to-task map is incomplete or contains extra entries');
|
||||
}
|
||||
}
|
||||
|
||||
function lineageHas(
|
||||
inputs: readonly ArtifactInputDigest[],
|
||||
kind: ArtifactInputDigest['artifactKind'],
|
||||
sha256: string,
|
||||
): boolean {
|
||||
return inputs.some((input) => input.artifactKind === kind && input.sha256 === sha256);
|
||||
}
|
||||
|
||||
function assertRefClass(ref: ArtifactRef, vulnerabilityClass: ReconciliationClass): void {
|
||||
if (ref.vulnerabilityClass !== vulnerabilityClass) {
|
||||
throw new ArtifactIntegrityError('Publication artifact reference crosses the declared class boundary');
|
||||
}
|
||||
}
|
||||
|
||||
function buildLineage(fixed: FixedTasksBody): Record<string, ManifestLineageEntry> {
|
||||
const lineage: Record<string, ManifestLineageEntry> = Object.create(null);
|
||||
for (const task of fixed.tasks) {
|
||||
lineage[task.ID] = {
|
||||
primary: task.producer_id,
|
||||
absorbed: (task.merged_from ?? []).map((member) => member.producer_id),
|
||||
};
|
||||
}
|
||||
return lineage;
|
||||
}
|
||||
|
||||
function buildManifest(args: {
|
||||
sessionId: string;
|
||||
vulnerabilityClass: ReconciliationClass;
|
||||
producerBody: ProducerObservationsBody;
|
||||
consumerFiles: readonly PublicationFile[];
|
||||
inputDigests: readonly ArtifactInputDigest[];
|
||||
fixed: FixedTasksBody;
|
||||
}): PublicationManifest {
|
||||
return {
|
||||
session_id: args.sessionId,
|
||||
vulnerability_class: args.vulnerabilityClass,
|
||||
schema_version: RECONCILIATION_SCHEMA_VERSION,
|
||||
producer_queue: {
|
||||
path: args.producerBody.producer_queue.path,
|
||||
blob_sha: args.producerBody.producer_queue.blob_sha,
|
||||
},
|
||||
consumer_files: args.consumerFiles.map((file) => ({ path: file.path, sha256: sha256Text(file.contents) })),
|
||||
input_digests: args.inputDigests.map((input) => ({ artifactKind: input.artifactKind, sha256: input.sha256 })),
|
||||
lineage: buildLineage(args.fixed),
|
||||
};
|
||||
}
|
||||
|
||||
async function preparePublication(args: PublishClassReconciliationOssArgs): Promise<PreparedPublication> {
|
||||
assertRefClass(args.producerRef, args.vulnerabilityClass);
|
||||
assertRefClass(args.supplementalRef, args.vulnerabilityClass);
|
||||
assertRefClass(args.fixedTasksRef, args.vulnerabilityClass);
|
||||
|
||||
const producerBody = await readArtifact(args.producerRef, args.sessionId, args.workspacesDir);
|
||||
const supplementalBody = await readArtifact(args.supplementalRef, args.sessionId, args.workspacesDir);
|
||||
const fixed = await readArtifact(args.fixedTasksRef, args.sessionId, args.workspacesDir);
|
||||
if (
|
||||
!Array.isArray(producerBody.observations) ||
|
||||
!isRecord(producerBody.producer_queue) ||
|
||||
!Array.isArray(supplementalBody.observations) ||
|
||||
!Array.isArray(supplementalBody.provenance) ||
|
||||
!Array.isArray(fixed.tasks) ||
|
||||
!isRecord(fixed.observation_to_task)
|
||||
) {
|
||||
throw new ArtifactIntegrityError('Publication artifact body is truncated or malformed');
|
||||
}
|
||||
if (
|
||||
!lineageHas(args.fixedTasksRef.inputs, 'producer-observations', args.producerRef.sha256) ||
|
||||
!lineageHas(args.fixedTasksRef.inputs, 'supplemental-observations', args.supplementalRef.sha256)
|
||||
) {
|
||||
throw new ArtifactIntegrityError('Fixed-task lineage does not name the publication observations');
|
||||
}
|
||||
if (producerBody.producer_queue.path !== exploitationQueuePath(args.vulnerabilityClass)) {
|
||||
throw new ArtifactIntegrityError('Producer artifact names the wrong class queue');
|
||||
}
|
||||
if (supplementalBody.provenance.length !== 0) {
|
||||
throw new ArtifactIntegrityError('Standalone OSS supplemental provenance must remain empty');
|
||||
}
|
||||
validateFixedTasks(fixed, producerBody, supplementalBody, args.vulnerabilityClass);
|
||||
|
||||
const includeSastProvenance = supplementalBody.sarif !== undefined;
|
||||
const contract = publicationContractForClass(args.vulnerabilityClass, includeSastProvenance);
|
||||
const consumerFiles: PublicationFile[] = [
|
||||
{ path: exploitationQueuePath(args.vulnerabilityClass), contents: serialize(buildPublishedQueue(fixed)) },
|
||||
...(includeSastProvenance
|
||||
? [{ path: sastProvenancePath(args.vulnerabilityClass), contents: serialize({ entries: [] }) }]
|
||||
: []),
|
||||
];
|
||||
const inputDigests: ArtifactInputDigest[] = [
|
||||
{ artifactKind: 'producer-observations', sha256: args.producerRef.sha256 },
|
||||
{ artifactKind: 'supplemental-observations', sha256: args.supplementalRef.sha256 },
|
||||
{ artifactKind: 'fixed-tasks', sha256: args.fixedTasksRef.sha256 },
|
||||
];
|
||||
const manifest = buildManifest({
|
||||
sessionId: args.sessionId,
|
||||
vulnerabilityClass: args.vulnerabilityClass,
|
||||
producerBody,
|
||||
consumerFiles,
|
||||
inputDigests,
|
||||
fixed,
|
||||
});
|
||||
if (!isManifest(manifest)) {
|
||||
throw new ArtifactIntegrityError('Built OSS publication manifest failed self-validation');
|
||||
}
|
||||
const manifestContents = serialize(manifest);
|
||||
return {
|
||||
contract,
|
||||
files: [...consumerFiles, { path: contract.manifestPath, contents: manifestContents }],
|
||||
manifest,
|
||||
manifestContents,
|
||||
manifestSha256: sha256Text(manifestContents),
|
||||
producerBody,
|
||||
includeSastProvenance,
|
||||
};
|
||||
}
|
||||
|
||||
function lineageEquals(
|
||||
first: Record<string, ManifestLineageEntry>,
|
||||
second: Record<string, ManifestLineageEntry>,
|
||||
): boolean {
|
||||
const firstKeys = Object.keys(first);
|
||||
const secondKeys = Object.keys(second);
|
||||
if (!arraysEqual(firstKeys, secondKeys)) return false;
|
||||
return firstKeys.every((key) => {
|
||||
const firstEntry = first[key];
|
||||
const secondEntry = second[key];
|
||||
return (
|
||||
firstEntry !== undefined &&
|
||||
secondEntry !== undefined &&
|
||||
firstEntry.primary === secondEntry.primary &&
|
||||
arraysEqual(firstEntry.absorbed, secondEntry.absorbed) &&
|
||||
firstEntry.novelty === secondEntry.novelty
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function manifestMatchesPrepared(existing: PublicationManifest, prepared: PublicationManifest): boolean {
|
||||
if (
|
||||
!arraysEqual(
|
||||
existing.input_digests.map((input) => `${input.artifactKind}:${input.sha256}`),
|
||||
prepared.input_digests.map((input) => `${input.artifactKind}:${input.sha256}`),
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const expectedConsumers = new Map(prepared.consumer_files.map((consumer) => [consumer.path, consumer.sha256]));
|
||||
if (
|
||||
existing.consumer_files.length !== expectedConsumers.size ||
|
||||
existing.consumer_files.some((consumer) => expectedConsumers.get(consumer.path) !== consumer.sha256)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return lineageEquals(existing.lineage, prepared.lineage);
|
||||
}
|
||||
|
||||
async function verifyCommittedFiles(deliverablesDir: string, files: readonly PublicationFile[]): Promise<void> {
|
||||
for (const file of files) {
|
||||
const committed = await readCommittedFile(deliverablesDir, file.path);
|
||||
if (committed.state !== 'present' || committed.contents !== file.contents) {
|
||||
throw new PublicationConflictError('Committed publication bytes do not match the prepared exact bytes');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adopt an already-committed publication when the HEAD manifest matches what this call prepared.
|
||||
*
|
||||
* Returns null when nothing is published yet (the caller then commits). Returns a result with
|
||||
* `alreadyPublished: true` when a coherent, byte-identical publication already exists, which is how
|
||||
* a re-drive after a lost acknowledgement converges instead of committing a second time. Any
|
||||
* manifest that exists but disagrees is a conflict, never a silent overwrite. Before returning, it
|
||||
* restores the exact committed paths from HEAD so a partially written retry leaves no dirty bytes.
|
||||
*/
|
||||
async function tryReturnExistingPublication(
|
||||
args: PublishClassReconciliationOssArgs,
|
||||
prepared: PreparedPublication,
|
||||
): Promise<PublishClassReconciliationResult | null> {
|
||||
const manifestRead = await readPublishedManifest(args.deliverablesDir, prepared.contract.manifestPath);
|
||||
if (manifestRead.state === 'absent') return null;
|
||||
if (manifestRead.state === 'invalid') {
|
||||
throw new PublicationConflictError(`Corrupt class manifest in HEAD: ${manifestRead.reason}`);
|
||||
}
|
||||
if (
|
||||
!isManifestCoherent({
|
||||
manifest: manifestRead.manifest,
|
||||
sessionId: args.sessionId,
|
||||
vulnerabilityClass: args.vulnerabilityClass,
|
||||
contract: prepared.contract,
|
||||
producerQueuePath: exploitationQueuePath(args.vulnerabilityClass),
|
||||
producerBlobSha: prepared.producerBody.producer_queue.blob_sha,
|
||||
}) ||
|
||||
!manifestMatchesPrepared(manifestRead.manifest, prepared.manifest)
|
||||
) {
|
||||
throw new PublicationConflictError('Existing class publication conflicts with the prepared publication');
|
||||
}
|
||||
if (!prepared.includeSastProvenance) {
|
||||
const unexpectedProvenance = await readCommittedFile(
|
||||
args.deliverablesDir,
|
||||
sastProvenancePath(args.vulnerabilityClass),
|
||||
);
|
||||
if (unexpectedProvenance.state !== 'absent') {
|
||||
throw new PublicationConflictError('Existing publication has provenance outside its exact manifest path set');
|
||||
}
|
||||
}
|
||||
await preflightSymlinks(args.deliverablesDir, [
|
||||
...prepared.contract.requiredOutputPaths,
|
||||
prepared.contract.manifestPath,
|
||||
]);
|
||||
await verifyCommittedFiles(args.deliverablesDir, prepared.files.slice(0, -1));
|
||||
await restorePathsFromHead(args.deliverablesDir, [
|
||||
...prepared.contract.requiredOutputPaths,
|
||||
prepared.contract.manifestPath,
|
||||
]);
|
||||
const commitHash = await lastCommitForPathAtHead(args.deliverablesDir, prepared.contract.manifestPath);
|
||||
if (commitHash === null) throw new ReconciliationIoError('Unable to read the existing publication commit');
|
||||
return {
|
||||
alreadyPublished: true,
|
||||
manifestSha256: sha256Text(manifestRead.contents),
|
||||
commitHash,
|
||||
};
|
||||
}
|
||||
|
||||
function isErrno(error: unknown, code: string): boolean {
|
||||
return error instanceof Error && (error as NodeJS.ErrnoException).code === code;
|
||||
}
|
||||
|
||||
// Refuses to publish through a path that is currently a symlink, so a deliverables directory
|
||||
// entry cannot redirect a publication write to somewhere outside the intended destination. Absence
|
||||
// (`ENOENT`) is not a violation; the path simply does not exist yet, which is the normal case for a
|
||||
// first publish.
|
||||
async function preflightSymlinks(deliverablesDir: string, relativePaths: readonly string[]): Promise<void> {
|
||||
for (const relativePath of relativePaths) {
|
||||
try {
|
||||
const stat = await lstat(path.join(deliverablesDir, relativePath));
|
||||
if (stat.isSymbolicLink()) throw new PublicationConflictError('Refusing to publish through a symlink');
|
||||
} catch (error) {
|
||||
if (isErrno(error, 'ENOENT')) continue;
|
||||
if (error instanceof ReconciliationError) throw error;
|
||||
throw new ReconciliationIoError('Unable to inspect a class publication destination');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function writeFileReplacingEntry(absolutePath: string, contents: string): Promise<void> {
|
||||
const temporaryPath = `${absolutePath}.tmp-${randomUUID()}`;
|
||||
try {
|
||||
await writeFile(temporaryPath, contents, { flag: 'wx' });
|
||||
await rename(temporaryPath, absolutePath);
|
||||
} catch (error) {
|
||||
await unlink(temporaryPath).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function expectedChangedPaths(deliverablesDir: string, files: readonly PublicationFile[]): Promise<string[]> {
|
||||
const changed: string[] = [];
|
||||
for (const file of files) {
|
||||
const committed = await readCommittedFile(deliverablesDir, file.path);
|
||||
if (committed.state === 'corrupt') {
|
||||
throw new PublicationConflictError('Publication destination has corrupt committed state');
|
||||
}
|
||||
if (committed.state === 'absent' || committed.contents !== file.contents) changed.push(file.path);
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
// The reconciliation prepared against a specific committed producer queue. If that queue changed
|
||||
// in HEAD between preparation and commit, the prepared tasks no longer describe it, so publishing
|
||||
// them would be incoherent. Both the blob SHA and a content digest are checked to catch a change
|
||||
// even if one identity were somehow reused.
|
||||
async function assertProducerStillCurrent(
|
||||
deliverablesDir: string,
|
||||
producerBody: ProducerObservationsBody,
|
||||
): Promise<void> {
|
||||
const queuePath = producerBody.producer_queue.path;
|
||||
const currentBlob = await blobShaFromHead(deliverablesDir, queuePath);
|
||||
if (currentBlob.state !== 'present' || currentBlob.sha !== producerBody.producer_queue.blob_sha) {
|
||||
throw new PublicationConflictError('Producer queue changed after reconciliation preparation');
|
||||
}
|
||||
const current = await readCommittedFile(deliverablesDir, queuePath);
|
||||
if (current.state !== 'present' || sha256Text(current.contents) !== producerBody.producer_queue.digest) {
|
||||
throw new PublicationConflictError('Producer queue bytes changed after reconciliation preparation');
|
||||
}
|
||||
}
|
||||
|
||||
// Standalone provenance with no manifest can only mean a prior publication attempt wrote some of
|
||||
// its files and was interrupted before the manifest (and therefore the whole commit) landed. That is
|
||||
// not a state a fresh publish should build on top of or silently repair by overwriting; it fails
|
||||
// closed and surfaces as a conflict instead.
|
||||
async function ensureNoPartialPreManifestState(
|
||||
deliverablesDir: string,
|
||||
vulnerabilityClass: ReconciliationClass,
|
||||
): Promise<void> {
|
||||
const provenance = await readCommittedFile(deliverablesDir, sastProvenancePath(vulnerabilityClass));
|
||||
if (provenance.state !== 'absent') {
|
||||
throw new PublicationConflictError('Pre-manifest class state contains standalone provenance');
|
||||
}
|
||||
}
|
||||
|
||||
async function commitPreparedPublication(
|
||||
args: PublishClassReconciliationOssArgs,
|
||||
prepared: PreparedPublication,
|
||||
): Promise<PublishClassReconciliationResult> {
|
||||
const ownedPaths = prepared.files.map((file) => file.path);
|
||||
await assertProducerStillCurrent(args.deliverablesDir, prepared.producerBody);
|
||||
await ensureNoPartialPreManifestState(args.deliverablesDir, args.vulnerabilityClass);
|
||||
await preflightSymlinks(args.deliverablesDir, ownedPaths);
|
||||
// Compute the exact set of paths whose bytes differ from HEAD before writing, and hand it to the
|
||||
// commit as the expected delta. The commit rejects any staged change outside this set, so a
|
||||
// dirty sibling file cannot ride along into this publication commit.
|
||||
const expectedChanges = await expectedChangedPaths(args.deliverablesDir, prepared.files);
|
||||
|
||||
try {
|
||||
for (const file of prepared.files) {
|
||||
await writeFileReplacingEntry(path.join(args.deliverablesDir, file.path), file.contents);
|
||||
}
|
||||
} catch (error) {
|
||||
await restorePathsFromHead(args.deliverablesDir, ownedPaths);
|
||||
throw error instanceof ReconciliationError
|
||||
? error
|
||||
: new ReconciliationIoError('Unable to write prepared class publication bytes');
|
||||
}
|
||||
|
||||
let commitHash: string;
|
||||
try {
|
||||
const committed = await commitExactPaths(
|
||||
args.deliverablesDir,
|
||||
ownedPaths,
|
||||
`Publish ${args.vulnerabilityClass} reconciliation`,
|
||||
args.logger,
|
||||
expectedChanges,
|
||||
);
|
||||
commitHash = committed.commitHash;
|
||||
} catch (error) {
|
||||
await restorePathsFromHead(args.deliverablesDir, ownedPaths);
|
||||
if (error instanceof ExactPathCommitMismatchError) {
|
||||
throw new PublicationConflictError('Publication staged path set differs from its exact prepared delta');
|
||||
}
|
||||
throw error instanceof ReconciliationError
|
||||
? error
|
||||
: new ReconciliationIoError('Unable to commit prepared class publication bytes');
|
||||
}
|
||||
|
||||
await verifyCommittedFiles(args.deliverablesDir, prepared.files);
|
||||
return {
|
||||
alreadyPublished: false,
|
||||
manifestSha256: prepared.manifestSha256,
|
||||
commitHash,
|
||||
};
|
||||
}
|
||||
|
||||
/** Publish one OSS class through one reentrant Git critical section. */
|
||||
export async function publishClassReconciliationOss(
|
||||
args: PublishClassReconciliationOssArgs,
|
||||
): Promise<PublishClassReconciliationResult> {
|
||||
const prepared = await preparePublication(args);
|
||||
return withGitRepoLock(async () => {
|
||||
const existing = await tryReturnExistingPublication(args, prepared);
|
||||
if (existing !== null) return existing;
|
||||
return commitPreparedPublication(args, prepared);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// 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.
|
||||
|
||||
/** Stable producer and exploitation-task reference namespaces. */
|
||||
|
||||
import type { ReconciliationClass } from '../../types/reconciliation.js';
|
||||
|
||||
export const REF_PREFIX: Readonly<Record<ReconciliationClass, string>> = Object.freeze({
|
||||
injection: 'INJ',
|
||||
xss: 'XSS',
|
||||
auth: 'AUTH',
|
||||
authz: 'AUTHZ',
|
||||
ssrf: 'SSRF',
|
||||
miscellaneous: 'MISC',
|
||||
});
|
||||
|
||||
export type ProducerSource = 'VULN' | 'SAST';
|
||||
|
||||
function positiveReferenceNumberPattern(): string {
|
||||
return '0*[1-9][0-9]*';
|
||||
}
|
||||
|
||||
/** The source-aware producer-ID pattern admitted for one internal class. */
|
||||
export function producerIdPattern(vulnClass: ReconciliationClass, source: ProducerSource): RegExp {
|
||||
if (vulnClass === 'miscellaneous' && source === 'VULN') {
|
||||
// The `miscellaneous` class has no vulnerability-analysis producer: it is seeded queue-only
|
||||
// and carries SAST producers alone. This pattern can never match, so any `MISC-VULN-*` ID
|
||||
// is rejected rather than admitted.
|
||||
return /(?!)^/;
|
||||
}
|
||||
return new RegExp(`^${REF_PREFIX[vulnClass]}-${source}-${positiveReferenceNumberPattern()}$`);
|
||||
}
|
||||
|
||||
/** Whether an ID belongs to the class and producer source that declared it. */
|
||||
export function isProducerId(id: string, vulnClass: ReconciliationClass, source: ProducerSource): boolean {
|
||||
return producerIdPattern(vulnClass, source).test(id);
|
||||
}
|
||||
|
||||
/** The stable exploitation-task reference pattern for one internal class. */
|
||||
export function taskReferencePattern(vulnClass: ReconciliationClass): RegExp {
|
||||
return new RegExp(`^${REF_PREFIX[vulnClass]}-${positiveReferenceNumberPattern()}$`);
|
||||
}
|
||||
|
||||
/** Whether an ID is a stable task reference in the declared class namespace. */
|
||||
export function isTaskReference(id: string, vulnClass: ReconciliationClass): boolean {
|
||||
return taskReferencePattern(vulnClass).test(id);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/** Build the bounded finding context sent to the SAST enrichment model. */
|
||||
|
||||
import type {
|
||||
DataflowFindingContext,
|
||||
FindingContext,
|
||||
LocalizedFindingContext,
|
||||
SarifLocation,
|
||||
SarifResult,
|
||||
} from './types.js';
|
||||
|
||||
function locationInfo(location: SarifLocation): { file: string; line: number; column: number; snippet: string } {
|
||||
const physical = location.physicalLocation;
|
||||
return {
|
||||
file: physical.artifactLocation.uri,
|
||||
line: physical.region.startLine,
|
||||
column: physical.region.startColumn ?? 1,
|
||||
snippet: physical.region.snippet?.text ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
function confidenceScore(level: SarifResult['level']): number {
|
||||
if (level === 'error') return 0.9;
|
||||
if (level === 'warning') return 0.6;
|
||||
return 0.3;
|
||||
}
|
||||
|
||||
// Best-effort labeling for the model's context only; a wrong guess here does not affect identity,
|
||||
// dedupe, or which class a finding routes to; it only changes how a step reads in the enrichment prompt.
|
||||
function stepRole(message: string, index: number, lastIndex: number): string {
|
||||
const normalized = message.toLowerCase();
|
||||
if (normalized.includes('sanit')) return 'SANITIZED';
|
||||
if (normalized.startsWith('source')) return 'SOURCE';
|
||||
if (normalized.startsWith('sink')) return 'SINK';
|
||||
if (index === 0) return 'SOURCE';
|
||||
if (index === lastIndex) return 'SINK';
|
||||
return 'HOP';
|
||||
}
|
||||
|
||||
function localizedContext(result: SarifResult): LocalizedFindingContext {
|
||||
const primary = locationInfo(result.locations[0]);
|
||||
return {
|
||||
cwe: result.ruleId,
|
||||
message: result.message.text,
|
||||
severity: result.properties.severity.toLowerCase(),
|
||||
confidence: confidenceScore(result.level),
|
||||
...primary,
|
||||
};
|
||||
}
|
||||
|
||||
/** Extract only validated SARIF fields; no repository identity or fallback path is synthesized. */
|
||||
export function extractContext(result: SarifResult): FindingContext {
|
||||
const locations = result.codeFlows[0]?.threadFlows[0]?.locations;
|
||||
if (locations === undefined || locations.length === 0) return localizedContext(result);
|
||||
|
||||
const lastIndex = locations.length - 1;
|
||||
const dataflowPath = locations.map((step, index) => {
|
||||
const info = locationInfo(step.location);
|
||||
return { ...info, role: stepRole(step.location.message?.text ?? '', index, lastIndex) };
|
||||
});
|
||||
const source = dataflowPath[0];
|
||||
const sink = dataflowPath[lastIndex];
|
||||
if (source === undefined || sink === undefined) return localizedContext(result);
|
||||
|
||||
const context: DataflowFindingContext = {
|
||||
cwe: result.ruleId,
|
||||
message: result.message.text,
|
||||
severity: result.properties.severity.toLowerCase(),
|
||||
confidence: confidenceScore(result.level),
|
||||
sinkFile: sink.file,
|
||||
sinkLine: sink.line,
|
||||
sinkColumn: sink.column,
|
||||
sinkSnippet: sink.snippet,
|
||||
sourceFile: source.file,
|
||||
sourceLine: source.line,
|
||||
sourceColumn: source.column,
|
||||
sourceSnippet: source.snippet,
|
||||
dataflowPath,
|
||||
validationReason: result.properties.description,
|
||||
sanitizationStatus: dataflowPath.some((step) => step.role === 'SANITIZED') ? 'partial' : 'none',
|
||||
};
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/** Deterministic bare-CWE routing into Shannon's six internal classes. */
|
||||
|
||||
import type { ReconciliationClass } from '../../../types/reconciliation.js';
|
||||
import type { Confidence, CWEMapping, ShannonCategory } from './types.js';
|
||||
|
||||
export const CWE_TO_CATEGORY: Readonly<Record<string, CWEMapping>> = Object.freeze({
|
||||
'CWE-89': { category: 'INJECTION', name: 'SQL Injection', priority: 'P1' },
|
||||
'CWE-78': { category: 'INJECTION', name: 'OS Command Injection', priority: 'P1' },
|
||||
'CWE-95': { category: 'INJECTION', name: 'Code/Eval Injection', priority: 'P1' },
|
||||
'CWE-94': { category: 'INJECTION', name: 'Code Injection', priority: 'P1' },
|
||||
'CWE-502': { category: 'INJECTION', name: 'Deserialization', priority: 'P1' },
|
||||
'CWE-611': { category: 'INJECTION', name: 'XXE', priority: 'P1' },
|
||||
'CWE-22': { category: 'INJECTION', name: 'Path Traversal', priority: 'P1' },
|
||||
'CWE-434': { category: 'INJECTION', name: 'Unrestricted File Upload', priority: 'P1' },
|
||||
'CWE-943': { category: 'INJECTION', name: 'NoSQL Injection', priority: 'P1' },
|
||||
'CWE-93': { category: 'INJECTION', name: 'CRLF Injection', priority: 'P2' },
|
||||
'CWE-117': { category: 'INJECTION', name: 'Log Injection', priority: 'P2' },
|
||||
'CWE-470': { category: 'INJECTION', name: 'Unsafe Reflection', priority: 'P2' },
|
||||
'CWE-829': { category: 'INJECTION', name: 'Untrusted Function Inclusion', priority: 'P1' },
|
||||
'CWE-643': { category: 'INJECTION', name: 'XPath Injection', priority: 'P2' },
|
||||
'CWE-90': { category: 'INJECTION', name: 'LDAP Injection', priority: 'P2' },
|
||||
'CWE-91': { category: 'INJECTION', name: 'XML Injection', priority: 'P2' },
|
||||
'CWE-1336': { category: 'INJECTION', name: 'Template Injection', priority: 'P1' },
|
||||
'CWE-1427': { category: 'INJECTION', name: 'Prompt Injection', priority: 'P2' },
|
||||
'CWE-1321': { category: 'INJECTION', name: 'Prototype Pollution', priority: 'P1' },
|
||||
'CWE-548': { category: 'INJECTION', name: 'Directory Listing', priority: 'P3' },
|
||||
|
||||
'CWE-79': { category: 'XSS', name: 'Cross-Site Scripting', priority: 'P1' },
|
||||
|
||||
'CWE-287': { category: 'AUTH', name: 'Broken Authentication', priority: 'P1' },
|
||||
'CWE-798': { category: 'AUTH', name: 'Hard-coded Credentials', priority: 'P1' },
|
||||
'CWE-319': { category: 'AUTH', name: 'Cleartext Transmission', priority: 'P2' },
|
||||
'CWE-330': { category: 'AUTH', name: 'Insufficient Randomness', priority: 'P2' },
|
||||
'CWE-346': { category: 'AUTH', name: 'Origin Validation Error', priority: 'P2' },
|
||||
'CWE-295': { category: 'AUTH', name: 'Improper Certificate Validation', priority: 'P2' },
|
||||
'CWE-347': { category: 'AUTH', name: 'Improper Signature Verification', priority: 'P2' },
|
||||
'CWE-326': { category: 'AUTH', name: 'Inadequate Encryption', priority: 'P3' },
|
||||
'CWE-329': { category: 'AUTH', name: 'Predictable IV', priority: 'P3' },
|
||||
'CWE-323': { category: 'AUTH', name: 'Nonce or Key Pair Reuse', priority: 'P2' },
|
||||
'CWE-327': { category: 'AUTH', name: 'Broken Cryptographic Algorithm', priority: 'P3' },
|
||||
'CWE-328': { category: 'AUTH', name: 'Weak Hash', priority: 'P3' },
|
||||
'CWE-916': { category: 'AUTH', name: 'Weak Password Hash Effort', priority: 'P3' },
|
||||
'CWE-614': { category: 'AUTH', name: 'Cookie without Secure Flag', priority: 'P3' },
|
||||
'CWE-942': { category: 'AUTH', name: 'Permissive Cross-domain Policy', priority: 'P3' },
|
||||
'CWE-1004': { category: 'AUTH', name: 'Cookie without HttpOnly', priority: 'P3' },
|
||||
'CWE-522': { category: 'AUTH', name: 'Insufficiently Protected Credentials', priority: 'P1' },
|
||||
'CWE-306': { category: 'AUTH', name: 'Missing Authentication', priority: 'P1' },
|
||||
'CWE-208': { category: 'AUTH', name: 'Timing Side-Channel', priority: 'P2' },
|
||||
'CWE-338': { category: 'AUTH', name: 'Weak PRNG', priority: 'P2' },
|
||||
|
||||
'CWE-639': { category: 'AUTHZ', name: 'IDOR', priority: 'P1' },
|
||||
'CWE-285': { category: 'AUTHZ', name: 'Broken Authorization', priority: 'P1' },
|
||||
'CWE-269': { category: 'AUTHZ', name: 'Privilege Escalation', priority: 'P1' },
|
||||
'CWE-284': { category: 'AUTHZ', name: 'Improper Access Control', priority: 'P1' },
|
||||
'CWE-653': { category: 'AUTHZ', name: 'Data Isolation Failure', priority: 'P1' },
|
||||
'CWE-732': { category: 'AUTHZ', name: 'Incorrect Permission Assignment', priority: 'P2' },
|
||||
'CWE-862': { category: 'AUTHZ', name: 'Missing Authorization', priority: 'P1' },
|
||||
'CWE-378': { category: 'AUTHZ', name: 'Permission Issue', priority: 'P2' },
|
||||
'CWE-359': { category: 'AUTHZ', name: 'PII Exposure', priority: 'P1' },
|
||||
'CWE-915': { category: 'AUTHZ', name: 'Mass Assignment', priority: 'P1' },
|
||||
|
||||
'CWE-918': { category: 'SSRF', name: 'Server-Side Request Forgery', priority: 'P1' },
|
||||
|
||||
'CWE-601': { category: 'MISC', name: 'Open Redirect', priority: 'P2' },
|
||||
'CWE-693': { category: 'MISC', name: 'Protection Mechanism Failure', priority: 'P3' },
|
||||
'CWE-1021': { category: 'MISC', name: 'Clickjacking', priority: 'P3' },
|
||||
'CWE-1333': { category: 'MISC', name: 'ReDoS', priority: 'P3' },
|
||||
'CWE-489': { category: 'MISC', name: 'Active Debug Code', priority: 'P3' },
|
||||
'CWE-352': { category: 'MISC', name: 'CSRF', priority: 'P1' },
|
||||
'CWE-532': { category: 'MISC', name: 'Sensitive Logging', priority: 'P3' },
|
||||
'CWE-311': { category: 'MISC', name: 'Missing Encryption at Rest', priority: 'P3' },
|
||||
'CWE-922': { category: 'MISC', name: 'Insecure Storage', priority: 'P3' },
|
||||
'CWE-1236': { category: 'MISC', name: 'CSV Formula Injection', priority: 'P2' },
|
||||
});
|
||||
|
||||
// Routing to `miscellaneous` (rather than dropping the finding) is what lets every schema-valid
|
||||
// SARIF result reach exploitation even when its CWE is not one of the ones Shannon names explicitly:
|
||||
// an unrecognized bare CWE still gets its own producer identity and its own exploitation task, just
|
||||
// without a specific category name and at the lowest priority.
|
||||
/** Unknown, but schema-valid, bare CWEs are retained for the generalist class. */
|
||||
export function unmappedMapping(ruleId: string): CWEMapping {
|
||||
return { category: 'MISC', name: ruleId, priority: 'P3' };
|
||||
}
|
||||
|
||||
export function vulnerabilityClassToCategory(vulnerabilityClass: ReconciliationClass): ShannonCategory {
|
||||
const categories: Record<ReconciliationClass, ShannonCategory> = {
|
||||
injection: 'INJECTION',
|
||||
xss: 'XSS',
|
||||
auth: 'AUTH',
|
||||
authz: 'AUTHZ',
|
||||
ssrf: 'SSRF',
|
||||
miscellaneous: 'MISC',
|
||||
};
|
||||
return categories[vulnerabilityClass];
|
||||
}
|
||||
|
||||
export function normalizeConfidence(confidence: string | undefined): Confidence | undefined {
|
||||
if (confidence === undefined) return undefined;
|
||||
const normalized = confidence.toLowerCase();
|
||||
if (normalized === 'med' || normalized === 'medium') return 'medium';
|
||||
if (normalized === 'high' || normalized === 'low') return normalized;
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/** One bounded structured-generation request for one nonempty class batch. */
|
||||
|
||||
import type { ReconciliationClass } from '../../../../types/reconciliation.js';
|
||||
import type {
|
||||
StructuredGenerationPort,
|
||||
StructuredGenerationRequest,
|
||||
StructuredGenerationResult,
|
||||
} from '../../../structured-generation.js';
|
||||
import { SAST_ENRICHMENT_TOOL_DESCRIPTION, sastEnrichmentToolSchema } from './schema.js';
|
||||
import { extractVulnerabilities } from './validate.js';
|
||||
|
||||
export interface SastEnrichmentUsage {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
costUsd: number;
|
||||
}
|
||||
|
||||
export type SastEnrichmentBatchOutcome =
|
||||
| { status: 'ok'; vulnerabilities: unknown[]; usage: SastEnrichmentUsage }
|
||||
| { status: 'aborted'; usage: SastEnrichmentUsage; message: string }
|
||||
| { status: 'failed'; usage: SastEnrichmentUsage; message: string; terminal: boolean };
|
||||
|
||||
export interface SastEnrichmentBatchRequest {
|
||||
vulnerabilityClass: ReconciliationClass;
|
||||
prompt: string;
|
||||
findingsJson: string;
|
||||
maxTokens: number;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
// `terminal` marks a failure the stage should not retry. It is set only when the provider itself
|
||||
// reported a non-retryable failure; an incomplete or empty response defaults to non-terminal so
|
||||
// Temporal drives another attempt.
|
||||
function isTerminalProviderFailure(result: StructuredGenerationResult): boolean {
|
||||
return result.providerFailure?.retryable === false;
|
||||
}
|
||||
|
||||
export async function runSastEnrichmentBatch<TModelContext>(
|
||||
port: StructuredGenerationPort<TModelContext>,
|
||||
modelContext: TModelContext,
|
||||
request: SastEnrichmentBatchRequest,
|
||||
): Promise<SastEnrichmentBatchOutcome> {
|
||||
const generationRequest: StructuredGenerationRequest = {
|
||||
userContent: `${request.prompt}\n${request.findingsJson}`,
|
||||
tool: {
|
||||
name: 'submit_result',
|
||||
description: SAST_ENRICHMENT_TOOL_DESCRIPTION,
|
||||
parametersJsonSchema: sastEnrichmentToolSchema(request.vulnerabilityClass),
|
||||
},
|
||||
maxTokens: request.maxTokens,
|
||||
...(request.signal !== undefined && { signal: request.signal }),
|
||||
};
|
||||
const result = await port.generate(generationRequest, modelContext);
|
||||
const usage = result.usage;
|
||||
|
||||
if (result.stopReason === 'aborted') {
|
||||
if (request.signal?.aborted === true) {
|
||||
return { status: 'aborted', usage, message: 'SAST enrichment was cancelled' };
|
||||
}
|
||||
return {
|
||||
status: 'failed',
|
||||
usage,
|
||||
message: 'SAST enrichment request ended before producing a result',
|
||||
terminal: false,
|
||||
};
|
||||
}
|
||||
if (
|
||||
result.stopReason !== 'toolUse' ||
|
||||
result.toolCalls.length !== 1 ||
|
||||
result.toolCalls[0]?.name !== 'submit_result'
|
||||
) {
|
||||
return {
|
||||
status: 'failed',
|
||||
usage,
|
||||
message: 'SAST enrichment did not return one complete submit_result call',
|
||||
terminal: isTerminalProviderFailure(result),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
return { status: 'ok', vulnerabilities: extractVulnerabilities(result.toolCalls[0].arguments), usage };
|
||||
} catch {
|
||||
return {
|
||||
status: 'failed',
|
||||
usage,
|
||||
message: 'SAST enrichment returned an invalid response envelope',
|
||||
terminal: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/** Code-owned SAST observation shaping and producer-ID minting. */
|
||||
|
||||
import type { ReconciliationClass } from '../../../../types/reconciliation.js';
|
||||
import { ArtifactIntegrityError } from '../../artifact-store.js';
|
||||
import type { ReconciliationObservation, SastSourceLocation } from '../../contracts.js';
|
||||
import { isProducerId, REF_PREFIX } from '../../refs.js';
|
||||
import type { ClassifiedFinding, FindingContext } from '../types.js';
|
||||
|
||||
export function sourceLocationFromContext(context: FindingContext): SastSourceLocation {
|
||||
if ('sinkFile' in context) {
|
||||
return {
|
||||
file: context.sinkFile,
|
||||
line: context.sinkLine,
|
||||
column: context.sinkColumn,
|
||||
rule_id: context.cwe,
|
||||
};
|
||||
}
|
||||
return { file: context.file, line: context.line, column: context.column, rule_id: context.cwe };
|
||||
}
|
||||
|
||||
// SAST producer IDs occupy a namespace (`PREFIX-SAST-NN`) disjoint from vulnerability-analysis
|
||||
// producer IDs (`PREFIX-VULN-NN`) even within the same class, so the two sources can never collide
|
||||
// on identity and `validateProducerIdentity` in publish.ts can tell them apart by construction.
|
||||
export function mintSastProducerId(vulnerabilityClass: ReconciliationClass, sastId: number): string {
|
||||
const producerId = `${REF_PREFIX[vulnerabilityClass]}-SAST-${String(sastId + 1).padStart(2, '0')}`;
|
||||
if (!isProducerId(producerId, vulnerabilityClass, 'SAST')) {
|
||||
throw new ArtifactIntegrityError('Minted SAST producer identifier is outside its class namespace');
|
||||
}
|
||||
return producerId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build one SAST-origin observation, always marked `preferred`.
|
||||
*
|
||||
* This is where the dedupe contract's primacy rule is established for a static-analysis finding: if
|
||||
* reconciliation later groups this observation with a pentest observation for the same underlying
|
||||
* vulnerability, this one becomes the task's primary record, since it carries an exact source
|
||||
* location and rule ID that a pentest finding does not.
|
||||
*/
|
||||
export function buildSastObservation(
|
||||
producerId: string,
|
||||
evidence: Record<string, unknown>,
|
||||
finding: ClassifiedFinding,
|
||||
): ReconciliationObservation {
|
||||
return {
|
||||
...evidence,
|
||||
producer_id: producerId,
|
||||
scan_source: 'sast',
|
||||
primary_preference: 'preferred',
|
||||
priority: finding.mapping.priority,
|
||||
sast_source_location: sourceLocationFromContext(finding.context),
|
||||
} as ReconciliationObservation;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/** Closed per-class submit schema for one SAST enrichment response. */
|
||||
|
||||
import type { ReconciliationClass } from '../../../../types/reconciliation.js';
|
||||
import { QUEUE_ENTRY_FIELD_NAMES } from '../../../queue-schemas.js';
|
||||
|
||||
// `ID` is omitted from the filtered class field list because it is prepended manually below instead
|
||||
// (so it always appears exactly once); `code_locations` is omitted entirely, because that field is a
|
||||
// structured array the model is never asked to reconstruct. Source location is authoritative code-owned
|
||||
// data derived straight from validated SARIF (see sourceLocationFromContext in policy.ts), not
|
||||
// something a free-text model response is trusted to supply.
|
||||
const OMITTED_MODEL_FIELDS = new Set(['ID', 'code_locations']);
|
||||
|
||||
/** Evidence fields the model may return for one class, excluding authoritative source metadata. */
|
||||
export function sastEnrichmentFieldNames(vulnerabilityClass: ReconciliationClass): readonly string[] {
|
||||
return [
|
||||
'ID',
|
||||
...QUEUE_ENTRY_FIELD_NAMES[vulnerabilityClass].filter((field) => !OMITTED_MODEL_FIELDS.has(field)),
|
||||
'_sastId',
|
||||
];
|
||||
}
|
||||
|
||||
function propertySchema(name: string): Record<string, unknown> {
|
||||
if (name === '_sastId') {
|
||||
return { type: 'integer', description: 'Copy the input _sastId exactly.' };
|
||||
}
|
||||
if (name === 'externally_exploitable') return { type: 'boolean' };
|
||||
if (name === 'confidence') return { type: 'string', description: 'high | med | low' };
|
||||
return { type: 'string' };
|
||||
}
|
||||
|
||||
/**
|
||||
* The envelope and each returned object are closed. Required-field and pairing
|
||||
* checks remain application-owned so a malformed sibling can be dropped alone.
|
||||
*/
|
||||
export function sastEnrichmentToolSchema(vulnerabilityClass: ReconciliationClass): Record<string, unknown> {
|
||||
const properties = Object.fromEntries(
|
||||
sastEnrichmentFieldNames(vulnerabilityClass).map((field) => [field, propertySchema(field)]),
|
||||
);
|
||||
return {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
vulnerabilities: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties,
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['vulnerabilities'],
|
||||
};
|
||||
}
|
||||
|
||||
export const SAST_ENRICHMENT_TOOL_DESCRIPTION =
|
||||
'Return the enriched exploitation-queue vulnerabilities. Call exactly once as your final action.';
|
||||
|
||||
export function enrichmentPromptName(vulnerabilityClass: ReconciliationClass): string {
|
||||
return `sast-enrichment-${vulnerabilityClass}`;
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
/** Validate, pair, and positively project one enrichment response. */
|
||||
|
||||
import type { ReconciliationClass } from '../../../../types/reconciliation.js';
|
||||
import { normalizeConfidence } from '../cwe-mapper.js';
|
||||
import type { ClassifiedFinding, Confidence } from '../types.js';
|
||||
import { sastEnrichmentFieldNames } from './schema.js';
|
||||
|
||||
export class EnrichmentAttemptError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'EnrichmentAttemptError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface PairedVulnerability {
|
||||
finding: ClassifiedFinding;
|
||||
sastId: number;
|
||||
evidence: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ValidationCounts {
|
||||
returned: number;
|
||||
malformed: number;
|
||||
orphaned: number;
|
||||
duplicate_sast_id: number;
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
paired: PairedVulnerability[];
|
||||
counts: ValidationCounts;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function normalizeEnrichedConfidence(value: string): Confidence | undefined {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return normalizeConfidence(normalized === 'moderate' ? 'medium' : normalized);
|
||||
}
|
||||
|
||||
/** Require the exact closed `{ vulnerabilities: [...] }` response envelope. */
|
||||
export function extractVulnerabilities(toolArguments: unknown): unknown[] {
|
||||
if (!isRecord(toolArguments) || !Array.isArray(toolArguments.vulnerabilities)) {
|
||||
throw new EnrichmentAttemptError('submit_result did not return a vulnerabilities array');
|
||||
}
|
||||
if (Object.keys(toolArguments).length !== 1 || !Object.hasOwn(toolArguments, 'vulnerabilities')) {
|
||||
throw new EnrichmentAttemptError('submit_result returned unexpected envelope fields');
|
||||
}
|
||||
return toolArguments.vulnerabilities;
|
||||
}
|
||||
|
||||
function toRequiredString(value: unknown): string | undefined {
|
||||
if (typeof value === 'string') return value;
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
|
||||
if (typeof value === 'boolean') return String(value);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function toBoolean(value: unknown): boolean | undefined {
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (typeof value !== 'string') return undefined;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized === 'true') return true;
|
||||
if (normalized === 'false') return false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function toSastId(value: unknown): number | undefined {
|
||||
let parsed: number;
|
||||
if (typeof value === 'number') {
|
||||
parsed = value;
|
||||
} else if (typeof value === 'string' && value.trim().length > 0) {
|
||||
parsed = Number(value.trim());
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : undefined;
|
||||
}
|
||||
|
||||
interface CoercedCommonFields {
|
||||
element: Record<string, unknown>;
|
||||
confidenceRaw: string;
|
||||
sastId: number | undefined;
|
||||
}
|
||||
|
||||
function coerceCommonFields(element: Record<string, unknown>): CoercedCommonFields | undefined {
|
||||
const id = toRequiredString(element.ID);
|
||||
const vulnerabilityType = toRequiredString(element.vulnerability_type);
|
||||
const externallyExploitable = toBoolean(element.externally_exploitable);
|
||||
const notes = toRequiredString(element.notes);
|
||||
const confidencePresent = element.confidence !== undefined && element.confidence !== null;
|
||||
if (
|
||||
id === undefined ||
|
||||
vulnerabilityType === undefined ||
|
||||
externallyExploitable === undefined ||
|
||||
notes === undefined ||
|
||||
!confidencePresent
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Confidence is present but not a scalar (an object or array). Carry a sentinel that no confidence
|
||||
// vocabulary can normalize, so the caller rejects the whole response rather than guessing a value.
|
||||
const scalarConfidence = toRequiredString(element.confidence);
|
||||
const confidenceRaw = scalarConfidence ?? '[non-scalar]';
|
||||
const sastId = toSastId(element._sastId);
|
||||
return {
|
||||
element: {
|
||||
...element,
|
||||
ID: id,
|
||||
vulnerability_type: vulnerabilityType,
|
||||
externally_exploitable: externallyExploitable,
|
||||
confidence: confidenceRaw,
|
||||
notes,
|
||||
...(sastId !== undefined && { _sastId: sastId }),
|
||||
},
|
||||
confidenceRaw,
|
||||
sastId,
|
||||
};
|
||||
}
|
||||
|
||||
function coerceEvidenceValue(key: string, value: unknown): unknown {
|
||||
if (key === 'externally_exploitable') return value;
|
||||
return toRequiredString(value) ?? JSON.stringify(value);
|
||||
}
|
||||
|
||||
function buildEvidence(
|
||||
element: Record<string, unknown>,
|
||||
vulnerabilityClass: ReconciliationClass,
|
||||
confidence: Confidence,
|
||||
): Record<string, unknown> {
|
||||
const evidence: Record<string, unknown> = {};
|
||||
for (const key of sastEnrichmentFieldNames(vulnerabilityClass)) {
|
||||
if (key === 'ID' || key === '_sastId' || key === 'confidence') continue;
|
||||
const value = element[key];
|
||||
if (value !== null && value !== undefined) evidence[key] = coerceEvidenceValue(key, value);
|
||||
}
|
||||
evidence.confidence = confidence;
|
||||
return evidence;
|
||||
}
|
||||
|
||||
// The model's own `ID` field (a free-text string it invents) is carried through as evidence but is
|
||||
// never trusted for identity: only `_sastId`, the small integer the code itself assigned before the
|
||||
// request, is looked up in `findingsById`. A model cannot forge or guess its way into pairing with a
|
||||
// finding it was not actually sent, since `_sastId` values outside the sent batch simply have no entry.
|
||||
/** Pair by code-assigned `_sastId`; never by response order or model-supplied ID. */
|
||||
export function pairEnrichedVulnerabilities(
|
||||
returned: readonly unknown[],
|
||||
findingsById: ReadonlyMap<number, ClassifiedFinding>,
|
||||
vulnerabilityClass: ReconciliationClass,
|
||||
): ValidationResult {
|
||||
const paired: PairedVulnerability[] = [];
|
||||
const consumed = new Set<number>();
|
||||
let malformed = 0;
|
||||
let orphaned = 0;
|
||||
let duplicate = 0;
|
||||
const allowedFields = new Set(sastEnrichmentFieldNames(vulnerabilityClass));
|
||||
|
||||
for (const value of returned) {
|
||||
if (!isRecord(value)) {
|
||||
malformed++;
|
||||
continue;
|
||||
}
|
||||
if (Object.keys(value).some((key) => !allowedFields.has(key))) {
|
||||
malformed++;
|
||||
continue;
|
||||
}
|
||||
const common = coerceCommonFields(value);
|
||||
if (common === undefined) {
|
||||
malformed++;
|
||||
continue;
|
||||
}
|
||||
const confidence = normalizeEnrichedConfidence(common.confidenceRaw);
|
||||
if (confidence === undefined) {
|
||||
throw new EnrichmentAttemptError('Response has an unrecognized confidence value');
|
||||
}
|
||||
const sastId = common.sastId;
|
||||
const finding = sastId === undefined ? undefined : findingsById.get(sastId);
|
||||
if (sastId === undefined || finding === undefined) {
|
||||
orphaned++;
|
||||
continue;
|
||||
}
|
||||
if (consumed.has(sastId)) {
|
||||
duplicate++;
|
||||
continue;
|
||||
}
|
||||
consumed.add(sastId);
|
||||
paired.push({
|
||||
finding,
|
||||
sastId,
|
||||
evidence: buildEvidence(common.element, vulnerabilityClass, confidence),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
paired,
|
||||
counts: {
|
||||
returned: returned.length,
|
||||
malformed,
|
||||
orphaned,
|
||||
duplicate_sast_id: duplicate,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/** Digest-pinned, workspace-contained SARIF byte intake. */
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { Stats } from 'node:fs';
|
||||
import { lstat, readFile, realpath } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { WORKSPACES_DIR } from '../../../paths.js';
|
||||
import type { SarifRef } from '../../sast/types.js';
|
||||
|
||||
const SHA256_PATTERN = /^[a-f0-9]{64}$/;
|
||||
|
||||
export type SarifIntakeFailureKind = 'invalid' | 'io';
|
||||
|
||||
export class SarifIntakeError extends Error {
|
||||
readonly kind: SarifIntakeFailureKind;
|
||||
|
||||
constructor(message: string, kind: SarifIntakeFailureKind = 'invalid') {
|
||||
super(message);
|
||||
this.name = 'SarifIntakeError';
|
||||
this.kind = kind;
|
||||
}
|
||||
}
|
||||
|
||||
function isErrno(error: unknown, ...codes: readonly string[]): boolean {
|
||||
return error instanceof Error && codes.includes((error as NodeJS.ErrnoException).code ?? '');
|
||||
}
|
||||
|
||||
function intakeFileSystemError(error: unknown, invalidMessage: string, ioMessage: string): SarifIntakeError {
|
||||
if (isErrno(error, 'ENOENT', 'ENOTDIR', 'ELOOP')) {
|
||||
return new SarifIntakeError(invalidMessage);
|
||||
}
|
||||
return new SarifIntakeError(ioMessage, 'io');
|
||||
}
|
||||
|
||||
function validSessionId(sessionId: string): boolean {
|
||||
return (
|
||||
sessionId.length > 0 &&
|
||||
sessionId !== '.' &&
|
||||
sessionId !== '..' &&
|
||||
!sessionId.includes('/') &&
|
||||
!sessionId.includes('\\') &&
|
||||
!sessionId.includes('\0')
|
||||
);
|
||||
}
|
||||
|
||||
function contained(root: string, candidate: string): boolean {
|
||||
const relative = path.relative(root, candidate);
|
||||
return relative.length > 0 && !relative.startsWith('..') && !path.isAbsolute(relative);
|
||||
}
|
||||
|
||||
/** Read exact SARIF bytes only after containment and no-symlink checks. */
|
||||
export async function readPinnedSarif(
|
||||
sessionId: string,
|
||||
ref: SarifRef,
|
||||
workspacesDir: string = WORKSPACES_DIR,
|
||||
): Promise<Buffer> {
|
||||
if (!validSessionId(sessionId)) throw new SarifIntakeError('Invalid SARIF session identifier');
|
||||
if (!SHA256_PATTERN.test(ref.sha256)) throw new SarifIntakeError('SARIF reference digest is invalid');
|
||||
|
||||
let realWorkspaces: string;
|
||||
try {
|
||||
realWorkspaces = await realpath(workspacesDir);
|
||||
} catch (error) {
|
||||
throw intakeFileSystemError(
|
||||
error,
|
||||
'SARIF workspace root is not visible',
|
||||
'SARIF workspace root could not be resolved',
|
||||
);
|
||||
}
|
||||
const sessionRoot = path.join(realWorkspaces, sessionId);
|
||||
let sessionStat: Stats;
|
||||
try {
|
||||
sessionStat = await lstat(sessionRoot);
|
||||
} catch (error) {
|
||||
throw intakeFileSystemError(
|
||||
error,
|
||||
'SARIF session workspace is not visible',
|
||||
'SARIF session workspace could not be inspected',
|
||||
);
|
||||
}
|
||||
if (sessionStat.isSymbolicLink() || !sessionStat.isDirectory()) {
|
||||
throw new SarifIntakeError('SARIF session workspace is not a regular directory');
|
||||
}
|
||||
|
||||
if (!path.isAbsolute(ref.path) || path.normalize(ref.path) !== ref.path || !contained(sessionRoot, ref.path)) {
|
||||
throw new SarifIntakeError('SARIF path is outside the current scan workspace');
|
||||
}
|
||||
|
||||
// Walk every path segment under the session root and reject a symlink at any level. Checking only
|
||||
// the final component would let a symlinked parent directory redirect the read outside the
|
||||
// workspace before the byte read and digest check ever run.
|
||||
const relativeSegments = path.relative(sessionRoot, ref.path).split(path.sep);
|
||||
let current = sessionRoot;
|
||||
for (const segment of relativeSegments) {
|
||||
current = path.join(current, segment);
|
||||
let stat: Stats;
|
||||
try {
|
||||
stat = await lstat(current);
|
||||
} catch (error) {
|
||||
throw intakeFileSystemError(error, 'SARIF path is not visible', 'SARIF path could not be inspected');
|
||||
}
|
||||
if (stat.isSymbolicLink()) throw new SarifIntakeError('SARIF path contains a symlink');
|
||||
}
|
||||
|
||||
let resolvedPath: string;
|
||||
try {
|
||||
resolvedPath = await realpath(ref.path);
|
||||
} catch (error) {
|
||||
throw intakeFileSystemError(error, 'SARIF path is not visible', 'SARIF path could not be resolved');
|
||||
}
|
||||
if (resolvedPath !== ref.path || !contained(sessionRoot, resolvedPath)) {
|
||||
throw new SarifIntakeError('SARIF path escapes the current scan workspace');
|
||||
}
|
||||
|
||||
let finalStat: Stats;
|
||||
try {
|
||||
finalStat = await lstat(resolvedPath);
|
||||
} catch (error) {
|
||||
throw intakeFileSystemError(error, 'SARIF path is not visible', 'SARIF path could not be inspected');
|
||||
}
|
||||
if (!finalStat.isFile()) throw new SarifIntakeError('SARIF path is not a regular file');
|
||||
|
||||
let bytes: Buffer;
|
||||
try {
|
||||
bytes = await readFile(resolvedPath);
|
||||
} catch (error) {
|
||||
throw intakeFileSystemError(error, 'SARIF bytes are not readable', 'SARIF bytes could not be read');
|
||||
}
|
||||
const observed = createHash('sha256').update(bytes).digest('hex');
|
||||
if (observed !== ref.sha256) throw new SarifIntakeError('SARIF digest does not match the exact bytes');
|
||||
return bytes;
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
/** Appendix A SARIF validator. Document failures throw; invalid findings are classified and dropped. */
|
||||
|
||||
import path from 'node:path';
|
||||
import type {
|
||||
DroppedSarifFinding,
|
||||
ParsedSarif,
|
||||
ParsedSarifFinding,
|
||||
SarifFindingDropReason,
|
||||
SarifLocation,
|
||||
SarifResult,
|
||||
SastPhase,
|
||||
} from './types.js';
|
||||
|
||||
const CWE_PATTERN = /^CWE-[1-9][0-9]*$/;
|
||||
const SEVERITIES = new Set(['Critical', 'High', 'Medium', 'Low', 'Info']);
|
||||
const LEVELS = new Set(['error', 'warning', 'note']);
|
||||
|
||||
/** A run/document contract failure that invalidates the supplied SARIF reference. */
|
||||
export class SarifDocumentError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'SarifDocumentError';
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function nonemptyString(value: unknown): value is string {
|
||||
return typeof value === 'string' && value.length > 0;
|
||||
}
|
||||
|
||||
// Phase is inferred from the tool name string rather than an explicit field, since the SARIF
|
||||
// contract does not carry a phase number directly. This is a best-effort classification: an
|
||||
// unrecognized name falls through to phase 3, the generic case, rather than failing the finding.
|
||||
function detectPhase(toolName: string): SastPhase {
|
||||
const lower = toolName.toLowerCase();
|
||||
if (lower.includes('check') || lower.includes('phase0') || lower.includes('phase_0')) return 0;
|
||||
if (lower.includes('logic') || lower.includes('business') || lower.includes('phase4') || lower.includes('phase_4')) {
|
||||
return 4;
|
||||
}
|
||||
return 3;
|
||||
}
|
||||
|
||||
function hasTraversal(uri: string): boolean {
|
||||
return uri.split('/').some((segment) => segment === '..' || segment === '.');
|
||||
}
|
||||
|
||||
/** Whether a SARIF location is a normalized repository-relative POSIX path. */
|
||||
export function isRepositoryRelativeSarifUri(uri: string): boolean {
|
||||
if (uri.length === 0 || uri.trim() !== uri || uri.includes('\\') || uri.includes('\0')) return false;
|
||||
if (path.posix.isAbsolute(uri) || /^[A-Za-z][A-Za-z0-9+.-]*:/.test(uri)) return false;
|
||||
if (uri.includes('//') || hasTraversal(uri)) return false;
|
||||
return path.posix.normalize(uri) === uri;
|
||||
}
|
||||
|
||||
function validateRule(value: unknown): string {
|
||||
if (!isRecord(value) || !nonemptyString(value.id) || !CWE_PATTERN.test(value.id)) {
|
||||
throw new SarifDocumentError('SARIF rule metadata has an invalid CWE id');
|
||||
}
|
||||
if (!nonemptyString(value.name) || typeof value.helpUri !== 'string') {
|
||||
throw new SarifDocumentError('SARIF rule metadata is missing required strings');
|
||||
}
|
||||
for (const descriptionKey of ['shortDescription', 'fullDescription']) {
|
||||
const description = value[descriptionKey];
|
||||
if (!isRecord(description) || typeof description.text !== 'string') {
|
||||
throw new SarifDocumentError('SARIF rule metadata has an invalid description');
|
||||
}
|
||||
}
|
||||
if (!isRecord(value.properties) || value.properties.cwe !== value.id || !Array.isArray(value.properties.tags)) {
|
||||
throw new SarifDocumentError('SARIF rule metadata does not match its CWE id');
|
||||
}
|
||||
if (!value.properties.tags.every((tag) => typeof tag === 'string')) {
|
||||
throw new SarifDocumentError('SARIF rule tags are invalid');
|
||||
}
|
||||
return value.id;
|
||||
}
|
||||
|
||||
// `requireSourceRoot` differs between callers: a result's primary location must declare
|
||||
// `uriBaseId: '%SRCROOT%'` explicitly (it is the location a finding is keyed on), while a code-flow
|
||||
// step's location may omit `uriBaseId` entirely and is only rejected if it names something other
|
||||
// than `%SRCROOT%`.
|
||||
function locationParts(
|
||||
value: unknown,
|
||||
requireSourceRoot: boolean,
|
||||
): {
|
||||
location?: SarifLocation;
|
||||
reason?: SarifFindingDropReason;
|
||||
} {
|
||||
if (!isRecord(value) || !isRecord(value.physicalLocation)) return { reason: 'location' };
|
||||
const physical = value.physicalLocation;
|
||||
if (!isRecord(physical.artifactLocation) || !isRecord(physical.region)) return { reason: 'location' };
|
||||
const artifact = physical.artifactLocation;
|
||||
const region = physical.region;
|
||||
if (typeof artifact.uri !== 'string') return { reason: 'location' };
|
||||
if (requireSourceRoot && artifact.uriBaseId !== '%SRCROOT%') return { reason: 'location' };
|
||||
if (!requireSourceRoot && artifact.uriBaseId !== undefined && artifact.uriBaseId !== '%SRCROOT%') {
|
||||
return { reason: 'location' };
|
||||
}
|
||||
if (hasTraversal(artifact.uri)) return { reason: 'traversal' };
|
||||
if (!isRepositoryRelativeSarifUri(artifact.uri)) return { reason: 'location' };
|
||||
if (!Number.isSafeInteger(region.startLine) || (region.startLine as number) <= 0) return { reason: 'line' };
|
||||
if (
|
||||
region.startColumn !== undefined &&
|
||||
(!Number.isSafeInteger(region.startColumn) || (region.startColumn as number) <= 0)
|
||||
) {
|
||||
return { reason: 'location' };
|
||||
}
|
||||
if (region.snippet !== undefined && (!isRecord(region.snippet) || typeof region.snippet.text !== 'string')) {
|
||||
return { reason: 'location' };
|
||||
}
|
||||
if (value.message !== undefined && (!isRecord(value.message) || typeof value.message.text !== 'string')) {
|
||||
return { reason: 'location' };
|
||||
}
|
||||
return { location: value as unknown as SarifLocation };
|
||||
}
|
||||
|
||||
function validateCodeFlows(value: unknown, primary: SarifLocation): SarifFindingDropReason | undefined {
|
||||
if (!Array.isArray(value) || value.length === 0) return 'malformed';
|
||||
let firstFlowLastLocation: SarifLocation | undefined;
|
||||
|
||||
for (let flowIndex = 0; flowIndex < value.length; flowIndex++) {
|
||||
const flow = value[flowIndex];
|
||||
if (!isRecord(flow) || !Array.isArray(flow.threadFlows) || flow.threadFlows.length === 0) return 'malformed';
|
||||
for (let threadIndex = 0; threadIndex < flow.threadFlows.length; threadIndex++) {
|
||||
const thread = flow.threadFlows[threadIndex];
|
||||
if (!isRecord(thread) || !Array.isArray(thread.locations) || thread.locations.length === 0) return 'malformed';
|
||||
for (let locationIndex = 0; locationIndex < thread.locations.length; locationIndex++) {
|
||||
const step = thread.locations[locationIndex];
|
||||
if (!isRecord(step) || !nonemptyString(step.importance)) return 'malformed';
|
||||
const checked = locationParts(step.location, false);
|
||||
if (checked.reason !== undefined) return checked.reason;
|
||||
if (
|
||||
!isRecord(step.location) ||
|
||||
!isRecord(step.location.message) ||
|
||||
typeof step.location.message.text !== 'string'
|
||||
) {
|
||||
return 'malformed';
|
||||
}
|
||||
if (flowIndex === 0 && threadIndex === 0 && locationIndex === thread.locations.length - 1) {
|
||||
firstFlowLastLocation = checked.location;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The last step of the first thread flow is the sink, and it must land on the same file and line
|
||||
// as the result's primary location. A code flow whose sink disagrees with the reported location
|
||||
// describes a different defect than it claims, so the finding is dropped.
|
||||
const primaryPhysical = primary.physicalLocation;
|
||||
const sinkPhysical = firstFlowLastLocation?.physicalLocation;
|
||||
if (
|
||||
sinkPhysical === undefined ||
|
||||
sinkPhysical.artifactLocation.uri !== primaryPhysical.artifactLocation.uri ||
|
||||
sinkPhysical.region.startLine !== primaryPhysical.region.startLine
|
||||
) {
|
||||
return 'location';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resultRuleId(value: unknown): string | undefined {
|
||||
return isRecord(value) && typeof value.ruleId === 'string' ? value.ruleId : undefined;
|
||||
}
|
||||
|
||||
function validateResult(value: unknown, rules: ReadonlySet<string>): SarifFindingDropReason | SarifResult {
|
||||
if (!isRecord(value)) return 'malformed';
|
||||
if (!nonemptyString(value.ruleId) || !CWE_PATTERN.test(value.ruleId)) return 'rule';
|
||||
if (!isRecord(value.properties) || value.properties.cwe !== value.ruleId || !rules.has(value.ruleId)) return 'rule';
|
||||
if (!isRecord(value.message) || typeof value.message.text !== 'string') return 'malformed';
|
||||
if (!LEVELS.has(value.level as string)) return 'severity';
|
||||
if (!Array.isArray(value.locations) || value.locations.length === 0) return 'location';
|
||||
const primary = locationParts(value.locations[0], true);
|
||||
if (primary.reason !== undefined) return primary.reason;
|
||||
if (primary.location === undefined) return 'location';
|
||||
|
||||
const codeFlowReason = validateCodeFlows(value.codeFlows, primary.location);
|
||||
if (codeFlowReason !== undefined) return codeFlowReason;
|
||||
|
||||
if (!SEVERITIES.has(value.properties.severity as string)) return 'severity';
|
||||
if (value.properties.status !== 'verified') return 'status';
|
||||
if (value.properties.findingSubType !== 'AGENT_SAST') return 'subtype';
|
||||
if (typeof value.properties.description !== 'string') return 'malformed';
|
||||
// These property names belong to a richer internal finding shape than the one this pipeline
|
||||
// accepts; a result carrying any of them was not produced against this exact SARIF contract (or
|
||||
// carries content, such as a proof-of-concept, this pipeline never wants to ingest), so it is
|
||||
// dropped rather than accepted with those fields silently ignored.
|
||||
for (const forbidden of ['invariantDescription', 'owasp_category', 'proofOfConcept']) {
|
||||
if (Object.hasOwn(value.properties, forbidden)) return 'malformed';
|
||||
}
|
||||
return value as unknown as SarifResult;
|
||||
}
|
||||
|
||||
function zeroDropReasons(): Record<SarifFindingDropReason, number> {
|
||||
return {
|
||||
malformed: 0,
|
||||
rule: 0,
|
||||
location: 0,
|
||||
traversal: 0,
|
||||
line: 0,
|
||||
severity: 0,
|
||||
status: 0,
|
||||
subtype: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Parse and strictly validate the Capella SARIF byte contract. */
|
||||
export function parseSarifContent(content: string): ParsedSarif {
|
||||
let document: unknown;
|
||||
try {
|
||||
document = JSON.parse(content);
|
||||
} catch {
|
||||
throw new SarifDocumentError('SARIF document is not valid JSON');
|
||||
}
|
||||
if (!isRecord(document)) throw new SarifDocumentError('SARIF document is not an object');
|
||||
if (!nonemptyString(document.$schema) || document.version !== '2.1.0') {
|
||||
throw new SarifDocumentError('SARIF document metadata is invalid');
|
||||
}
|
||||
if (!Array.isArray(document.runs) || document.runs.length === 0) {
|
||||
throw new SarifDocumentError('SARIF document has no runs');
|
||||
}
|
||||
|
||||
const findings: ParsedSarifFinding[] = [];
|
||||
const droppedFindings: DroppedSarifFinding[] = [];
|
||||
const droppedByReason = zeroDropReasons();
|
||||
let declaredFindings = 0;
|
||||
|
||||
for (const runValue of document.runs) {
|
||||
if (!isRecord(runValue) || !isRecord(runValue.tool) || !isRecord(runValue.tool.driver)) {
|
||||
throw new SarifDocumentError('SARIF run tool metadata is invalid');
|
||||
}
|
||||
const driver = runValue.tool.driver;
|
||||
if (!nonemptyString(driver.name) || !nonemptyString(driver.version) || !nonemptyString(driver.informationUri)) {
|
||||
throw new SarifDocumentError('SARIF run driver metadata is incomplete');
|
||||
}
|
||||
if (!Array.isArray(driver.rules) || !Array.isArray(runValue.results) || !isRecord(runValue.properties)) {
|
||||
throw new SarifDocumentError('SARIF run arrays or properties are invalid');
|
||||
}
|
||||
if (
|
||||
typeof runValue.properties.repository !== 'string' ||
|
||||
!Number.isSafeInteger(runValue.properties.totalFindings) ||
|
||||
runValue.properties.totalFindings !== runValue.results.length
|
||||
) {
|
||||
throw new SarifDocumentError('SARIF run finding count or repository metadata is invalid');
|
||||
}
|
||||
|
||||
const rules = new Set<string>();
|
||||
for (const rule of driver.rules) {
|
||||
const id = validateRule(rule);
|
||||
if (rules.has(id)) throw new SarifDocumentError('SARIF run contains duplicate rule metadata');
|
||||
rules.add(id);
|
||||
}
|
||||
|
||||
const phase = detectPhase(driver.name);
|
||||
declaredFindings += runValue.results.length;
|
||||
for (const resultValue of runValue.results) {
|
||||
const validation = validateResult(resultValue, rules);
|
||||
if (typeof validation === 'string') {
|
||||
droppedByReason[validation]++;
|
||||
const ruleId = resultRuleId(resultValue);
|
||||
droppedFindings.push({ ...(ruleId !== undefined && { ruleId }), reason: validation });
|
||||
continue;
|
||||
}
|
||||
findings.push({ result: validation, phase, toolName: driver.name });
|
||||
}
|
||||
}
|
||||
|
||||
return { findings, droppedFindings, droppedByReason, declaredFindings };
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/** Strict SARIF and SAST-enrichment contracts. */
|
||||
|
||||
export type ShannonCategory = 'INJECTION' | 'XSS' | 'AUTH' | 'AUTHZ' | 'SSRF' | 'MISC';
|
||||
export type Priority = 'P1' | 'P2' | 'P3';
|
||||
export type Confidence = 'high' | 'medium' | 'low';
|
||||
// Which analysis phase produced a finding, detected from the SARIF driver's tool name (see
|
||||
// `detectPhase` in sarif-parser.ts): 0 is an early check-style phase, 4 is business-logic analysis,
|
||||
// and 3 is every other phase. Kept as a small closed set of numbers rather than named phase strings
|
||||
// because it only needs to round-trip through enrichment, not describe the phase to a person.
|
||||
export type SastPhase = 0 | 3 | 4;
|
||||
|
||||
export interface CWEMapping {
|
||||
category: ShannonCategory;
|
||||
name: string;
|
||||
priority: Priority;
|
||||
}
|
||||
|
||||
export interface SarifRegion {
|
||||
startLine: number;
|
||||
startColumn?: number;
|
||||
snippet?: { text: string };
|
||||
}
|
||||
|
||||
export interface SarifArtifactLocation {
|
||||
uri: string;
|
||||
uriBaseId?: '%SRCROOT%';
|
||||
}
|
||||
|
||||
export interface SarifLocation {
|
||||
physicalLocation: {
|
||||
artifactLocation: SarifArtifactLocation;
|
||||
region: SarifRegion;
|
||||
};
|
||||
message?: { text: string };
|
||||
}
|
||||
|
||||
export interface SarifThreadFlowLocation {
|
||||
location: SarifLocation;
|
||||
importance: string;
|
||||
}
|
||||
|
||||
export interface SarifResult {
|
||||
ruleId: string;
|
||||
level: 'error' | 'warning' | 'note';
|
||||
message: { text: string };
|
||||
locations: [SarifLocation, ...unknown[]];
|
||||
codeFlows: Array<{
|
||||
threadFlows: Array<{
|
||||
locations: SarifThreadFlowLocation[];
|
||||
}>;
|
||||
}>;
|
||||
properties: {
|
||||
severity: 'Critical' | 'High' | 'Medium' | 'Low' | 'Info';
|
||||
cwe: string;
|
||||
status: 'verified';
|
||||
description: string;
|
||||
findingSubType: 'AGENT_SAST';
|
||||
};
|
||||
}
|
||||
|
||||
export type SarifFindingDropReason =
|
||||
| 'malformed'
|
||||
| 'rule'
|
||||
| 'location'
|
||||
| 'traversal'
|
||||
| 'line'
|
||||
| 'severity'
|
||||
| 'status'
|
||||
| 'subtype';
|
||||
|
||||
export interface ParsedSarifFinding {
|
||||
result: SarifResult;
|
||||
phase: SastPhase;
|
||||
toolName: string;
|
||||
}
|
||||
|
||||
export interface DroppedSarifFinding {
|
||||
ruleId?: string;
|
||||
reason: SarifFindingDropReason;
|
||||
}
|
||||
|
||||
export interface ParsedSarif {
|
||||
findings: ParsedSarifFinding[];
|
||||
droppedFindings: DroppedSarifFinding[];
|
||||
droppedByReason: Record<SarifFindingDropReason, number>;
|
||||
declaredFindings: number;
|
||||
}
|
||||
|
||||
export interface DataflowFindingContext {
|
||||
cwe: string;
|
||||
message: string;
|
||||
severity: string;
|
||||
confidence: number;
|
||||
sinkFile: string;
|
||||
sinkLine: number;
|
||||
sinkColumn: number;
|
||||
sinkSnippet: string;
|
||||
sourceFile: string;
|
||||
sourceLine: number;
|
||||
sourceColumn: number;
|
||||
sourceSnippet: string;
|
||||
dataflowPath: Array<{
|
||||
file: string;
|
||||
line: number;
|
||||
column: number;
|
||||
snippet: string;
|
||||
role: string;
|
||||
}>;
|
||||
validationReason: string;
|
||||
sanitizationStatus: string;
|
||||
}
|
||||
|
||||
export interface LocalizedFindingContext {
|
||||
cwe: string;
|
||||
message: string;
|
||||
severity: string;
|
||||
confidence: number;
|
||||
file: string;
|
||||
line: number;
|
||||
column: number;
|
||||
snippet: string;
|
||||
}
|
||||
|
||||
// A dataflow context is built when a finding's code flow names a distinct source and sink;
|
||||
// otherwise the finding gets a localized context describing only its single reported location.
|
||||
export type FindingContext = DataflowFindingContext | LocalizedFindingContext;
|
||||
|
||||
export interface ClassifiedFinding {
|
||||
context: FindingContext;
|
||||
mapping: CWEMapping;
|
||||
phase: SastPhase;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Schema version shared by every reconciliation artifact and publication.
|
||||
*
|
||||
* Keep this leaf module free of runtime imports so workflow-safe type modules can
|
||||
* refer to the version without pulling filesystem code into a workflow bundle.
|
||||
*/
|
||||
export const RECONCILIATION_SCHEMA_VERSION = 1 as const;
|
||||
@@ -0,0 +1,205 @@
|
||||
// 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.
|
||||
|
||||
/** Durable, queue-only producer seeding for the analysis-less `miscellaneous` class. */
|
||||
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { lstat, rename, unlink, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
commitExactPaths,
|
||||
ExactPathCommitMismatchError,
|
||||
gitBlobShaForContents,
|
||||
lastCommitForPathAtHead,
|
||||
readCommittedFile,
|
||||
restorePathsFromHead,
|
||||
withGitRepoLock,
|
||||
} from '../../services/git-manager.js';
|
||||
import type { ActivityLogger } from '../../types/activity-logger.js';
|
||||
import { PublicationConflictError, ReconciliationError, ReconciliationIoError } from './artifact-store.js';
|
||||
import { isManifestCoherent, readPublishedManifest } from './manifest.js';
|
||||
import { exploitationQueuePath, reconciliationManifestPath, sastProvenancePath } from './prepare.js';
|
||||
import { publicationContractForClass } from './publish.js';
|
||||
|
||||
/**
|
||||
* Canonical committed producer bytes for the analysis-less class.
|
||||
*
|
||||
* Every seed writes these exact bytes, so a re-run after a lost acknowledgement produces an
|
||||
* identical blob and the seed is idempotent. Any other committed content for the `miscellaneous` queue is
|
||||
* treated as a conflict, never overwritten.
|
||||
*/
|
||||
export const CANONICAL_EMPTY_MISCELLANEOUS_QUEUE = `${JSON.stringify({ vulnerabilities: [] }, null, 2)}\n`;
|
||||
|
||||
export interface SeedEmptyProducerQueueArgs {
|
||||
deliverablesDir: string;
|
||||
sessionId: string;
|
||||
logger: ActivityLogger;
|
||||
}
|
||||
|
||||
export interface SeedEmptyProducerQueueResult {
|
||||
alreadySeeded: boolean;
|
||||
alreadyPublished: boolean;
|
||||
commitHash: string;
|
||||
}
|
||||
|
||||
function sha256Text(contents: string): string {
|
||||
return createHash('sha256').update(contents, 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
function isErrno(error: unknown, code: string): boolean {
|
||||
return error instanceof Error && (error as NodeJS.ErrnoException).code === code;
|
||||
}
|
||||
|
||||
async function rejectQueueSymlink(deliverablesDir: string, queuePath: string): Promise<void> {
|
||||
try {
|
||||
const stat = await lstat(path.join(deliverablesDir, queuePath));
|
||||
if (stat.isSymbolicLink()) throw new PublicationConflictError('Refusing to seed through a symlink');
|
||||
} catch (error) {
|
||||
if (isErrno(error, 'ENOENT')) return;
|
||||
if (error instanceof ReconciliationError) throw error;
|
||||
throw new ReconciliationIoError('Unable to inspect the miscellaneous producer-queue destination');
|
||||
}
|
||||
}
|
||||
|
||||
async function writeQueueReplacingEntry(absolutePath: string): Promise<void> {
|
||||
const temporaryPath = `${absolutePath}.tmp-${randomUUID()}`;
|
||||
try {
|
||||
await writeFile(temporaryPath, CANONICAL_EMPTY_MISCELLANEOUS_QUEUE, { flag: 'wx' });
|
||||
await rename(temporaryPath, absolutePath);
|
||||
} catch (error) {
|
||||
await unlink(temporaryPath).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function publicationCommit(deliverablesDir: string, relativePath: string): Promise<string> {
|
||||
const commitHash = await lastCommitForPathAtHead(deliverablesDir, relativePath);
|
||||
if (commitHash === null) throw new ReconciliationIoError('Unable to read the exact-path publication commit');
|
||||
return commitHash;
|
||||
}
|
||||
|
||||
async function verifyManifestConsumers(
|
||||
deliverablesDir: string,
|
||||
consumers: ReadonlyArray<{ path: string; sha256: string }>,
|
||||
): Promise<void> {
|
||||
for (const consumer of consumers) {
|
||||
const committed = await readCommittedFile(deliverablesDir, consumer.path);
|
||||
if (committed.state !== 'present' || sha256Text(committed.contents) !== consumer.sha256) {
|
||||
throw new PublicationConflictError('Existing miscellaneous publication is missing a coherent committed consumer');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed `miscellaneous_exploitation_queue.json` once, or return an existing seed/final publication.
|
||||
*
|
||||
* Resolves to one of three states under the Git lock: a coherent final publication already exists
|
||||
* and is adopted (`alreadyPublished`); the canonical empty queue is already committed and adopted
|
||||
* (`alreadySeeded`); or nothing is committed yet and the canonical queue is written and committed
|
||||
* fresh. A committed queue with non-canonical bytes, or standalone provenance with no manifest,
|
||||
* is a conflict rather than a state to reprocess.
|
||||
*/
|
||||
// The `miscellaneous` class has no analysis agent of its own: nothing runs vulnerability analysis
|
||||
// against it directly, so unlike the five core classes it never gets a producer queue from an
|
||||
// upstream agent. Seeding the canonical empty queue here, then running it through the same
|
||||
// publish/manifest machinery as every other class, means downstream consumers (materialization,
|
||||
// the report, the exploitation phase) never need a miscellaneous-specific code path for "this class
|
||||
// might not have a queue file at all."
|
||||
export async function seedEmptyProducerQueue(args: SeedEmptyProducerQueueArgs): Promise<SeedEmptyProducerQueueResult> {
|
||||
const queuePath = exploitationQueuePath('miscellaneous');
|
||||
const manifestPath = reconciliationManifestPath('miscellaneous');
|
||||
const provenancePath = sastProvenancePath('miscellaneous');
|
||||
const finalContracts = [
|
||||
publicationContractForClass('miscellaneous', false),
|
||||
publicationContractForClass('miscellaneous', true),
|
||||
];
|
||||
|
||||
return withGitRepoLock(async (): Promise<SeedEmptyProducerQueueResult> => {
|
||||
const manifestRead = await readPublishedManifest(args.deliverablesDir, manifestPath);
|
||||
if (manifestRead.state === 'invalid') {
|
||||
throw new PublicationConflictError(`Corrupt miscellaneous manifest in HEAD: ${manifestRead.reason}`);
|
||||
}
|
||||
if (manifestRead.state === 'present') {
|
||||
const producerBlobSha = await gitBlobShaForContents(args.deliverablesDir, CANONICAL_EMPTY_MISCELLANEOUS_QUEUE);
|
||||
const coherentFinalContract = finalContracts.some((contract) =>
|
||||
isManifestCoherent({
|
||||
manifest: manifestRead.manifest,
|
||||
sessionId: args.sessionId,
|
||||
vulnerabilityClass: 'miscellaneous',
|
||||
contract,
|
||||
producerQueuePath: queuePath,
|
||||
producerBlobSha,
|
||||
}),
|
||||
);
|
||||
if (!coherentFinalContract) {
|
||||
throw new PublicationConflictError(
|
||||
'Existing miscellaneous manifest does not cohere with the final publication',
|
||||
);
|
||||
}
|
||||
await verifyManifestConsumers(args.deliverablesDir, manifestRead.manifest.consumer_files);
|
||||
await rejectQueueSymlink(args.deliverablesDir, queuePath);
|
||||
await restorePathsFromHead(args.deliverablesDir, [queuePath]);
|
||||
return {
|
||||
alreadySeeded: true,
|
||||
alreadyPublished: true,
|
||||
commitHash: await publicationCommit(args.deliverablesDir, manifestPath),
|
||||
};
|
||||
}
|
||||
|
||||
const provenanceRead = await readCommittedFile(args.deliverablesDir, provenancePath);
|
||||
if (provenanceRead.state !== 'absent') {
|
||||
throw new PublicationConflictError('Pre-manifest miscellaneous state contains standalone provenance');
|
||||
}
|
||||
|
||||
const queueRead = await readCommittedFile(args.deliverablesDir, queuePath);
|
||||
if (queueRead.state === 'corrupt') {
|
||||
throw new PublicationConflictError('Committed miscellaneous producer queue is unreadable');
|
||||
}
|
||||
if (queueRead.state === 'present') {
|
||||
if (queueRead.contents !== CANONICAL_EMPTY_MISCELLANEOUS_QUEUE) {
|
||||
throw new PublicationConflictError('Pre-manifest miscellaneous producer queue is not canonical empty state');
|
||||
}
|
||||
await rejectQueueSymlink(args.deliverablesDir, queuePath);
|
||||
await restorePathsFromHead(args.deliverablesDir, [queuePath]);
|
||||
return {
|
||||
alreadySeeded: true,
|
||||
alreadyPublished: false,
|
||||
commitHash: await publicationCommit(args.deliverablesDir, queuePath),
|
||||
};
|
||||
}
|
||||
|
||||
await rejectQueueSymlink(args.deliverablesDir, queuePath);
|
||||
try {
|
||||
await writeQueueReplacingEntry(path.join(args.deliverablesDir, queuePath));
|
||||
} catch {
|
||||
await restorePathsFromHead(args.deliverablesDir, [queuePath]);
|
||||
throw new ReconciliationIoError('Unable to write the canonical miscellaneous producer queue');
|
||||
}
|
||||
|
||||
let commitHash: string;
|
||||
try {
|
||||
const committed = await commitExactPaths(
|
||||
args.deliverablesDir,
|
||||
[queuePath],
|
||||
'Seed miscellaneous producer queue',
|
||||
args.logger,
|
||||
[queuePath],
|
||||
);
|
||||
commitHash = committed.commitHash;
|
||||
} catch (error) {
|
||||
await restorePathsFromHead(args.deliverablesDir, [queuePath]);
|
||||
if (error instanceof ExactPathCommitMismatchError) {
|
||||
throw new PublicationConflictError('Miscellaneous seed staged path set differs from its queue-only contract');
|
||||
}
|
||||
throw new ReconciliationIoError('Unable to commit the canonical miscellaneous producer queue');
|
||||
}
|
||||
const committedQueue = await readCommittedFile(args.deliverablesDir, queuePath);
|
||||
if (committedQueue.state !== 'present' || committedQueue.contents !== CANONICAL_EMPTY_MISCELLANEOUS_QUEUE) {
|
||||
throw new PublicationConflictError('Committed miscellaneous seed bytes do not match canonical empty state');
|
||||
}
|
||||
return { alreadySeeded: false, alreadyPublished: false, commitHash };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/** Type-only wire and artifact-body contracts for reconciliation stages. */
|
||||
|
||||
import type { SarifRef } from '../sast/types.js';
|
||||
import type {
|
||||
ArtifactRef,
|
||||
ClassEvidence,
|
||||
Priority,
|
||||
ReconciliationObservation,
|
||||
ReconciliationTask,
|
||||
SastSourceLocation,
|
||||
ScanSource,
|
||||
} from './contracts.js';
|
||||
|
||||
// "Positive" projection means this type is built by copying named fields in, never by taking the
|
||||
// full observation and deleting fields out. A field that is not explicitly listed here (including
|
||||
// `producer_id` and every other internal key) cannot appear on this type at all, so a future field
|
||||
// added to `ReconciliationObservation` is model-invisible by default instead of leaking by default.
|
||||
/** Positive model projection of one observation. */
|
||||
export type ObservationView<E extends ClassEvidence = ClassEvidence> = E & {
|
||||
scan_source: ScanSource;
|
||||
priority?: Priority;
|
||||
sast_source_location?: SastSourceLocation;
|
||||
};
|
||||
|
||||
export interface TaskFormationInput {
|
||||
queued_findings: Array<{ label: string; entry: ObservationView }>;
|
||||
}
|
||||
|
||||
export interface TaskFormationOutput {
|
||||
groups: Array<{ queue_labels: string[]; reasoning: string }>;
|
||||
}
|
||||
|
||||
// Identifies the exact committed producer queue a reconciliation run was prepared against. Publish
|
||||
// re-reads this queue at commit time and compares both the Git blob SHA and the content digest, so
|
||||
// a queue that changed in HEAD between preparation and commit is caught rather than silently
|
||||
// published against stale tasks.
|
||||
export interface ProducerQueueIdentity {
|
||||
path: string;
|
||||
blob_sha: string;
|
||||
digest: string;
|
||||
}
|
||||
|
||||
export interface ProducerObservationsBody {
|
||||
observations: ReconciliationObservation[];
|
||||
producer_queue: ProducerQueueIdentity;
|
||||
}
|
||||
|
||||
/** Optional adapter-facing provenance row. Standalone enrichment emits no rows. */
|
||||
export interface SupplementalProvenanceRecord {
|
||||
producer_id: string;
|
||||
repository_id?: string;
|
||||
scan_run_id?: string;
|
||||
rule_id: string;
|
||||
file: string;
|
||||
line: number;
|
||||
column: number;
|
||||
}
|
||||
|
||||
// Counts only, never identities: this is telemetry surfaced to the scan log, not a channel that
|
||||
// carries any producer ID or SARIF content forward. See `dropped_findings` on the body below for
|
||||
// the one place a dropped finding's identity is actually retained.
|
||||
export interface SupplementalDropCounts {
|
||||
unknown_cwe: number;
|
||||
other_category: number;
|
||||
malformed: number;
|
||||
orphaned: number;
|
||||
duplicate_sast_id: number;
|
||||
enrichment_dropped: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Identity of one finding that was sent for enrichment but never paired back.
|
||||
*
|
||||
* Recovered from the sent side, so it is always complete: a malformed response may
|
||||
* carry no usable `sastId`, which is precisely what makes it malformed.
|
||||
*/
|
||||
export interface SupplementalDroppedFinding {
|
||||
producer_id: string;
|
||||
sast_id: number;
|
||||
sast_source_location: SastSourceLocation;
|
||||
}
|
||||
|
||||
export interface SupplementalObservationsBody {
|
||||
observations: ReconciliationObservation[];
|
||||
provenance: SupplementalProvenanceRecord[];
|
||||
sarif?: SarifRef;
|
||||
drops: SupplementalDropCounts;
|
||||
// Sibling of `drops`, never a member of it: `drops` is spread into the artifact envelope's
|
||||
// numeric `counts` map, which rejects any non-integer value.
|
||||
dropped_findings: SupplementalDroppedFinding[];
|
||||
}
|
||||
|
||||
export interface AcceptedTaskGroup {
|
||||
producer_ids: string[];
|
||||
reasoning: string;
|
||||
}
|
||||
|
||||
// `model_ran` is false for both the "fewer than two observations" skip and any future zero-request
|
||||
// path; it lets a reader of this artifact tell a genuine empty result apart from a model call that
|
||||
// simply produced no groups.
|
||||
export interface TaskFormationBody {
|
||||
model_ran: boolean;
|
||||
groups: AcceptedTaskGroup[];
|
||||
rejected_group_count: number;
|
||||
dropped_unknown_label_count: number;
|
||||
}
|
||||
|
||||
// `observation_to_task` is the complete forward index from every observation's producer ID to the
|
||||
// task it was materialized into (whether as primary or as a merged member). Publication uses it to
|
||||
// prove every observation was placed exactly once before anything is written.
|
||||
export interface FixedTasksBody {
|
||||
tasks: ReconciliationTask[];
|
||||
observation_to_task: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ArtifactBodyMap {
|
||||
'producer-observations': ProducerObservationsBody;
|
||||
'supplemental-observations': SupplementalObservationsBody;
|
||||
'task-formation': TaskFormationBody;
|
||||
'fixed-tasks': FixedTasksBody;
|
||||
}
|
||||
|
||||
export interface PrepareAlreadyPublished {
|
||||
outcome: 'already_published';
|
||||
manifestSha256: string;
|
||||
}
|
||||
|
||||
export interface PreparePending {
|
||||
outcome: 'pending';
|
||||
ref: ArtifactRef<'producer-observations'>;
|
||||
}
|
||||
|
||||
export type PrepareResult = PrepareAlreadyPublished | PreparePending;
|
||||
|
||||
export interface StageMetrics {
|
||||
costUsd: number;
|
||||
modelCalls: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
}
|
||||
|
||||
export interface EnrichSuccess {
|
||||
ref: ArtifactRef<'supplemental-observations'>;
|
||||
metrics: StageMetrics;
|
||||
}
|
||||
|
||||
export interface FormSuccess {
|
||||
ref: ArtifactRef<'task-formation'>;
|
||||
metrics: StageMetrics;
|
||||
}
|
||||
|
||||
// Sentinel result of task formation when the model stage exhausted its retries on an eligible
|
||||
// failure. Materialization treats it as an instruction to skip grouping and give every observation
|
||||
// its own task, so a reconciliation still completes without any formation artifact.
|
||||
export const SINGLETON_FALLBACK = 'singleton_fallback' as const;
|
||||
export type FormResult = FormSuccess | typeof SINGLETON_FALLBACK;
|
||||
|
||||
export interface MaterializeResult {
|
||||
ref: ArtifactRef<'fixed-tasks'>;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// 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.
|
||||
|
||||
/** Captured submit tool with closed-schema and cross-group validation before capture. */
|
||||
|
||||
import { defineTool } from '@earendil-works/pi-coding-agent';
|
||||
import { Type } from 'typebox';
|
||||
import { Value } from 'typebox/value';
|
||||
import type { CapturedSubmitTool } from '../submit-tool.js';
|
||||
|
||||
export type SubmitValidator = (parameters: unknown) => readonly string[];
|
||||
|
||||
export interface ValidatingSubmitTool extends CapturedSubmitTool {
|
||||
readonly getAcceptedCount: () => number;
|
||||
readonly sawRejectedSubmission: () => boolean;
|
||||
}
|
||||
|
||||
function rejection(problems: readonly string[]): {
|
||||
content: Array<{ type: 'text'; text: string }>;
|
||||
details: undefined;
|
||||
} {
|
||||
const detail = problems.map((problem) => `- ${problem}`).join('\n');
|
||||
const message = `Your answer was not accepted. Fix the following and call submit_result again:\n${detail}`;
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({ status: 'error', errorType: 'ValidationError', retryable: true, message }),
|
||||
},
|
||||
],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const VALIDATING_DIRECTIVE =
|
||||
'\n\nDeliver your structured answer by calling submit_result. If it reports problems, correct them and call it ' +
|
||||
'again. Once it accepts your answer, stop. Do not output JSON as text.';
|
||||
|
||||
/** Build one executor-owned submit tool that captures only an accepted, schema-closed payload. */
|
||||
export function createValidatingSubmitTool(
|
||||
schema: Record<string, unknown>,
|
||||
validate: SubmitValidator,
|
||||
): ValidatingSubmitTool {
|
||||
const parametersSchema = Type.Unsafe(schema);
|
||||
let captured: unknown;
|
||||
let acceptedCount = 0;
|
||||
let rejected = false;
|
||||
|
||||
return {
|
||||
tool: defineTool({
|
||||
name: 'submit_result',
|
||||
label: 'Submit task groups',
|
||||
description: 'Submit task groups. Correct a rejected submission, then stop after the first accepted call.',
|
||||
promptSnippet: 'submit_result: submit task groups; correct and resubmit only when rejected',
|
||||
promptGuidelines: [
|
||||
'Call submit_result to deliver task groups.',
|
||||
'If it reports validation problems, correct them and call it again.',
|
||||
'Stop after the first accepted submission. Do not output JSON as text.',
|
||||
],
|
||||
parameters: parametersSchema,
|
||||
async execute(_toolCallId, parameters) {
|
||||
if (!Value.Check(parametersSchema, parameters)) {
|
||||
rejected = true;
|
||||
return rejection(['The submission does not match the closed task-formation schema.']);
|
||||
}
|
||||
|
||||
const problems = validate(parameters);
|
||||
if (problems.length > 0) {
|
||||
rejected = true;
|
||||
return rejection(problems);
|
||||
}
|
||||
|
||||
// Only the first accepted submission is captured. A second accepted call is refused and
|
||||
// terminates the session, so the stage always materializes from a single settled answer
|
||||
// rather than silently taking the last of several.
|
||||
acceptedCount += 1;
|
||||
if (acceptedCount > 1) {
|
||||
rejected = true;
|
||||
return {
|
||||
...rejection(['Only one accepted submission is allowed.']),
|
||||
terminate: true,
|
||||
};
|
||||
}
|
||||
captured = parameters;
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: 'Task groups accepted.' }],
|
||||
details: undefined,
|
||||
terminate: true,
|
||||
};
|
||||
},
|
||||
}),
|
||||
getCaptured: () => captured,
|
||||
getAcceptedCount: () => acceptedCount,
|
||||
sawRejectedSubmission: () => rejected,
|
||||
directive: VALIDATING_DIRECTIVE,
|
||||
};
|
||||
}
|
||||
|
||||
export const PROBLEM_LABEL_PREVIEW = 6;
|
||||
|
||||
export function previewLabels(labels: readonly string[], maximum: number = PROBLEM_LABEL_PREVIEW): string {
|
||||
const shown = labels.slice(0, maximum).join(', ');
|
||||
const remaining = labels.length - maximum;
|
||||
return remaining > 0 ? `${shown} (+${remaining} more)` : shown;
|
||||
}
|
||||
|
||||
export function describeUnknownLabels(unknown: readonly string[], kind: string): string {
|
||||
if (unknown.length === 0) return '';
|
||||
return `These are not labels of any ${kind} in this task: ${previewLabels(unknown)}. Use only supplied labels.`;
|
||||
}
|
||||
|
||||
export function describeReusedLabels(reused: readonly string[], container = 'submission'): string {
|
||||
if (reused.length === 0) return '';
|
||||
return `Each label belongs to at most one ${container}; these are reused: ${previewLabels(reused)}. Remove every duplicate claim.`;
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
// 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.
|
||||
|
||||
/** Closed Pass 1 submission schema and defensive group acceptance. */
|
||||
|
||||
import { describeReusedLabels, describeUnknownLabels } from './submit-validation.js';
|
||||
|
||||
const TOP_LEVEL_KEYS = Object.freeze(['groups'] as const);
|
||||
const GROUP_KEYS = Object.freeze(['queue_labels', 'reasoning'] as const);
|
||||
|
||||
export interface TaskFormationGroup {
|
||||
readonly queue_labels: readonly string[];
|
||||
readonly reasoning: string;
|
||||
}
|
||||
|
||||
export interface AcceptedTaskGroups {
|
||||
readonly groups: readonly TaskFormationGroup[];
|
||||
readonly rejectedGroupCount: number;
|
||||
readonly droppedUnknownLabelCount: number;
|
||||
}
|
||||
|
||||
interface ParsedGroup {
|
||||
readonly group?: TaskFormationGroup;
|
||||
readonly labels: readonly string[];
|
||||
readonly duplicateLabels: readonly string[];
|
||||
readonly closed: boolean;
|
||||
readonly structurallyValid: boolean;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function hasExactKeys(value: Record<string, unknown>, expected: readonly string[]): boolean {
|
||||
const actual = Object.keys(value).sort();
|
||||
const sortedExpected = [...expected].sort();
|
||||
return actual.length === sortedExpected.length && actual.every((key, index) => key === sortedExpected[index]);
|
||||
}
|
||||
|
||||
function duplicateValues(values: readonly string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const duplicates = new Set<string>();
|
||||
for (const value of values) {
|
||||
if (seen.has(value)) duplicates.add(value);
|
||||
seen.add(value);
|
||||
}
|
||||
return [...duplicates];
|
||||
}
|
||||
|
||||
function parseGroup(value: unknown): ParsedGroup {
|
||||
if (!isRecord(value)) {
|
||||
return { labels: [], duplicateLabels: [], closed: false, structurallyValid: false };
|
||||
}
|
||||
|
||||
const closed = hasExactKeys(value, GROUP_KEYS);
|
||||
const rawLabels = value.queue_labels;
|
||||
const labels = Array.isArray(rawLabels)
|
||||
? rawLabels.filter((label): label is string => typeof label === 'string')
|
||||
: [];
|
||||
const duplicateLabels = duplicateValues(labels);
|
||||
const structurallyValid =
|
||||
closed &&
|
||||
Array.isArray(rawLabels) &&
|
||||
labels.length === rawLabels.length &&
|
||||
labels.length >= 2 &&
|
||||
duplicateLabels.length === 0 &&
|
||||
typeof value.reasoning === 'string' &&
|
||||
value.reasoning.trim().length > 0;
|
||||
|
||||
return {
|
||||
...(structurallyValid && { group: { queue_labels: labels, reasoning: value.reasoning as string } }),
|
||||
labels,
|
||||
duplicateLabels,
|
||||
closed,
|
||||
structurallyValid,
|
||||
};
|
||||
}
|
||||
|
||||
function capturedGroups(captured: unknown): { readonly closed: boolean; readonly groups: readonly unknown[] } {
|
||||
if (!isRecord(captured) || !hasExactKeys(captured, TOP_LEVEL_KEYS) || !Array.isArray(captured.groups)) {
|
||||
return { closed: false, groups: [] };
|
||||
}
|
||||
return { closed: true, groups: captured.groups };
|
||||
}
|
||||
|
||||
/** Build the call-local schema. Both object layers reject unknown properties. */
|
||||
export function buildTaskFormationSchema(queueLabels: readonly string[]): Record<string, unknown> {
|
||||
return {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['groups'],
|
||||
properties: {
|
||||
groups: {
|
||||
type: 'array',
|
||||
description:
|
||||
'Sets of observations that predict one exploitation attempt. Use an empty array when every observation stands alone.',
|
||||
items: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['queue_labels', 'reasoning'],
|
||||
properties: {
|
||||
queue_labels: {
|
||||
type: 'array',
|
||||
minItems: 2,
|
||||
uniqueItems: true,
|
||||
items: { type: 'string', enum: [...queueLabels] },
|
||||
description: 'Two or more distinct labels supplied in this call. A label belongs to at most one group.',
|
||||
},
|
||||
reasoning: {
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
description: 'Why one exploitation attempt and one verdict settle every named observation.',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Explain cross-group and nonblank constraints so the model can correct a rejected call. */
|
||||
export function findTaskFormationProblems(captured: unknown, queueLabels: ReadonlySet<string>): string[] {
|
||||
const envelope = capturedGroups(captured);
|
||||
if (!envelope.closed) return ['The submission must be exactly one object with a groups array and no other fields.'];
|
||||
|
||||
const parsed = envelope.groups.map(parseGroup);
|
||||
const problems: string[] = [];
|
||||
const unknown = new Set<string>();
|
||||
const duplicateWithinGroup = new Set<string>();
|
||||
const labelUse = new Map<string, number>();
|
||||
let malformedGroups = 0;
|
||||
let undersizedGroups = 0;
|
||||
let blankReasoningGroups = 0;
|
||||
|
||||
for (let index = 0; index < envelope.groups.length; index++) {
|
||||
const raw = envelope.groups[index];
|
||||
const group = parsed[index] as ParsedGroup;
|
||||
if (!isRecord(raw) || !group.closed || !Array.isArray(raw.queue_labels)) malformedGroups++;
|
||||
if (group.labels.length < 2) undersizedGroups++;
|
||||
if (isRecord(raw) && (typeof raw.reasoning !== 'string' || raw.reasoning.trim().length === 0)) {
|
||||
blankReasoningGroups++;
|
||||
}
|
||||
for (const duplicate of group.duplicateLabels) duplicateWithinGroup.add(duplicate);
|
||||
for (const label of group.labels) {
|
||||
if (!queueLabels.has(label)) unknown.add(label);
|
||||
labelUse.set(label, (labelUse.get(label) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (malformedGroups > 0) {
|
||||
problems.push('Every group must contain exactly queue_labels and reasoning, with no other fields.');
|
||||
}
|
||||
if (undersizedGroups > 0) {
|
||||
problems.push(
|
||||
'Every group must name at least two distinct queued observations; leave single observations ungrouped.',
|
||||
);
|
||||
}
|
||||
if (duplicateWithinGroup.size > 0) {
|
||||
problems.push(`A group cannot repeat a label: ${[...duplicateWithinGroup].join(', ')}.`);
|
||||
}
|
||||
if (blankReasoningGroups > 0) {
|
||||
problems.push('Every group needs nonblank reasoning tied to one exploitation attempt and one verdict.');
|
||||
}
|
||||
|
||||
const unknownMessage = describeUnknownLabels([...unknown], 'queued finding');
|
||||
if (unknownMessage) problems.push(unknownMessage);
|
||||
|
||||
const reused = [...labelUse.entries()].filter(([, count]) => count > 1).map(([label]) => label);
|
||||
const reusedMessage = describeReusedLabels(reused, 'group');
|
||||
if (reusedMessage) problems.push(reusedMessage);
|
||||
return problems;
|
||||
}
|
||||
|
||||
/**
|
||||
* Defensively accept only closed, well-formed groups. Reuse is counted across every submitted
|
||||
* group, including a group already invalid for another reason, so every claimant is discarded.
|
||||
*/
|
||||
export function acceptTaskGroups(captured: unknown, queueLabels: ReadonlySet<string>): AcceptedTaskGroups {
|
||||
const envelope = capturedGroups(captured);
|
||||
if (!envelope.closed) {
|
||||
const submittedCount = isRecord(captured) && Array.isArray(captured.groups) ? captured.groups.length : 0;
|
||||
return { groups: [], rejectedGroupCount: submittedCount, droppedUnknownLabelCount: 0 };
|
||||
}
|
||||
|
||||
const parsed = envelope.groups.map(parseGroup);
|
||||
const labelUse = new Map<string, number>();
|
||||
for (const group of parsed) {
|
||||
for (const label of group.labels) labelUse.set(label, (labelUse.get(label) ?? 0) + 1);
|
||||
}
|
||||
|
||||
let droppedUnknownLabelCount = 0;
|
||||
const groups: TaskFormationGroup[] = [];
|
||||
for (const parsedGroup of parsed) {
|
||||
const containsUnknown = parsedGroup.labels.some((label) => !queueLabels.has(label));
|
||||
if (containsUnknown) droppedUnknownLabelCount++;
|
||||
if (!parsedGroup.structurallyValid || parsedGroup.group === undefined || containsUnknown) continue;
|
||||
if (parsedGroup.labels.some((label) => labelUse.get(label) !== 1)) continue;
|
||||
groups.push(parsedGroup.group);
|
||||
}
|
||||
|
||||
return {
|
||||
groups,
|
||||
rejectedGroupCount: envelope.groups.length - groups.length,
|
||||
droppedUnknownLabelCount,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,568 @@
|
||||
// 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 { execFile } from 'node:child_process';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { mkdir, open, readFile, realpath, rename, rm } from 'node:fs/promises';
|
||||
import { basename, dirname, isAbsolute, relative, resolve } from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
import { isProviderFailureCategory } from '../../../types/errors.js';
|
||||
import type { AgenticSastReduction, CapellaStage, CapellaUsage, SarifRef } from '../types.js';
|
||||
import { InvalidInputError, SastContractError } from './errors.js';
|
||||
import {
|
||||
type AtomicPublishOptions,
|
||||
type CapellaArtifactEnvelope,
|
||||
type CapellaArtifactRef,
|
||||
type CapellaRunFailure,
|
||||
type CapellaRunRecord,
|
||||
type CapellaStageInput,
|
||||
type StageArtifactValidator,
|
||||
type StageUsageSummary,
|
||||
usageAccountingWarning,
|
||||
ZERO_CAPELLA_USAGE,
|
||||
} from './types.js';
|
||||
import { isAgenticSastReduction } from './validation.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const STAGE_ORDER: readonly CapellaStage[] = [
|
||||
'architecture',
|
||||
'threat-model',
|
||||
'plan',
|
||||
'research',
|
||||
'dedupe',
|
||||
'review',
|
||||
'critic',
|
||||
'confirm',
|
||||
'calibrate',
|
||||
'export',
|
||||
];
|
||||
const FAILURE_CODE_PATTERN = /^[A-Z][A-Z0-9_]{0,63}$/;
|
||||
|
||||
// A recorded failure code is either an internal SCREAMING_SNAKE_CASE code this module minted
|
||||
// (ARTIFACT_PATH, SARIF_DIGEST, ...) or a provider failure category forwarded verbatim from the
|
||||
// model harness; both are bounded, closed vocabularies safe to persist in run.json.
|
||||
function isFailureCode(value: unknown): value is string {
|
||||
return typeof value === 'string' && (FAILURE_CODE_PATTERN.test(value) || isProviderFailureCategory(value));
|
||||
}
|
||||
|
||||
function canonicalize(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(canonicalize);
|
||||
if (value && typeof value === 'object') {
|
||||
const output: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
|
||||
for (const key of Object.keys(value).sort()) {
|
||||
const child = (value as Record<string, unknown>)[key];
|
||||
if (child !== undefined) output[key] = canonicalize(child);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
if (typeof value === 'number' && !Number.isFinite(value)) {
|
||||
throw new SastContractError('Capella artifacts cannot contain non-finite numbers', 'ARTIFACT_NON_FINITE');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Serialize a JSON value with recursively sorted object keys. */
|
||||
export function stableJson(value: unknown): string {
|
||||
return `${JSON.stringify(canonicalize(value), null, 2)}\n`;
|
||||
}
|
||||
|
||||
/** Lowercase SHA-256 over exact bytes. */
|
||||
export function sha256Bytes(bytes: string | Uint8Array): string {
|
||||
return createHash('sha256').update(bytes).digest('hex');
|
||||
}
|
||||
|
||||
/** Deterministic fingerprint over a closed set of named inputs. */
|
||||
export function buildFingerprint(parts: Record<string, unknown>): string {
|
||||
return sha256Bytes(stableJson(parts));
|
||||
}
|
||||
|
||||
/** Resolve the immutable repository commit used by all stage fingerprints. */
|
||||
export async function repositoryIdentity(repoPath: string): Promise<string> {
|
||||
let realRepoPath: string;
|
||||
try {
|
||||
realRepoPath = await realpath(repoPath);
|
||||
} catch {
|
||||
throw new InvalidInputError('Capella repository root does not exist', 'REPOSITORY_UNAVAILABLE');
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await execFileAsync('git', ['-C', realRepoPath, 'rev-parse', '--verify', 'HEAD'], {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 64 * 1024,
|
||||
});
|
||||
const commit = stdout.trim().toLowerCase();
|
||||
if (!/^[0-9a-f]{40,64}$/.test(commit)) throw new Error('invalid commit');
|
||||
return commit;
|
||||
} catch {
|
||||
throw new InvalidInputError('Capella requires a repository with a valid HEAD commit', 'REPOSITORY_HEAD');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The run-level identity every stage fingerprint is built on top of. Changing any field here
|
||||
* (a different repository commit, model, format or prompt-set version, or code-path scope)
|
||||
* must invalidate every artifact from a prior run rather than let a resumed scan silently mix
|
||||
* outputs produced under different assumptions.
|
||||
*/
|
||||
export function buildRunInputFingerprint(input: CapellaStageInput, repoIdentity: string): string {
|
||||
return buildFingerprint({
|
||||
repositoryIdentity: repoIdentity,
|
||||
modelSpec: input.modelSpec,
|
||||
capellaFormatVersion: input.capellaFormatVersion,
|
||||
promptSetVersion: input.promptSetVersion,
|
||||
codePathAvoids: [...input.codePathAvoids].sort(),
|
||||
codePathFocus: [...input.codePathFocus].sort(),
|
||||
pipelineTestingMode: input.pipelineTestingMode,
|
||||
});
|
||||
}
|
||||
|
||||
export function stageArtifactPath(artifactRoot: string, stage: CapellaStage): string {
|
||||
return resolve(artifactRoot, 'stages', `${stage}.json`);
|
||||
}
|
||||
|
||||
/** Reject any publish target outside the artifact root, including the root itself. */
|
||||
function assertOwnedPath(artifactRoot: string, targetPath: string): void {
|
||||
const root = resolve(artifactRoot);
|
||||
const target = resolve(targetPath);
|
||||
const rel = relative(root, target);
|
||||
if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) {
|
||||
throw new InvalidInputError('Capella artifact path escapes its artifact root', 'ARTIFACT_PATH');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish exact bytes through a unique sibling and one atomic rename.
|
||||
*
|
||||
* The handle is fsynced before the rename so the visible path can never hold
|
||||
* partial bytes after a crash; on any failure the temporary sibling is removed
|
||||
* and the final path is untouched.
|
||||
*/
|
||||
export async function atomicPublishBytes(
|
||||
artifactRoot: string,
|
||||
finalPath: string,
|
||||
bytes: string | Uint8Array,
|
||||
options: AtomicPublishOptions = {},
|
||||
): Promise<string> {
|
||||
assertOwnedPath(artifactRoot, finalPath);
|
||||
await mkdir(dirname(finalPath), { recursive: true });
|
||||
const temporaryPath = resolve(dirname(finalPath), `.${basename(finalPath)}.${process.pid}.${randomUUID()}.tmp`);
|
||||
let handle: Awaited<ReturnType<typeof open>> | undefined;
|
||||
try {
|
||||
handle = await open(temporaryPath, 'wx', 0o600);
|
||||
await handle.writeFile(bytes);
|
||||
await handle.sync();
|
||||
await handle.close();
|
||||
handle = undefined;
|
||||
await options.beforeRename?.(temporaryPath, finalPath);
|
||||
await rename(temporaryPath, finalPath);
|
||||
return sha256Bytes(bytes);
|
||||
} catch (error) {
|
||||
await handle?.close().catch(() => undefined);
|
||||
await rm(temporaryPath, { force: true }).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function atomicPublishJson(
|
||||
artifactRoot: string,
|
||||
finalPath: string,
|
||||
value: unknown,
|
||||
options: AtomicPublishOptions = {},
|
||||
): Promise<{ readonly sha256: string; readonly bytes: string }> {
|
||||
const bytes = stableJson(value);
|
||||
const sha256 = await atomicPublishBytes(artifactRoot, finalPath, bytes, options);
|
||||
return { sha256, bytes };
|
||||
}
|
||||
|
||||
function isUsage(value: unknown): value is CapellaUsage {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
const usage = value as Record<string, unknown>;
|
||||
const counters = ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens', 'turns'];
|
||||
return (
|
||||
counters.every((key) => Number.isSafeInteger(usage[key]) && Number(usage[key]) >= 0) &&
|
||||
typeof usage.costUsd === 'number' &&
|
||||
Number.isFinite(usage.costUsd) &&
|
||||
usage.costUsd >= 0
|
||||
);
|
||||
}
|
||||
|
||||
function isEnvelope<T>(
|
||||
value: unknown,
|
||||
stage: CapellaStage,
|
||||
fingerprint: string,
|
||||
validate: StageArtifactValidator<T>,
|
||||
): value is CapellaArtifactEnvelope<T> {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
const record = value as Record<string, unknown>;
|
||||
return (
|
||||
record.schemaVersion === 1 &&
|
||||
record.stage === stage &&
|
||||
record.fingerprint === fingerprint &&
|
||||
isUsage(record.usage) &&
|
||||
validate(record.value)
|
||||
);
|
||||
}
|
||||
|
||||
export interface LoadedArtifact<T> {
|
||||
readonly ref: CapellaArtifactRef;
|
||||
readonly value: T;
|
||||
readonly usage: CapellaUsage;
|
||||
}
|
||||
|
||||
/** Return only a schema-valid, fingerprint-matching completed artifact. */
|
||||
export async function loadCompletedArtifact<T>(
|
||||
artifactRoot: string,
|
||||
finalPath: string,
|
||||
stage: CapellaStage,
|
||||
fingerprint: string,
|
||||
validate: StageArtifactValidator<T>,
|
||||
): Promise<LoadedArtifact<T> | undefined> {
|
||||
assertOwnedPath(artifactRoot, finalPath);
|
||||
try {
|
||||
const bytes = await readFile(finalPath);
|
||||
const parsed: unknown = JSON.parse(bytes.toString('utf8'));
|
||||
if (!isEnvelope(parsed, stage, fingerprint, validate)) return undefined;
|
||||
return {
|
||||
ref: { path: finalPath, sha256: sha256Bytes(bytes), fingerprint },
|
||||
value: parsed.value,
|
||||
usage: parsed.usage,
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Load and verify a stage artifact supplied by an earlier activity. */
|
||||
export async function loadArtifactRef<T>(
|
||||
artifactRoot: string,
|
||||
ref: CapellaArtifactRef,
|
||||
stage: CapellaStage,
|
||||
validate: StageArtifactValidator<T>,
|
||||
): Promise<LoadedArtifact<T>> {
|
||||
assertOwnedPath(artifactRoot, ref.path);
|
||||
if (resolve(ref.path) !== stageArtifactPath(artifactRoot, stage)) {
|
||||
throw new SastContractError(`${stage} artifact has an unexpected path`, 'ARTIFACT_PATH');
|
||||
}
|
||||
let bytes: Buffer;
|
||||
try {
|
||||
bytes = await readFile(ref.path);
|
||||
} catch {
|
||||
throw new SastContractError(`${stage} artifact is missing`, 'ARTIFACT_MISSING');
|
||||
}
|
||||
if (sha256Bytes(bytes) !== ref.sha256) {
|
||||
throw new SastContractError(`${stage} artifact digest mismatch`, 'ARTIFACT_DIGEST');
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(bytes.toString('utf8'));
|
||||
} catch {
|
||||
throw new SastContractError(`${stage} artifact is not valid JSON`, 'ARTIFACT_JSON');
|
||||
}
|
||||
if (!isEnvelope(parsed, stage, ref.fingerprint, validate)) {
|
||||
throw new SastContractError(`${stage} artifact failed schema or fingerprint validation`, 'ARTIFACT_SCHEMA');
|
||||
}
|
||||
return { ref, value: parsed.value, usage: parsed.usage };
|
||||
}
|
||||
|
||||
export async function publishStageArtifact<T>(
|
||||
artifactRoot: string,
|
||||
stage: CapellaStage,
|
||||
fingerprint: string,
|
||||
usage: CapellaUsage,
|
||||
value: T,
|
||||
): Promise<CapellaArtifactRef> {
|
||||
const finalPath = stageArtifactPath(artifactRoot, stage);
|
||||
const envelope: CapellaArtifactEnvelope<T> = { schemaVersion: 1, stage, fingerprint, usage, value };
|
||||
const { sha256 } = await atomicPublishJson(artifactRoot, finalPath, envelope);
|
||||
return { path: finalPath, sha256, fingerprint };
|
||||
}
|
||||
|
||||
/** Publish a fingerprinted checkpoint whose path is stage-owned but not the stage completion marker. */
|
||||
export async function publishCheckpointArtifact<T>(
|
||||
artifactRoot: string,
|
||||
finalPath: string,
|
||||
stage: CapellaStage,
|
||||
fingerprint: string,
|
||||
usage: CapellaUsage,
|
||||
value: T,
|
||||
): Promise<CapellaArtifactRef> {
|
||||
const envelope: CapellaArtifactEnvelope<T> = { schemaVersion: 1, stage, fingerprint, usage, value };
|
||||
const { sha256 } = await atomicPublishJson(artifactRoot, finalPath, envelope);
|
||||
return { path: finalPath, sha256, fingerprint };
|
||||
}
|
||||
|
||||
export function addUsage(left: CapellaUsage, right: CapellaUsage): CapellaUsage {
|
||||
return {
|
||||
inputTokens: left.inputTokens + right.inputTokens,
|
||||
outputTokens: left.outputTokens + right.outputTokens,
|
||||
cacheReadTokens: left.cacheReadTokens + right.cacheReadTokens,
|
||||
cacheWriteTokens: left.cacheWriteTokens + right.cacheWriteTokens,
|
||||
costUsd: left.costUsd + right.costUsd,
|
||||
turns: left.turns + right.turns,
|
||||
};
|
||||
}
|
||||
|
||||
function sumStageUsage(stageUsage: Partial<Record<CapellaStage, CapellaUsage>>): CapellaUsage {
|
||||
return STAGE_ORDER.reduce(
|
||||
(total, stage) => addUsage(total, stageUsage[stage] ?? ZERO_CAPELLA_USAGE),
|
||||
ZERO_CAPELLA_USAGE,
|
||||
);
|
||||
}
|
||||
|
||||
/** A run's reduced-coverage set: valid members, at most one per stage, in stage order. */
|
||||
function isReductionSet(value: unknown): value is readonly AgenticSastReduction[] {
|
||||
if (!Array.isArray(value) || !value.every(isAgenticSastReduction)) return false;
|
||||
const stages = value.map((reduction) => reduction.stage);
|
||||
if (new Set(stages).size !== stages.length) return false;
|
||||
const positions = stages.map((stage) => STAGE_ORDER.indexOf(stage));
|
||||
return positions.every((position, index) => index === 0 || position > (positions[index - 1] ?? -1));
|
||||
}
|
||||
|
||||
/** Fold one reduction into a run's set, replacing any prior entry for the same stage, in stage order. */
|
||||
function mergeReductions(
|
||||
existing: readonly AgenticSastReduction[],
|
||||
reduction: AgenticSastReduction,
|
||||
): AgenticSastReduction[] {
|
||||
const byStage = new Map<CapellaStage, AgenticSastReduction>();
|
||||
for (const entry of existing) byStage.set(entry.stage, entry);
|
||||
byStage.set(reduction.stage, reduction);
|
||||
return STAGE_ORDER.filter((stage) => byStage.has(stage)).map((stage) => byStage.get(stage) as AgenticSastReduction);
|
||||
}
|
||||
|
||||
// completedStages must read as a prefix of STAGE_ORDER with no gaps skipped backward, so a
|
||||
// corrupted or hand-edited run.json cannot claim a later stage completed without its predecessors.
|
||||
function stagesAreStrictlyOrdered(stages: readonly CapellaStage[]): boolean {
|
||||
for (let index = 1; index < stages.length; index += 1) {
|
||||
const previous = stages[index - 1];
|
||||
const current = stages[index];
|
||||
if (!previous || !current || STAGE_ORDER.indexOf(current) <= STAGE_ORDER.indexOf(previous)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// The 2,000-character error bound and the attempt/retryable shape keep a persisted failure record
|
||||
// wire-sized and closed, so a provider or filesystem error cannot inflate run.json with unbounded text.
|
||||
function isRunFailure(value: unknown): value is CapellaRunFailure {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
||||
const failure = value as Record<string, unknown>;
|
||||
return (
|
||||
(failure.stage === 'workflow' || STAGE_ORDER.includes(failure.stage as CapellaStage)) &&
|
||||
isFailureCode(failure.code) &&
|
||||
typeof failure.error === 'string' &&
|
||||
failure.error.length > 0 &&
|
||||
failure.error.length <= 2_000 &&
|
||||
Number.isSafeInteger(failure.attempt) &&
|
||||
Number(failure.attempt) >= 1 &&
|
||||
typeof failure.retryable === 'boolean'
|
||||
);
|
||||
}
|
||||
|
||||
function isRunRecord(value: unknown): value is CapellaRunRecord {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
const record = value as Record<string, unknown>;
|
||||
if (record.schemaVersion !== 1) return false;
|
||||
if (typeof record.capellaFormatVersion !== 'string' || typeof record.promptSetVersion !== 'string') return false;
|
||||
if (typeof record.inputFingerprint !== 'string' || !/^[0-9a-f]{64}$/.test(record.inputFingerprint)) return false;
|
||||
if (!Array.isArray(record.completedStages)) return false;
|
||||
if (!record.completedStages.every((stage) => STAGE_ORDER.includes(stage as CapellaStage))) return false;
|
||||
if (new Set(record.completedStages).size !== record.completedStages.length) return false;
|
||||
if (!Array.isArray(record.warnings)) return false;
|
||||
if (!record.warnings.every((warning) => typeof warning === 'string' && warning.length <= 2_000)) return false;
|
||||
if (!isUsage(record.usage)) return false;
|
||||
if (typeof record.usageAccountingComplete !== 'boolean') return false;
|
||||
if (!record.stageUsage || typeof record.stageUsage !== 'object' || Array.isArray(record.stageUsage)) return false;
|
||||
|
||||
const completedStages = record.completedStages as CapellaStage[];
|
||||
if (!stagesAreStrictlyOrdered(completedStages)) return false;
|
||||
const stageUsage = record.stageUsage as Record<string, unknown>;
|
||||
if (
|
||||
Object.keys(stageUsage).some((stage) => !STAGE_ORDER.includes(stage as CapellaStage) || !isUsage(stageUsage[stage]))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (record.reductions !== undefined && !isReductionSet(record.reductions)) return false;
|
||||
|
||||
if (record.finalState === 'succeeded') {
|
||||
if (
|
||||
!completedStages.includes('export') ||
|
||||
!record.sarif ||
|
||||
typeof record.sarif !== 'object' ||
|
||||
record.failure !== undefined
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const sarif = record.sarif as Record<string, unknown>;
|
||||
return typeof sarif.path === 'string' && typeof sarif.sha256 === 'string' && /^[0-9a-f]{64}$/.test(sarif.sha256);
|
||||
}
|
||||
if (record.finalState === 'failed') {
|
||||
return isRunFailure(record.failure) && record.sarif === undefined;
|
||||
}
|
||||
// A running record may carry a failure only while it is retryable: that is
|
||||
// an attempt in flight, not a terminal outcome.
|
||||
return (
|
||||
record.finalState === 'running' &&
|
||||
record.sarif === undefined &&
|
||||
(record.failure === undefined || (isRunFailure(record.failure) && record.failure.retryable))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load only the current input's schema-valid Capella run record.
|
||||
*
|
||||
* Any mismatch (schema, fingerprint, version, or a succeeded record whose SARIF
|
||||
* is not the canonical `capella.sarif` path) reads as absent, so a resumed run
|
||||
* starts fresh instead of adopting progress it cannot trust.
|
||||
*/
|
||||
export async function loadRunRecord(
|
||||
input: CapellaStageInput,
|
||||
inputFingerprint: string,
|
||||
): Promise<CapellaRunRecord | undefined> {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(await readFile(resolve(input.artifactRoot, 'run.json'), 'utf8'));
|
||||
if (!isRunRecord(parsed)) return undefined;
|
||||
if (parsed.inputFingerprint !== inputFingerprint) return undefined;
|
||||
if (parsed.capellaFormatVersion !== input.capellaFormatVersion) return undefined;
|
||||
if (parsed.promptSetVersion !== input.promptSetVersion) return undefined;
|
||||
if (parsed.finalState === 'succeeded' && parsed.sarif?.path !== resolve(input.artifactRoot, 'capella.sarif')) {
|
||||
return undefined;
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function recordStageCompletion(
|
||||
input: CapellaStageInput,
|
||||
inputFingerprint: string,
|
||||
stage: CapellaStage,
|
||||
usage: CapellaUsage,
|
||||
warnings: readonly string[] = [],
|
||||
sarif?: SarifRef,
|
||||
reductions: readonly AgenticSastReduction[] = [],
|
||||
): Promise<void> {
|
||||
const existing = await loadRunRecord(input, inputFingerprint);
|
||||
const stageUsage = { ...(existing?.stageUsage ?? {}), [stage]: usage };
|
||||
// Rebuilt from STAGE_ORDER so the list stays canonically ordered and
|
||||
// deduplicated no matter which stage reports first after a resume.
|
||||
const completedStages = STAGE_ORDER.filter(
|
||||
(candidate) => candidate === stage || existing?.completedStages.includes(candidate),
|
||||
);
|
||||
const mergedWarnings = [...new Set([...(existing?.warnings ?? []), ...warnings])].sort();
|
||||
const mergedReductions = reductions.reduce(
|
||||
(current, reduction) => mergeReductions(current, reduction),
|
||||
[...(existing?.reductions ?? [])],
|
||||
);
|
||||
const record: CapellaRunRecord = {
|
||||
schemaVersion: 1,
|
||||
capellaFormatVersion: input.capellaFormatVersion,
|
||||
promptSetVersion: input.promptSetVersion,
|
||||
inputFingerprint,
|
||||
completedStages,
|
||||
finalState: sarif ? 'succeeded' : 'running',
|
||||
warnings: mergedWarnings,
|
||||
usage: sumStageUsage(stageUsage),
|
||||
stageUsage,
|
||||
// Optimistic: this write carries only the successful attempt's spend. recordStageUsageAccounting
|
||||
// reconciles the figure against the full attempt ledger and downgrades this if the stage retried.
|
||||
usageAccountingComplete: existing?.usageAccountingComplete ?? true,
|
||||
...(mergedReductions.length > 0 ? { reductions: mergedReductions } : {}),
|
||||
...(sarif ? { sarif } : {}),
|
||||
};
|
||||
await atomicPublishJson(input.artifactRoot, resolve(input.artifactRoot, 'run.json'), record);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile a completed stage's spend against its full per-attempt usage ledger.
|
||||
*
|
||||
* recordStageCompletion writes the successful attempt's usage as a crash-safe marker; this
|
||||
* heals that figure to the ledger aggregate (which includes failed attempts) once the activity
|
||||
* has folded the ledger. A retried or ledger-incomplete stage drives usageAccountingComplete
|
||||
* false and names the reason in warnings. Absent record: the completion write must run first,
|
||||
* so there is nothing to reconcile.
|
||||
*/
|
||||
export async function recordStageUsageAccounting(
|
||||
input: CapellaStageInput,
|
||||
inputFingerprint: string,
|
||||
stage: CapellaStage,
|
||||
summary: StageUsageSummary,
|
||||
): Promise<void> {
|
||||
const existing = await loadRunRecord(input, inputFingerprint);
|
||||
if (!existing) return;
|
||||
const stageUsage = { ...existing.stageUsage, [stage]: summary.usage };
|
||||
const stageComplete = summary.complete && !summary.retried;
|
||||
const warnings = stageComplete
|
||||
? existing.warnings
|
||||
: [...new Set([...existing.warnings, usageAccountingWarning(stage)])].sort();
|
||||
const record: CapellaRunRecord = {
|
||||
...existing,
|
||||
warnings,
|
||||
usage: sumStageUsage(stageUsage),
|
||||
stageUsage,
|
||||
usageAccountingComplete: existing.usageAccountingComplete && stageComplete,
|
||||
};
|
||||
await atomicPublishJson(input.artifactRoot, resolve(input.artifactRoot, 'run.json'), record);
|
||||
}
|
||||
|
||||
export interface RecordRunFailureOptions {
|
||||
/** Keep an original fallback-stage failure when its replacement export did not complete. */
|
||||
readonly preserveExistingFailure?: boolean;
|
||||
/** Keep a success that this activity invocation itself completed before later bookkeeping failed. */
|
||||
readonly preserveExistingSuccess?: boolean;
|
||||
}
|
||||
|
||||
export async function recordRunFailure(
|
||||
input: CapellaStageInput,
|
||||
inputFingerprint: string,
|
||||
failure: CapellaRunFailure,
|
||||
terminal: boolean,
|
||||
stageUsageSummary?: StageUsageSummary,
|
||||
options: RecordRunFailureOptions = {},
|
||||
): Promise<void> {
|
||||
const existing = await loadRunRecord(input, inputFingerprint);
|
||||
if (options.preserveExistingSuccess && existing?.finalState === 'succeeded') return;
|
||||
if (options.preserveExistingFailure && existing?.finalState === 'failed') return;
|
||||
const failureIsFinal = terminal || !failure.retryable;
|
||||
// A failing stage still spent tokens; fold its ledger aggregate in so the durable record
|
||||
// counts it. Verify accounting against the same ledger predicate every other ledger uses:
|
||||
// a stage whose spend reconciles (complete and un-retried) keeps the run trusted and clears
|
||||
// its warning; anything unverifiable stays incomplete and names the reason.
|
||||
const stageUsage =
|
||||
stageUsageSummary && failure.stage !== 'workflow'
|
||||
? { ...(existing?.stageUsage ?? {}), [failure.stage]: stageUsageSummary.usage }
|
||||
: (existing?.stageUsage ?? {});
|
||||
const stageComplete =
|
||||
stageUsageSummary !== undefined &&
|
||||
failure.stage !== 'workflow' &&
|
||||
stageUsageSummary.complete &&
|
||||
!stageUsageSummary.retried;
|
||||
const usageAccountingComplete = (existing?.usageAccountingComplete ?? true) && stageComplete;
|
||||
const warnings = stageComplete
|
||||
? (existing?.warnings ?? [])
|
||||
: [...new Set([...(existing?.warnings ?? []), usageAccountingWarning(failure.stage)])].sort();
|
||||
const record: CapellaRunRecord = {
|
||||
schemaVersion: 1,
|
||||
capellaFormatVersion: input.capellaFormatVersion,
|
||||
promptSetVersion: input.promptSetVersion,
|
||||
inputFingerprint,
|
||||
completedStages: existing?.completedStages ?? [],
|
||||
finalState: failureIsFinal ? 'failed' : 'running',
|
||||
warnings,
|
||||
usage: sumStageUsage(stageUsage),
|
||||
stageUsage,
|
||||
usageAccountingComplete,
|
||||
...(existing?.reductions ? { reductions: existing.reductions } : {}),
|
||||
failure: {
|
||||
stage: failure.stage,
|
||||
code: isFailureCode(failure.code) ? failure.code : 'ACTIVITY_FAILURE',
|
||||
error: failure.error.slice(0, 2_000) || 'Capella run failed',
|
||||
attempt: failure.attempt,
|
||||
retryable: failure.retryable,
|
||||
},
|
||||
};
|
||||
await atomicPublishJson(input.artifactRoot, resolve(input.artifactRoot, 'run.json'), record);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,45 @@
|
||||
// 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.
|
||||
|
||||
/** Workflow-safe error identities shared by the Capella executor and Temporal policy. */
|
||||
|
||||
export const CAPELLA_AGENT_ERROR_NAMES = Object.freeze([
|
||||
'AgentExecutionError',
|
||||
'AuthenticationError',
|
||||
'ConfigurationError',
|
||||
'InvalidInputError',
|
||||
'SastContractError',
|
||||
] as const);
|
||||
|
||||
export type CapellaAgentErrorName = (typeof CAPELLA_AGENT_ERROR_NAMES)[number];
|
||||
|
||||
/**
|
||||
* Temporal's type-level retry gate. Activity classification separately forwards each error
|
||||
* instance's retryability, so `AgentExecutionError` is not a blanket retry guarantee.
|
||||
*
|
||||
* `AgentExecutionError` is the one name marked retryable at the type level: it covers transient
|
||||
* failures (provider hiccups, transport faults) that a retry can plausibly clear. The rest name
|
||||
* problems a retry cannot fix on its own: bad credentials, bad configuration, bad input, or a
|
||||
* contract violation in Capella's own output.
|
||||
*/
|
||||
export const CAPELLA_ERROR_TYPE_NON_RETRYABLE = Object.freeze({
|
||||
AgentExecutionError: false,
|
||||
AuthenticationError: true,
|
||||
ConfigurationError: true,
|
||||
InvalidInputError: true,
|
||||
SastContractError: true,
|
||||
} as const satisfies Readonly<Record<CapellaAgentErrorName, boolean>>);
|
||||
|
||||
export const CAPELLA_NON_RETRYABLE_ERROR_TYPES = Object.freeze(
|
||||
CAPELLA_AGENT_ERROR_NAMES.filter((name) => CAPELLA_ERROR_TYPE_NON_RETRYABLE[name]),
|
||||
);
|
||||
|
||||
// Compile-time exhaustiveness check: if a name is ever added to CAPELLA_AGENT_ERROR_NAMES without
|
||||
// a matching entry in CAPELLA_ERROR_TYPE_NON_RETRYABLE, UnclassifiedCapellaAgentError stops being
|
||||
// `never` and this assignment fails to typecheck, catching the gap before it reaches Temporal.
|
||||
type UnclassifiedCapellaAgentError = Exclude<CapellaAgentErrorName, keyof typeof CAPELLA_ERROR_TYPE_NON_RETRYABLE>;
|
||||
const _everyCapellaAgentErrorHasTypeGate: UnclassifiedCapellaAgentError extends never ? true : never = true;
|
||||
void _everyCapellaAgentErrorHasTypeGate;
|
||||
@@ -0,0 +1,69 @@
|
||||
// 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 type { ProviderFailureCategory } from '../../../types/errors.js';
|
||||
|
||||
const MAX_ERROR_MESSAGE_LENGTH = 2_000;
|
||||
const MACHINE_CODE_PATTERN = /^[A-Z][A-Z0-9_]{0,63}$/;
|
||||
|
||||
class NamedCapellaError extends Error {
|
||||
constructor(
|
||||
name: string,
|
||||
readonly code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message.slice(0, MAX_ERROR_MESSAGE_LENGTH));
|
||||
this.name = name;
|
||||
}
|
||||
}
|
||||
|
||||
/** A caller supplied an invalid repository, path, artifact, or stage input. */
|
||||
export class InvalidInputError extends NamedCapellaError {
|
||||
constructor(message: string, code = 'INVALID_INPUT') {
|
||||
super('InvalidInputError', code, message);
|
||||
}
|
||||
}
|
||||
|
||||
/** A Capella format, stage, or SARIF invariant was violated. */
|
||||
export class SastContractError extends NamedCapellaError {
|
||||
constructor(message: string, code = 'SAST_CONTRACT') {
|
||||
super('SastContractError', code, message);
|
||||
}
|
||||
}
|
||||
|
||||
/** A required Capella prompt or immutable setting is unavailable. */
|
||||
export class ConfigurationError extends NamedCapellaError {
|
||||
constructor(message: string, code = 'CONFIGURATION') {
|
||||
super('ConfigurationError', code, message);
|
||||
}
|
||||
}
|
||||
|
||||
/** A sanitized local failure that is safe to retry without exposing its underlying I/O error. */
|
||||
export class CapellaRetryableError extends NamedCapellaError {
|
||||
constructor(message: string, code = 'RETRYABLE_IO') {
|
||||
super('CapellaRetryableError', code, message);
|
||||
}
|
||||
}
|
||||
|
||||
/** Return a bounded machine code, never an error message or provider-authored value. */
|
||||
export function capellaFailureCode(error: unknown, fallback: string): string {
|
||||
if (!error || typeof error !== 'object' || !('code' in error)) return fallback;
|
||||
const code = (error as { readonly code?: unknown }).code;
|
||||
return typeof code === 'string' && MACHINE_CODE_PATTERN.test(code) ? code : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* The most specific bounded code for a classified agent failure. A provider category names a
|
||||
* real cause worth preferring (rate_limit, quota, context_limit, ...), but the vocabulary's
|
||||
* `unknown` member names nothing, so a concrete fixed code outranks it.
|
||||
*/
|
||||
export function capellaClassifiedFailureCode(
|
||||
code: string,
|
||||
providerCategory: ProviderFailureCategory | undefined,
|
||||
): string {
|
||||
if (providerCategory === undefined || providerCategory === 'unknown') return code;
|
||||
return providerCategory;
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
// 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.
|
||||
|
||||
/**
|
||||
* Capella's on-disk record shapes and vocabularies.
|
||||
*
|
||||
* A transcription of the upstream Mantis finding contract, minus the
|
||||
* fields for stages Capella does not run (patch, reattack, chain, and the
|
||||
* whole snapshot/provenance layer). The calibrate surface is retained because
|
||||
* calibrate is kept report-only.
|
||||
*
|
||||
* The vocabularies are exported as `readonly` arrays so the collector schemas and
|
||||
* the Node-side validators share one source of truth; drift between the two is
|
||||
* how an agent ships a value the schema forbids.
|
||||
*/
|
||||
|
||||
// === Enumerated vocabularies (upstream finding contract) ===
|
||||
|
||||
export const CAPELLA_SEVERITIES = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'] as const;
|
||||
export type CapellaSeverity = (typeof CAPELLA_SEVERITIES)[number];
|
||||
|
||||
export const CAPELLA_STATUSES = [
|
||||
'VALID',
|
||||
'FALSE_POSITIVE',
|
||||
'PROVISIONALLY_VALID',
|
||||
'NEEDS_RESEARCH',
|
||||
'DUPLICATE',
|
||||
] as const;
|
||||
export type CapellaStatus = (typeof CAPELLA_STATUSES)[number];
|
||||
|
||||
/**
|
||||
* The statuses a review verdict may assign. `DUPLICATE` is excluded: only
|
||||
* dedupe assigns it, through a different tool.
|
||||
*/
|
||||
export const CAPELLA_REVIEW_STATUSES = ['VALID', 'FALSE_POSITIVE', 'PROVISIONALLY_VALID', 'NEEDS_RESEARCH'] as const;
|
||||
export type CapellaReviewStatus = (typeof CAPELLA_REVIEW_STATUSES)[number];
|
||||
|
||||
export const CAPELLA_VIABILITIES = ['VIABLE', 'NON_VIABLE', 'SAMPLE_OR_TEST', 'CONDITIONAL_VIABLE'] as const;
|
||||
export type CapellaViability = (typeof CAPELLA_VIABILITIES)[number];
|
||||
|
||||
/**
|
||||
* The full upstream `repro_status` enum, kept for record fidelity. Capella can
|
||||
* only ever *set* `statically_confirmed` or `not_attempted`: it has no
|
||||
* execution sandbox, so `reproduced` and `failed_to_reproduce` are unreachable.
|
||||
*/
|
||||
export const CAPELLA_REPRO_STATUSES = [
|
||||
'reproduced',
|
||||
'statically_confirmed',
|
||||
'not_attempted',
|
||||
'failed_to_reproduce',
|
||||
] as const;
|
||||
export type CapellaReproStatus = (typeof CAPELLA_REPRO_STATUSES)[number];
|
||||
|
||||
/** The classifications the static-confirmation stage may assign. */
|
||||
export const CAPELLA_CONFIRM_STATUSES = ['statically_confirmed', 'not_attempted'] as const;
|
||||
export type CapellaConfirmStatus = (typeof CAPELLA_CONFIRM_STATUSES)[number];
|
||||
|
||||
export const CAPELLA_PRIVILEGES = ['NONE', 'LOW', 'HIGH'] as const;
|
||||
export type CapellaPrivileges = (typeof CAPELLA_PRIVILEGES)[number];
|
||||
|
||||
export const CAPELLA_ATTACKER_POSITIONS = [
|
||||
'EXTERNAL',
|
||||
'INTERNAL_NETWORK',
|
||||
'IN_CLUSTER',
|
||||
'LOCAL',
|
||||
'HOST_SYSTEM',
|
||||
'SUPPLY_CHAIN',
|
||||
'PHYSICAL_TEMPORARY',
|
||||
'PHYSICAL_LONG_TERM',
|
||||
] as const;
|
||||
export type CapellaAttackerPosition = (typeof CAPELLA_ATTACKER_POSITIONS)[number];
|
||||
|
||||
export const CAPELLA_USER_INTERACTIONS = ['NONE', 'REQUIRED'] as const;
|
||||
export type CapellaUserInteraction = (typeof CAPELLA_USER_INTERACTIONS)[number];
|
||||
|
||||
export const CAPELLA_AVAILABILITY_TIERS = ['CRITICAL', 'STANDARD', 'LOW_CRITICALITY'] as const;
|
||||
export type CapellaAvailabilityTier = (typeof CAPELLA_AVAILABILITY_TIERS)[number];
|
||||
|
||||
export const CAPELLA_EXPOSURES = ['EXPOSED', 'INTERNAL', 'PRIVILEGED'] as const;
|
||||
export type CapellaExposure = (typeof CAPELLA_EXPOSURES)[number];
|
||||
|
||||
// === Checklists ===
|
||||
|
||||
export const CAPELLA_TRIAGE_OUTCOMES = ['PASS', 'FAIL', 'UNKNOWN', 'NOT_APPLICABLE'] as const;
|
||||
export type CapellaTriageOutcome = (typeof CAPELLA_TRIAGE_OUTCOMES)[number];
|
||||
|
||||
/** The 13 negative constraints, in schema order. */
|
||||
export const TRIAGE_RULE_KEYS = [
|
||||
'ignore_hypothetical_misuse',
|
||||
'ignore_missing_hygiene',
|
||||
'require_strict_reproducibility',
|
||||
'avoid_pedantic_linting',
|
||||
'no_security_flaw_stretching',
|
||||
'evaluate_questionable_file_paths',
|
||||
'ignore_resource_exhaustion_dos',
|
||||
'intrinsic_security_flaws',
|
||||
'verify_mitigations_pragmatically',
|
||||
'refine_code_paths_strictly',
|
||||
'ignore_simd_vector_padding',
|
||||
'ensure_source_code_coherence',
|
||||
'verify_attacker_control_of_source',
|
||||
] as const;
|
||||
export type TriageRuleKey = (typeof TRIAGE_RULE_KEYS)[number];
|
||||
|
||||
export interface TriageRuleEvaluation {
|
||||
outcome: CapellaTriageOutcome;
|
||||
/** Required whenever outcome is FAIL, UNKNOWN or NOT_APPLICABLE. */
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export type TriageChecklist = Record<TriageRuleKey, TriageRuleEvaluation>;
|
||||
|
||||
export const CAPELLA_CALIBRATION_OUTCOMES = ['APPLIES', 'DOES_NOT_APPLY', 'UNKNOWN'] as const;
|
||||
export type CapellaCalibrationOutcome = (typeof CAPELLA_CALIBRATION_OUTCOMES)[number];
|
||||
|
||||
/** The 27 sanity-cap rules, in schema order. */
|
||||
export const CALIBRATION_RULE_KEYS = [
|
||||
'repro_failure',
|
||||
'unreachable_inputs',
|
||||
'third_party_reachability',
|
||||
'minor_config_hygiene',
|
||||
'non_security_critical',
|
||||
'vague_code_paths',
|
||||
'unreliable_triggers',
|
||||
'prerequisite_shell',
|
||||
'physical_long_term',
|
||||
'trusted_controller_zero_delta',
|
||||
'standard_host_attacks',
|
||||
'static_confirmation',
|
||||
'strict_xss',
|
||||
'internal_nested',
|
||||
'probabilistic_llm',
|
||||
'supply_chain_prerequisites',
|
||||
'non_default_config',
|
||||
'confidential_computing_host',
|
||||
'trusted_controller_critical_bypass',
|
||||
'local_attack_vector',
|
||||
'self_contained_blast',
|
||||
'rarely_exposed',
|
||||
'equivalent_primitives',
|
||||
'documented_insecure_config',
|
||||
'physical_temporary',
|
||||
'high_privilege_external',
|
||||
'trusted_controller_standard_bypass',
|
||||
] as const;
|
||||
export type CalibrationRuleKey = (typeof CALIBRATION_RULE_KEYS)[number];
|
||||
|
||||
export interface CalibrationRuleEvaluation {
|
||||
outcome: CapellaCalibrationOutcome;
|
||||
/** Required whenever outcome is APPLIES or UNKNOWN. */
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export type CalibrationChecklist = Record<CalibrationRuleKey, CalibrationRuleEvaluation>;
|
||||
|
||||
// === History ===
|
||||
|
||||
/**
|
||||
* One entry in a finding's audit trail. Simplified from the upstream
|
||||
* `history_entry`: there is no multi-pass loop, so no `pass_number`, and Node
|
||||
* writes the entries so the shape is ours to set.
|
||||
*/
|
||||
export interface CapellaHistoryEntry {
|
||||
stage: string;
|
||||
action: string;
|
||||
details: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
// === The finding record ===
|
||||
|
||||
/**
|
||||
* The Capella finding record (`findings/<id>.json`).
|
||||
*
|
||||
* A subset of Mantis's `finding` object: the patch, reattack, chain and
|
||||
* snapshot/provenance fields are dropped with their stages, and `outrage_commentary`
|
||||
* / `executive_summary` go with the dropped report stage. The calibrate fields
|
||||
* are kept because calibrate is retained report-only.
|
||||
*/
|
||||
export interface CapellaFinding {
|
||||
// Identity + creation (researcher)
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
code_paths: string[];
|
||||
impact: string;
|
||||
severity: CapellaSeverity;
|
||||
privileges_required: CapellaPrivileges;
|
||||
attacker_position: CapellaAttackerPosition;
|
||||
user_interaction: CapellaUserInteraction;
|
||||
mitigation: string;
|
||||
/** Required here where upstream makes it optional: it keys the dedup identity. */
|
||||
cwe: string;
|
||||
history: CapellaHistoryEntry[];
|
||||
status: CapellaStatus;
|
||||
|
||||
// Dedupe
|
||||
duplicate_of?: string;
|
||||
|
||||
// Review
|
||||
reasoning?: string;
|
||||
repro_hints?: string;
|
||||
triage_checklist?: TriageChecklist;
|
||||
|
||||
// Critic
|
||||
production_viability?: CapellaViability;
|
||||
critic_reasoning?: string;
|
||||
|
||||
// Confirm (static only)
|
||||
repro_status?: CapellaReproStatus;
|
||||
|
||||
// Calibrate (report-only)
|
||||
impact_score?: number;
|
||||
likelihood_score?: number;
|
||||
availability_tier?: CapellaAvailabilityTier | null;
|
||||
inferred_exposure?: CapellaExposure;
|
||||
mantis_risk_score?: number;
|
||||
priority?: CapellaSeverity;
|
||||
sanity_triage_applied?: string | null;
|
||||
calibration_checklist?: CalibrationChecklist;
|
||||
|
||||
// Bookkeeping (ours, not upstream's)
|
||||
recordedAt: number;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// 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 path from 'node:path';
|
||||
|
||||
export interface ParsedCodePath {
|
||||
readonly file: string;
|
||||
readonly line: number;
|
||||
}
|
||||
|
||||
/** Parse a sink-first `file:line` locator without coercing invalid paths. */
|
||||
export function parseCodePath(entry: string): ParsedCodePath | undefined {
|
||||
const match = entry.trim().match(/^(.+):(\d+)$/);
|
||||
if (!match) return undefined;
|
||||
const file = match[1];
|
||||
const line = Number(match[2]);
|
||||
if (!file || !Number.isSafeInteger(line) || line <= 0) return undefined;
|
||||
return { file, line };
|
||||
}
|
||||
|
||||
/**
|
||||
* The normalized repository-relative POSIX path contract shared by SARIF
|
||||
* locations and code-path scoping. Rejects absolute, drive-letter, encoded,
|
||||
* traversal, and non-canonical forms so the same string keys comparisons on
|
||||
* both the producing and consuming side.
|
||||
*/
|
||||
export function isNormalizedRepositoryPath(value: string): boolean {
|
||||
if (!value || value.includes('\\') || value.includes('\0') || value.includes('://')) return false;
|
||||
if (/%(?:00|2e|2f|5c)/i.test(value)) return false;
|
||||
if (value.startsWith('/') || /^[A-Za-z]:/.test(value)) return false;
|
||||
if (value.startsWith('./') || value.endsWith('/') || value.includes('//')) return false;
|
||||
const segments = value.split('/');
|
||||
if (segments.some((segment) => segment === '' || segment === '.' || segment === '..')) return false;
|
||||
return path.posix.normalize(value) === value;
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[|\\{}()[\]^$+?.]/g, '\\$&');
|
||||
}
|
||||
|
||||
// `**` collapses to a plain `*` before compiling: this matcher only needs to decide membership
|
||||
// under an excluded directory, not distinguish depth, so a simpler wildcard is equivalent here.
|
||||
function globExpression(pattern: string): RegExp {
|
||||
const normalized = pattern.replace(/^(?:\.\.?\/)+/, '').replace(/\*\*/g, '*');
|
||||
const expression = escapeRegExp(normalized).replace(/\*/g, '.*').replace(/\\\?/g, '.');
|
||||
return new RegExp(`^(?:${expression}|.*/${expression})(?:/.*)?$`);
|
||||
}
|
||||
|
||||
/** Match a repository-relative path against normalized code_path exclusions. */
|
||||
export function isExcludedCodePath(file: string, avoids: readonly string[]): boolean {
|
||||
return avoids.some((avoid) => {
|
||||
const normalized = avoid
|
||||
.trim()
|
||||
.replace(/^(?:\.\.?\/)+/, '')
|
||||
.replace(/\/+$/, '');
|
||||
if (!normalized) return false;
|
||||
if (normalized.includes('*') || normalized.includes('?')) return globExpression(normalized).test(file);
|
||||
return file === normalized || file.startsWith(`${normalized}/`) || file.includes(`/${normalized}/`);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
// 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 { stableJson } from './artifacts.js';
|
||||
import type { CapellaFinding } from './finding-types.js';
|
||||
import type { Investigation, KbResult } from './schemas.js';
|
||||
|
||||
function compareText(left: string, right: string): number {
|
||||
if (left < right) return -1;
|
||||
if (left > right) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export interface CapellaToolContext {
|
||||
readonly [key: string]: string;
|
||||
readonly CAPELLA_EXTRA_TOOLS: string;
|
||||
readonly CAPELLA_RECORDING_ROUTE: string;
|
||||
}
|
||||
|
||||
const STRUCTURED_OUTPUT: CapellaToolContext = {
|
||||
CAPELLA_EXTRA_TOOLS: '',
|
||||
CAPELLA_RECORDING_ROUTE: 'by returning it as your structured output',
|
||||
};
|
||||
|
||||
export const ARCHITECTURE_TOOLS = STRUCTURED_OUTPUT;
|
||||
export const THREAT_MODEL_TOOLS = STRUCTURED_OUTPUT;
|
||||
export const PLAN_TOOLS = STRUCTURED_OUTPUT;
|
||||
export const TRIAGE_TOOLS = STRUCTURED_OUTPUT;
|
||||
export const RESEARCH_TOOLS: CapellaToolContext = {
|
||||
CAPELLA_EXTRA_TOOLS: ', plus `report_finding`',
|
||||
CAPELLA_RECORDING_ROUTE: 'by calling `report_finding`',
|
||||
};
|
||||
export const DEDUPE_TOOLS: CapellaToolContext = {
|
||||
CAPELLA_EXTRA_TOOLS: ', plus `record_duplicates`',
|
||||
CAPELLA_RECORDING_ROUTE: 'by calling `record_duplicates`',
|
||||
};
|
||||
export const REVIEW_TOOLS: CapellaToolContext = {
|
||||
CAPELLA_EXTRA_TOOLS: ', plus `record_review_verdict`',
|
||||
CAPELLA_RECORDING_ROUTE: 'by calling `record_review_verdict`',
|
||||
};
|
||||
export const CRITIC_TOOLS: CapellaToolContext = {
|
||||
CAPELLA_EXTRA_TOOLS: ', plus `record_viability`',
|
||||
CAPELLA_RECORDING_ROUTE: 'by calling `record_viability`',
|
||||
};
|
||||
export const CONFIRM_TOOLS: CapellaToolContext = {
|
||||
CAPELLA_EXTRA_TOOLS: ', plus `record_static_confirmation`',
|
||||
CAPELLA_RECORDING_ROUTE: 'by calling `record_static_confirmation`',
|
||||
};
|
||||
export const CALIBRATE_TOOLS: CapellaToolContext = {
|
||||
CAPELLA_EXTRA_TOOLS: ', plus `record_calibration`',
|
||||
CAPELLA_RECORDING_ROUTE: 'by calling `record_calibration`',
|
||||
};
|
||||
|
||||
/**
|
||||
* The context keys each prompt template requires. The loader refuses to render
|
||||
* a prompt whose caller omitted one, so a template edit that adds a placeholder
|
||||
* must extend this table or every render of that stage fails fast.
|
||||
*/
|
||||
export const CAPELLA_PROMPT_CONTEXT_KEYS = {
|
||||
'sast.capella.architecture': [
|
||||
'CAPELLA_EXTRA_TOOLS',
|
||||
'CAPELLA_RECORDING_ROUTE',
|
||||
'LANGUAGE_CONTEXT',
|
||||
'BOUNDARY_CONTEXT',
|
||||
],
|
||||
'sast.capella.threat_model': ['CAPELLA_EXTRA_TOOLS', 'CAPELLA_RECORDING_ROUTE', 'KB_DIR'],
|
||||
'sast.capella.plan': [
|
||||
'CAPELLA_EXTRA_TOOLS',
|
||||
'CAPELLA_RECORDING_ROUTE',
|
||||
'KB_DIR',
|
||||
'LANGUAGE_CONTEXT',
|
||||
'BOUNDARY_CONTEXT',
|
||||
],
|
||||
'sast.capella.triage': [
|
||||
'CAPELLA_EXTRA_TOOLS',
|
||||
'CAPELLA_RECORDING_ROUTE',
|
||||
'LANGUAGE_CONTEXT',
|
||||
'BOUNDARY_CONTEXT',
|
||||
'TARGET_FILES',
|
||||
],
|
||||
'sast.capella.research': ['CAPELLA_EXTRA_TOOLS', 'CAPELLA_RECORDING_ROUTE', 'LANGUAGE_CONTEXT', 'BOUNDARY_CONTEXT'],
|
||||
'sast.capella.dedupe': ['CAPELLA_EXTRA_TOOLS', 'CAPELLA_RECORDING_ROUTE'],
|
||||
'sast.capella.review': ['CAPELLA_EXTRA_TOOLS', 'CAPELLA_RECORDING_ROUTE'],
|
||||
'sast.capella.critic': ['CAPELLA_EXTRA_TOOLS', 'CAPELLA_RECORDING_ROUTE', 'KB_DIR'],
|
||||
'sast.capella.confirm': ['CAPELLA_EXTRA_TOOLS', 'CAPELLA_RECORDING_ROUTE'],
|
||||
'sast.capella.calibrate': ['CAPELLA_EXTRA_TOOLS', 'CAPELLA_RECORDING_ROUTE', 'KB_DIR'],
|
||||
} as const;
|
||||
|
||||
function sortedUnique(values: readonly string[]): string[] {
|
||||
return [...new Set(values.map((value) => value.trim()).filter(Boolean))].sort();
|
||||
}
|
||||
|
||||
/** Prompt-only steering. Tool enforcement remains authoritative for denied paths. */
|
||||
export function buildCodePathScopeSnippet(focus: readonly string[], avoids: readonly string[]): string {
|
||||
const normalizedFocus = sortedUnique(focus);
|
||||
const normalizedAvoids = sortedUnique(avoids);
|
||||
if (normalizedFocus.length === 0 && normalizedAvoids.length === 0) return '';
|
||||
|
||||
const lines = ['### Repository code-path scope'];
|
||||
if (normalizedFocus.length > 0) {
|
||||
lines.push(
|
||||
'',
|
||||
'Prioritize these configured paths while preserving end-to-end traces:',
|
||||
...normalizedFocus.map((path) => `- ${path}`),
|
||||
);
|
||||
}
|
||||
if (normalizedAvoids.length > 0) {
|
||||
lines.push(
|
||||
'',
|
||||
'Do not inspect or report findings whose sink is under these excluded paths:',
|
||||
...normalizedAvoids.map((path) => `- ${path}`),
|
||||
);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/** Embed the KB because repository-confined tools cannot read the sibling artifact root. */
|
||||
export function buildKnowledgeBaseContext(knowledgeBase: KbResult): string {
|
||||
return [
|
||||
'## Shannon host-provided knowledge base',
|
||||
'',
|
||||
'The knowledge base is supplied as structured data below. Do not look for it in the repository.',
|
||||
'',
|
||||
'<capella_knowledge_base_json>',
|
||||
stableJson(knowledgeBase).trimEnd(),
|
||||
'</capella_knowledge_base_json>',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/** Embed the immutable current finding set for verdict stages. */
|
||||
export function buildFindingsContext(findings: readonly CapellaFinding[]): string {
|
||||
const ordered = [...findings].sort((left, right) => compareText(left.id, right.id));
|
||||
return [
|
||||
'## Shannon host-provided findings',
|
||||
'',
|
||||
'The complete current finding set is supplied below. Do not look for a `findings/` directory.',
|
||||
'Use repository tools only for the source paths cited by these records.',
|
||||
'',
|
||||
'<capella_findings_json>',
|
||||
stableJson(ordered).trimEnd(),
|
||||
'</capella_findings_json>',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function buildResearchAssignment(
|
||||
investigation: Investigation,
|
||||
flaggedFiles: readonly string[],
|
||||
knowledgeBase: KbResult,
|
||||
): string {
|
||||
const referenced = new Set(investigation.kb_references ?? []);
|
||||
const kbEntries = [...knowledgeBase.entities, ...knowledgeBase.vulnerabilities]
|
||||
.filter((entry) => referenced.size === 0 || [...referenced].some((ref) => ref.includes(entry.name)))
|
||||
.sort((left, right) => compareText(left.name, right.name));
|
||||
return [
|
||||
'## Your assignment',
|
||||
'',
|
||||
`Question: ${investigation.question}`,
|
||||
'',
|
||||
'Flagged target files:',
|
||||
...[...flaggedFiles].sort().map((file) => `- ${file}`),
|
||||
'',
|
||||
'The referenced KB entries are embedded below. Use this content directly; do not look for KB Markdown files in the repository.',
|
||||
'',
|
||||
'<capella_referenced_kb_json>',
|
||||
stableJson(kbEntries).trimEnd(),
|
||||
'</capella_referenced_kb_json>',
|
||||
].join('\n');
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// 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 { readdirSync, readFileSync } from 'node:fs';
|
||||
import { basename, join } from 'node:path';
|
||||
import Handlebars from 'handlebars';
|
||||
import { CapellaRetryableError, ConfigurationError } from './errors.js';
|
||||
import { CAPELLA_PROMPT_CONTEXT_KEYS } from './prompt-context.js';
|
||||
|
||||
/** Transient I/O only; a missing or malformed asset is configuration, not retryable. */
|
||||
const RETRYABLE_PROMPT_IO_CODES = new Set(['EAGAIN', 'EBUSY', 'EIO', 'EMFILE', 'ENFILE', 'ENOMEM']);
|
||||
|
||||
export const CAPELLA_PROMPT_IDS = [
|
||||
'sast.capella.architecture',
|
||||
'sast.capella.threat_model',
|
||||
'sast.capella.plan',
|
||||
'sast.capella.triage',
|
||||
'sast.capella.research',
|
||||
'sast.capella.dedupe',
|
||||
'sast.capella.review',
|
||||
'sast.capella.critic',
|
||||
'sast.capella.confirm',
|
||||
'sast.capella.calibrate',
|
||||
] as const;
|
||||
|
||||
export type CapellaPromptId = (typeof CAPELLA_PROMPT_IDS)[number];
|
||||
|
||||
export interface RenderCapellaPromptOptions {
|
||||
readonly pipelineTestingMode?: boolean;
|
||||
}
|
||||
|
||||
export interface CapellaPromptLoader {
|
||||
render(
|
||||
promptId: CapellaPromptId,
|
||||
context?: Readonly<Record<string, unknown>>,
|
||||
options?: RenderCapellaPromptOptions,
|
||||
): string;
|
||||
}
|
||||
|
||||
/** Create an isolated loader that registers only Capella-owned partials. */
|
||||
export function createCapellaPromptLoader(promptRoot: string): CapellaPromptLoader {
|
||||
const handlebars = Handlebars.create();
|
||||
const partialsDir = join(promptRoot, 'partials');
|
||||
const cache = new Map<string, Handlebars.TemplateDelegate>();
|
||||
|
||||
let partialFiles: string[];
|
||||
try {
|
||||
partialFiles = readdirSync(partialsDir)
|
||||
.filter((file) => /^capella-[A-Za-z0-9._-]+\.hbs$/.test(file))
|
||||
.sort();
|
||||
} catch (error) {
|
||||
throw promptReadError(error, 'PROMPT_PARTIALS_UNAVAILABLE');
|
||||
}
|
||||
|
||||
for (const file of partialFiles) {
|
||||
const name = basename(file, '.hbs');
|
||||
handlebars.registerPartial(name, readPromptFile(join(partialsDir, file)));
|
||||
}
|
||||
|
||||
return {
|
||||
render(
|
||||
promptId: CapellaPromptId,
|
||||
context: Readonly<Record<string, unknown>> = {},
|
||||
options: RenderCapellaPromptOptions = {},
|
||||
): string {
|
||||
if (!(CAPELLA_PROMPT_IDS as readonly string[]).includes(promptId)) {
|
||||
throw new ConfigurationError('Unknown Capella prompt id');
|
||||
}
|
||||
for (const requiredKey of CAPELLA_PROMPT_CONTEXT_KEYS[promptId]) {
|
||||
if (!(requiredKey in context)) {
|
||||
throw new ConfigurationError(`Capella prompt context is missing ${requiredKey}`);
|
||||
}
|
||||
}
|
||||
const relative = promptId.replace(/\./g, '/');
|
||||
const suffix = options.pipelineTestingMode === true ? '.test.hbs' : '.prompt.hbs';
|
||||
const filePath = join(promptRoot, `${relative}${suffix}`);
|
||||
let template = cache.get(filePath);
|
||||
if (!template) {
|
||||
try {
|
||||
template = handlebars.compile(readPromptFile(filePath), { noEscape: true });
|
||||
} catch (error) {
|
||||
if (error instanceof ConfigurationError || error instanceof CapellaRetryableError) throw error;
|
||||
throw new ConfigurationError('Required Capella prompt asset is invalid', 'PROMPT_COMPILE_FAILED');
|
||||
}
|
||||
cache.set(filePath, template);
|
||||
}
|
||||
try {
|
||||
return template(context);
|
||||
} catch {
|
||||
throw new ConfigurationError('Required Capella prompt asset is invalid', 'PROMPT_RENDER_FAILED');
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function readPromptFile(filePath: string): string {
|
||||
try {
|
||||
return readFileSync(filePath, 'utf8');
|
||||
} catch (error) {
|
||||
throw promptReadError(error, 'PROMPT_ASSET_UNAVAILABLE');
|
||||
}
|
||||
}
|
||||
|
||||
function promptReadError(error: unknown, unavailableCode: string): Error {
|
||||
const code = (error as NodeJS.ErrnoException | undefined)?.code;
|
||||
if (code && RETRYABLE_PROMPT_IO_CODES.has(code)) {
|
||||
return new CapellaRetryableError('Capella prompt assets could not be read', 'PROMPT_IO');
|
||||
}
|
||||
return new ConfigurationError('Required Capella prompt asset is unavailable', unavailableCode);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// 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.
|
||||
|
||||
/**
|
||||
* Deterministic `report.md` rendering. There is no model call: the report is
|
||||
* built from the same structured records the SARIF is, keeping the LLM out of
|
||||
* the JSON-to-Markdown conversion.
|
||||
*
|
||||
* Unlike the SARIF, the report renders *every* finding, including those the
|
||||
* export gate drops, and surfaces the report-only calibration (`mantis_risk_score`,
|
||||
* `sanity_triage_applied`) so an operator can see what calibrate would have said.
|
||||
*/
|
||||
|
||||
import type { CapellaFinding } from './finding-types.js';
|
||||
|
||||
function compareText(left: string, right: string): number {
|
||||
if (left < right) return -1;
|
||||
if (left > right) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function riskLine(finding: CapellaFinding): string {
|
||||
if (finding.mantis_risk_score === undefined) return '';
|
||||
const caps = finding.sanity_triage_applied ? ` — caps: ${finding.sanity_triage_applied}` : '';
|
||||
const priority = finding.priority ? ` (${finding.priority})` : '';
|
||||
return `\n- **Calibrated risk:** ${finding.mantis_risk_score}/10${priority}${caps}`;
|
||||
}
|
||||
|
||||
function renderFinding(finding: CapellaFinding): string {
|
||||
const location = finding.code_paths[0] ?? '(no location)'; // sink = primary location
|
||||
const viability = finding.production_viability ? ` · ${finding.production_viability}` : '';
|
||||
return [
|
||||
`### ${finding.title}`,
|
||||
'',
|
||||
`- **CWE:** ${finding.cwe}`,
|
||||
`- **Severity:** ${finding.severity}`,
|
||||
`- **Status:** ${finding.status}${viability}`,
|
||||
`- **Location:** \`${location}\``,
|
||||
`- **Code path:** ${[...finding.code_paths]
|
||||
.reverse()
|
||||
.map((p) => `\`${p}\``)
|
||||
.join(' → ')}`,
|
||||
riskLine(finding),
|
||||
'',
|
||||
finding.description,
|
||||
'',
|
||||
`**Impact:** ${finding.impact}`,
|
||||
'',
|
||||
`**Mitigation:** ${finding.mitigation}`,
|
||||
finding.reasoning ? `\n**Reviewer reasoning:** ${finding.reasoning}` : '',
|
||||
]
|
||||
.filter((line) => line !== '')
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
/** Render the full report from every finding, partitioned by actual SARIF membership. */
|
||||
export function renderCapellaReport(
|
||||
findings: readonly CapellaFinding[],
|
||||
exportedFindingIds: ReadonlySet<string>,
|
||||
repoPath: string,
|
||||
): string {
|
||||
const ordered = [...findings].sort((left, right) => compareText(left.id, right.id));
|
||||
const exported = ordered.filter((finding) => exportedFindingIds.has(finding.id));
|
||||
const dropped = ordered.filter((finding) => !exportedFindingIds.has(finding.id));
|
||||
|
||||
const sections: string[] = [
|
||||
'# Capella SAST Report',
|
||||
'',
|
||||
`Repository: \`${repoPath}\``,
|
||||
'',
|
||||
`- Exported to SARIF: **${exported.length}**`,
|
||||
`- Not exported to SARIF: **${dropped.length}**`,
|
||||
'',
|
||||
'## Exported findings',
|
||||
'',
|
||||
exported.length ? exported.map(renderFinding).join('\n\n---\n\n') : '_None._',
|
||||
];
|
||||
|
||||
if (dropped.length) {
|
||||
sections.push(
|
||||
'',
|
||||
'## Not exported',
|
||||
'',
|
||||
'These were filtered before SARIF export by status, viability, or code-path rules. Shown for context.',
|
||||
'',
|
||||
dropped.map(renderFinding).join('\n\n---\n\n'),
|
||||
);
|
||||
}
|
||||
|
||||
return `${sections.join('\n')}\n`;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// 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.
|
||||
|
||||
/** Workflow-safe public failure projection for Agentic SAST. */
|
||||
|
||||
export const CAPELLA_SAFE_FAILURE_MESSAGES = Object.freeze({
|
||||
AuthenticationError: 'Provider authentication failed. Verify the configured credential.',
|
||||
ConfigurationError: 'Agentic SAST configuration is invalid.',
|
||||
InvalidInputError: 'Agentic SAST received invalid input.',
|
||||
SastContractError: 'An agentic SAST step returned an unusable result.',
|
||||
AgentExecutionError: 'An agentic SAST step failed.',
|
||||
} as const);
|
||||
|
||||
export type CapellaSafeFailureType = keyof typeof CAPELLA_SAFE_FAILURE_MESSAGES;
|
||||
|
||||
const CAPELLA_TERMINAL_STAGE_LABELS = Object.freeze({
|
||||
architecture: 'architecture',
|
||||
'threat-model': 'threat model',
|
||||
plan: 'planning',
|
||||
research: 'audit wave',
|
||||
dedupe: 'deduplication',
|
||||
review: 'review',
|
||||
critic: 'critic',
|
||||
confirm: 'confirmation',
|
||||
calibrate: 'calibration',
|
||||
export: 'export',
|
||||
workflow: 'orchestration',
|
||||
} as const);
|
||||
|
||||
export function capellaTerminalStageLabel(stage: keyof typeof CAPELLA_TERMINAL_STAGE_LABELS): string {
|
||||
return CAPELLA_TERMINAL_STAGE_LABELS[stage];
|
||||
}
|
||||
|
||||
export function isCapellaTerminalStageLabel(value: string): boolean {
|
||||
return (Object.values(CAPELLA_TERMINAL_STAGE_LABELS) as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export function capellaSafeFailureMessage(type: string | null | undefined): string {
|
||||
if (type !== undefined && type !== null && type in CAPELLA_SAFE_FAILURE_MESSAGES) {
|
||||
return CAPELLA_SAFE_FAILURE_MESSAGES[type as CapellaSafeFailureType];
|
||||
}
|
||||
return CAPELLA_SAFE_FAILURE_MESSAGES.AgentExecutionError;
|
||||
}
|
||||
|
||||
// The two literal strings below are not produced by this module: they are emitted by the parent
|
||||
// pentest pipeline when Capella never got far enough to fail its own way (a scan cancelled before
|
||||
// the child workflow started, or infrastructure that failed before any stage ran). Listing them
|
||||
// here keeps this predicate the single place that recognizes every message the workflow-safe
|
||||
// surface is allowed to show, not just this file's own table.
|
||||
export function isCapellaSafeFailureMessage(message: string): boolean {
|
||||
return (
|
||||
(Object.values(CAPELLA_SAFE_FAILURE_MESSAGES) as readonly string[]).includes(message) ||
|
||||
message === 'Agentic SAST infrastructure failed before producing a usable result.' ||
|
||||
message === 'Agentic SAST had not finished when the scan stopped.'
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
// 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 { readFile } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
import {
|
||||
CAPELLA_SARIF_DRIVER_NAME,
|
||||
CAPELLA_SARIF_DRIVER_VERSION,
|
||||
CAPELLA_SARIF_INFORMATION_URI,
|
||||
CAPELLA_SARIF_SCHEMA,
|
||||
type CapellaSarif,
|
||||
type CapellaSarifLevel,
|
||||
type CapellaSarifResult,
|
||||
type CapellaSarifRule,
|
||||
type CapellaSarifSeverity,
|
||||
validateCapellaSarif,
|
||||
} from '../sarif-profile.js';
|
||||
import type { AgenticSastOmission, AgenticSastReduction, SarifRef } from '../types.js';
|
||||
import { atomicPublishBytes, sha256Bytes, stableJson } from './artifacts.js';
|
||||
import { SastContractError } from './errors.js';
|
||||
import type { CapellaFinding, CapellaSeverity } from './finding-types.js';
|
||||
import { isExcludedCodePath, isNormalizedRepositoryPath, parseCodePath } from './paths.js';
|
||||
import { renderCapellaReport } from './report.js';
|
||||
import type { AtomicPublishOptions } from './types.js';
|
||||
import { isCapellaFinding } from './validation.js';
|
||||
|
||||
export interface CapellaExportResult {
|
||||
readonly sarif: SarifRef;
|
||||
readonly findingCount: number;
|
||||
readonly coverage: 'complete' | 'reduced';
|
||||
readonly warnings: string[];
|
||||
readonly reportPath: string;
|
||||
readonly reduction?: AgenticSastReduction;
|
||||
}
|
||||
|
||||
export interface CapellaExportOptions {
|
||||
readonly artifactRoot: string;
|
||||
readonly repositoryLabel: string;
|
||||
readonly codePathAvoids: readonly string[];
|
||||
readonly publishOptions?: AtomicPublishOptions;
|
||||
readonly cancellationSignal?: AbortSignal;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function throwIfExportCancelled(signal: AbortSignal | undefined): void {
|
||||
if (!signal?.aborted) return;
|
||||
if (signal.reason instanceof Error) throw signal.reason;
|
||||
throw new DOMException('Capella export cancelled.', 'AbortError');
|
||||
}
|
||||
|
||||
export function passesExportGate(finding: CapellaFinding): boolean {
|
||||
return (
|
||||
finding.status === 'VALID' &&
|
||||
(finding.production_viability === 'VIABLE' || finding.production_viability === 'CONDITIONAL_VIABLE')
|
||||
);
|
||||
}
|
||||
|
||||
function compareText(left: string, right: string): number {
|
||||
if (left < right) return -1;
|
||||
if (left > right) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function severityName(severity: CapellaSeverity): CapellaSarifSeverity {
|
||||
switch (severity) {
|
||||
case 'CRITICAL':
|
||||
return 'Critical';
|
||||
case 'HIGH':
|
||||
return 'High';
|
||||
case 'MEDIUM':
|
||||
return 'Medium';
|
||||
case 'LOW':
|
||||
return 'Low';
|
||||
}
|
||||
}
|
||||
|
||||
function severityLevel(severity: CapellaSeverity): CapellaSarifLevel {
|
||||
if (severity === 'CRITICAL' || severity === 'HIGH') return 'error';
|
||||
if (severity === 'MEDIUM') return 'warning';
|
||||
return 'note';
|
||||
}
|
||||
|
||||
function cweHelpUri(cwe: string): string {
|
||||
return `https://cwe.mitre.org/data/definitions/${cwe.slice('CWE-'.length)}.html`;
|
||||
}
|
||||
|
||||
function threadLocationLabel(index: number, locationCount: number): 'Source' | 'Step' | 'Sink' {
|
||||
if (index === locationCount - 1) return 'Sink';
|
||||
if (index === 0) return 'Source';
|
||||
return 'Step';
|
||||
}
|
||||
|
||||
function classifyExportCandidate(
|
||||
value: unknown,
|
||||
): { readonly finding: CapellaFinding } | { readonly omission: AgenticSastOmission } {
|
||||
if (!isCapellaFinding(value)) {
|
||||
return { omission: buildOmission(value, 'invalid_finding_record') };
|
||||
}
|
||||
if (value.code_paths.length === 0) {
|
||||
return { omission: buildOmission(value, 'missing_code_path') };
|
||||
}
|
||||
const codePathsAreValid = value.code_paths.every((entry) => {
|
||||
const parsed = parseCodePath(entry);
|
||||
return parsed !== undefined && isNormalizedRepositoryPath(parsed.file);
|
||||
});
|
||||
if (!codePathsAreValid) {
|
||||
return { omission: buildOmission(value, 'invalid_code_path') };
|
||||
}
|
||||
return { finding: value };
|
||||
}
|
||||
|
||||
function buildOmission(value: unknown, reason: AgenticSastOmission['reason']): AgenticSastOmission {
|
||||
if (!isRecord(value)) return { reason };
|
||||
const findingId = safeFindingId(value.id);
|
||||
const displayName = safeFindingDisplayName(value.cwe, value.title);
|
||||
return {
|
||||
reason,
|
||||
...(findingId !== undefined && { findingId }),
|
||||
...(displayName !== undefined && { displayName }),
|
||||
};
|
||||
}
|
||||
|
||||
function safeFindingId(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string' || !/^[a-z0-9-]{1,256}$/.test(value)) return undefined;
|
||||
return value;
|
||||
}
|
||||
|
||||
function safeFindingDisplayName(cwe: unknown, title: unknown): string | undefined {
|
||||
if (typeof cwe !== 'string' || !/^CWE-\d+$/.test(cwe) || typeof title !== 'string') return undefined;
|
||||
const normalizedTitle = [...title]
|
||||
.map((character) => {
|
||||
const code = character.charCodeAt(0);
|
||||
return code <= 31 || code === 127 ? ' ' : character;
|
||||
})
|
||||
.join('')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
if (normalizedTitle.length === 0) return undefined;
|
||||
const prefix = `${cwe}: `;
|
||||
return `${prefix}${normalizedTitle.slice(0, 160 - prefix.length)}`;
|
||||
}
|
||||
|
||||
function buildRule(finding: CapellaFinding): CapellaSarifRule {
|
||||
const cwe = finding.cwe as `CWE-${number}`;
|
||||
return {
|
||||
id: cwe,
|
||||
name: finding.title,
|
||||
shortDescription: { text: `${finding.cwe}: ${finding.title}` },
|
||||
fullDescription: { text: finding.description },
|
||||
helpUri: cweHelpUri(finding.cwe),
|
||||
properties: { cwe, tags: ['security', 'vulnerability'] },
|
||||
};
|
||||
}
|
||||
|
||||
function buildResult(finding: CapellaFinding): CapellaSarifResult {
|
||||
const primary = parseCodePath(finding.code_paths[0] ?? '');
|
||||
if (!primary || !isNormalizedRepositoryPath(primary.file)) {
|
||||
throw new SastContractError('Capella export received an invalid primary finding location', 'SARIF_LOCATION');
|
||||
}
|
||||
const parsedTrace = finding.code_paths
|
||||
.map(parseCodePath)
|
||||
.filter((step): step is NonNullable<ReturnType<typeof parseCodePath>> => {
|
||||
return step !== undefined && isNormalizedRepositoryPath(step.file);
|
||||
});
|
||||
const sourceToSink = [...parsedTrace].reverse();
|
||||
const threadLocations = sourceToSink.map((step, index) => ({
|
||||
location: {
|
||||
physicalLocation: {
|
||||
artifactLocation: { uri: step.file, uriBaseId: '%SRCROOT%' as const },
|
||||
region: { startLine: step.line },
|
||||
},
|
||||
message: { text: threadLocationLabel(index, sourceToSink.length) },
|
||||
},
|
||||
importance: index === sourceToSink.length - 1 ? ('essential' as const) : ('important' as const),
|
||||
}));
|
||||
const severity = severityName(finding.severity);
|
||||
const cwe = finding.cwe as `CWE-${number}`;
|
||||
return {
|
||||
ruleId: cwe,
|
||||
level: severityLevel(finding.severity),
|
||||
message: { text: finding.title },
|
||||
locations: [
|
||||
{
|
||||
physicalLocation: {
|
||||
artifactLocation: { uri: primary.file, uriBaseId: '%SRCROOT%' },
|
||||
region: { startLine: primary.line },
|
||||
},
|
||||
},
|
||||
],
|
||||
codeFlows: [{ threadFlows: [{ locations: threadLocations }] }],
|
||||
properties: {
|
||||
severity,
|
||||
cwe,
|
||||
status: 'verified',
|
||||
description: finding.impact ? `${finding.description}\n\nImpact: ${finding.impact}` : finding.description,
|
||||
findingSubType: 'AGENT_SAST',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCapellaSarif(findings: readonly CapellaFinding[], repositoryLabel: string): CapellaSarif {
|
||||
const ordered = [...findings].sort((left, right) => compareText(left.id, right.id));
|
||||
const rulesById = new Map<string, CapellaSarifRule>();
|
||||
for (const finding of ordered) {
|
||||
if (!rulesById.has(finding.cwe)) rulesById.set(finding.cwe, buildRule(finding));
|
||||
}
|
||||
const rules = [...rulesById.values()].sort((left, right) => compareText(left.id, right.id));
|
||||
const results = ordered.map(buildResult);
|
||||
return {
|
||||
$schema: CAPELLA_SARIF_SCHEMA,
|
||||
version: '2.1.0',
|
||||
runs: [
|
||||
{
|
||||
tool: {
|
||||
driver: {
|
||||
name: CAPELLA_SARIF_DRIVER_NAME,
|
||||
version: CAPELLA_SARIF_DRIVER_VERSION,
|
||||
informationUri: CAPELLA_SARIF_INFORMATION_URI,
|
||||
rules,
|
||||
},
|
||||
},
|
||||
results,
|
||||
properties: { repository: repositoryLabel, totalFindings: results.length },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the immutable SARIF reference from a prior successful run, if any.
|
||||
*
|
||||
* A missing or unreadable run.json reads as no prior success, and the export
|
||||
* publishes fresh. A record that claims success but carries a bad reference
|
||||
* throws: that is corruption of an immutability promise, not a fresh run.
|
||||
*/
|
||||
async function readSuccessfulSarifRef(artifactRoot: string, expectedPath: string): Promise<SarifRef | undefined> {
|
||||
try {
|
||||
const run = JSON.parse(await readFile(resolve(artifactRoot, 'run.json'), 'utf8')) as Record<string, unknown>;
|
||||
if (run.finalState !== 'succeeded') return undefined;
|
||||
if (!run.sarif || typeof run.sarif !== 'object') {
|
||||
throw new SastContractError('A successful Capella run is missing its SARIF reference', 'SARIF_REFERENCE');
|
||||
}
|
||||
const sarif = run.sarif as Record<string, unknown>;
|
||||
if (sarif.path !== expectedPath || typeof sarif.sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(sarif.sha256)) {
|
||||
throw new SastContractError('A successful Capella run contains an invalid SARIF reference', 'SARIF_REFERENCE');
|
||||
}
|
||||
return { path: sarif.path, sha256: sarif.sha256 };
|
||||
} catch (error) {
|
||||
if (error instanceof SastContractError) throw error;
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Revalidate, filter, report, serialize once, and atomically publish exact SARIF bytes. */
|
||||
export async function exportCapellaFindings(
|
||||
rawFindings: readonly unknown[],
|
||||
options: CapellaExportOptions,
|
||||
): Promise<CapellaExportResult> {
|
||||
throwIfExportCancelled(options.cancellationSignal);
|
||||
|
||||
const warnings: string[] = [];
|
||||
const classified = rawFindings.map(classifyExportCandidate);
|
||||
const validFindings = classified.flatMap((entry) => ('finding' in entry ? [entry.finding] : []));
|
||||
const omissions = classified.flatMap((entry) => ('omission' in entry ? [entry.omission] : []));
|
||||
const invalidCount = omissions.length;
|
||||
if (invalidCount > 0) {
|
||||
const finding = invalidCount === 1 ? 'finding was' : 'findings were';
|
||||
warnings.push(`${invalidCount} agentic SAST ${finding} malformed and left out.`);
|
||||
}
|
||||
const reduction: AgenticSastReduction | undefined =
|
||||
invalidCount > 0
|
||||
? {
|
||||
stage: 'export',
|
||||
reason: 'malformed_findings',
|
||||
omittedCount: invalidCount,
|
||||
consideredCount: rawFindings.length,
|
||||
omissions,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const gated = validFindings.filter(passesExportGate);
|
||||
const exported = gated
|
||||
.filter((finding) => {
|
||||
const primary = parseCodePath(finding.code_paths[0] ?? '');
|
||||
return primary !== undefined && !isExcludedCodePath(primary.file, options.codePathAvoids);
|
||||
})
|
||||
.sort((left, right) => compareText(left.id, right.id));
|
||||
const excludedCount = gated.length - exported.length;
|
||||
if (excludedCount > 0) {
|
||||
warnings.push(`${excludedCount} agentic SAST findings were in paths your config told Shannon to avoid.`);
|
||||
}
|
||||
if (validFindings.length > 0 && exported.length === 0) {
|
||||
warnings.push(
|
||||
'Every agentic SAST finding was excluded, so no static-analysis results reached the pentest. Check the avoid rules in your config file.',
|
||||
);
|
||||
}
|
||||
|
||||
const sarifDocument = buildCapellaSarif(exported, options.repositoryLabel);
|
||||
const validation = validateCapellaSarif(sarifDocument);
|
||||
if (!validation.valid) {
|
||||
throw new SastContractError('Capella SARIF document validation failed', 'SARIF_VALIDATION');
|
||||
}
|
||||
if (sarifDocument.runs[0].properties.totalFindings !== exported.length) {
|
||||
throw new SastContractError('Capella SARIF document count does not match exported findings', 'SARIF_COUNT');
|
||||
}
|
||||
|
||||
const sarifBytes = stableJson(sarifDocument);
|
||||
const digest = sha256Bytes(sarifBytes);
|
||||
const reportPath = resolve(options.artifactRoot, 'report.md');
|
||||
const sarifPath = resolve(options.artifactRoot, 'capella.sarif');
|
||||
|
||||
// Adoption path: once run.json says succeeded, the export is immutable. A
|
||||
// re-driven activity verifies the published bytes still match what this input
|
||||
// would produce and returns the existing reference instead of republishing.
|
||||
const successful = await readSuccessfulSarifRef(options.artifactRoot, sarifPath);
|
||||
if (successful) {
|
||||
let existingBytes: Buffer;
|
||||
try {
|
||||
existingBytes = await readFile(successful.path);
|
||||
await readFile(reportPath);
|
||||
} catch {
|
||||
throw new SastContractError('A successful Capella export is no longer complete and readable', 'SARIF_READ');
|
||||
}
|
||||
if (
|
||||
successful.path !== sarifPath ||
|
||||
successful.sha256 !== sha256Bytes(existingBytes) ||
|
||||
successful.sha256 !== digest
|
||||
) {
|
||||
throw new SastContractError(
|
||||
'A successful Capella SARIF reference is immutable and no longer matches export bytes',
|
||||
'SARIF_IMMUTABLE',
|
||||
);
|
||||
}
|
||||
return {
|
||||
sarif: successful,
|
||||
findingCount: exported.length,
|
||||
// Coverage is 'reduced' only when records were invalid: gate-dropped and
|
||||
// operator-excluded findings are normal outcomes, not lost data.
|
||||
coverage: invalidCount > 0 ? 'reduced' : 'complete',
|
||||
warnings: [...warnings].sort(),
|
||||
reportPath,
|
||||
...(reduction !== undefined && { reduction }),
|
||||
};
|
||||
}
|
||||
|
||||
// Cancellation is re-checked immediately before each publish so an aborted
|
||||
// activity stops without writing new bytes.
|
||||
throwIfExportCancelled(options.cancellationSignal);
|
||||
await atomicPublishBytes(
|
||||
options.artifactRoot,
|
||||
reportPath,
|
||||
renderCapellaReport(validFindings, new Set(exported.map((finding) => finding.id)), options.repositoryLabel),
|
||||
);
|
||||
throwIfExportCancelled(options.cancellationSignal);
|
||||
const publishedDigest = await atomicPublishBytes(
|
||||
options.artifactRoot,
|
||||
sarifPath,
|
||||
sarifBytes,
|
||||
options.publishOptions ?? {},
|
||||
);
|
||||
if (publishedDigest !== digest) {
|
||||
throw new SastContractError('Published Capella SARIF bytes changed before hashing', 'SARIF_DIGEST');
|
||||
}
|
||||
|
||||
return {
|
||||
sarif: { path: sarifPath, sha256: publishedDigest },
|
||||
findingCount: exported.length,
|
||||
coverage: invalidCount > 0 ? 'reduced' : 'complete',
|
||||
warnings: [...warnings].sort(),
|
||||
reportPath,
|
||||
...(reduction !== undefined && { reduction }),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
// 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.
|
||||
|
||||
/**
|
||||
* Structured-output schemas for the Capella stages that return a document rather
|
||||
* than mutate a finding through a collector tool.
|
||||
*
|
||||
* Architecture, threat-model, plan and the triage wave each produce one document
|
||||
* describing their whole phase, so a schema is the right shape; the harness
|
||||
* writes the file from the returned object. Everything a *finding* passes through
|
||||
* goes via a collector tool instead (validation there happens while the agent can
|
||||
* still fix it, which a schema violation at the end of a run cannot).
|
||||
*/
|
||||
|
||||
// === Architecture (Knowledge Base) ===
|
||||
|
||||
export interface KbEntity {
|
||||
/** Filename stem under `kb/entities/` or `kb/vulnerabilities/`, e.g. `auth_module` or `CWE-89`. */
|
||||
name: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface KbResult {
|
||||
architecture: string;
|
||||
entities: KbEntity[];
|
||||
vulnerabilities: KbEntity[];
|
||||
index: string;
|
||||
dependencies: Record<string, string[]>;
|
||||
}
|
||||
|
||||
const KB_ENTITY = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', description: 'Filename stem, e.g. "auth_module" or "CWE-89" (no extension, no path)' },
|
||||
content: { type: 'string', description: 'The Markdown body of the file' },
|
||||
},
|
||||
required: ['name', 'content'],
|
||||
additionalProperties: false,
|
||||
} as const;
|
||||
|
||||
export const ARCHITECTURE_SCHEMA = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
architecture: {
|
||||
type: 'string',
|
||||
description: 'The architecture.md body: data flows, zones, availability requirements',
|
||||
},
|
||||
entities: { type: 'array', items: KB_ENTITY, description: 'One entry per component (kb/entities/<name>.md)' },
|
||||
vulnerabilities: {
|
||||
type: 'array',
|
||||
items: KB_ENTITY,
|
||||
description: 'One entry per bug class (kb/vulnerabilities/<name>.md)',
|
||||
},
|
||||
index: { type: 'string', description: 'The index.md body: a catalog linking every entity and vulnerability file' },
|
||||
dependencies: {
|
||||
type: 'object',
|
||||
description: 'Import/dependency edges: keys are source files, values are the files that import them. {} if none.',
|
||||
additionalProperties: { type: 'array', items: { type: 'string' } },
|
||||
},
|
||||
},
|
||||
required: ['architecture', 'entities', 'vulnerabilities', 'index', 'dependencies'],
|
||||
additionalProperties: false,
|
||||
} as const satisfies Record<string, unknown>;
|
||||
|
||||
// === Threat Model ===
|
||||
|
||||
export interface ThreatModelResult {
|
||||
threatModel: string;
|
||||
intent: 'PRODUCTION' | 'SAMPLE_OR_TEST_ONLY';
|
||||
}
|
||||
|
||||
export const THREAT_MODEL_SCHEMA = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
threatModel: { type: 'string', description: 'The full THREAT_MODEL.md body, including the Deployment Intent line' },
|
||||
intent: {
|
||||
type: 'string',
|
||||
enum: ['PRODUCTION', 'SAMPLE_OR_TEST_ONLY'],
|
||||
description: 'The deployment-intent verdict — exactly one of these two values',
|
||||
},
|
||||
},
|
||||
required: ['threatModel', 'intent'],
|
||||
additionalProperties: false,
|
||||
} as const satisfies Record<string, unknown>;
|
||||
|
||||
// === Plan ===
|
||||
|
||||
export interface Investigation {
|
||||
title: string;
|
||||
target_files: string[];
|
||||
kb_references: string[];
|
||||
question: string;
|
||||
}
|
||||
|
||||
export interface PlanResult {
|
||||
investigations: Investigation[];
|
||||
}
|
||||
|
||||
export const PLAN_SCHEMA = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
investigations: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
title: { type: 'string' },
|
||||
target_files: { type: 'array', items: { type: 'string' }, description: 'Repository-relative files to audit' },
|
||||
kb_references: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'KB files providing context, e.g. entities/auth.md',
|
||||
},
|
||||
question: { type: 'string', description: 'The reviewing prompt for the researcher' },
|
||||
},
|
||||
required: ['title', 'target_files', 'kb_references', 'question'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['investigations'],
|
||||
additionalProperties: false,
|
||||
} as const satisfies Record<string, unknown>;
|
||||
|
||||
// === Triage (research wave 1) ===
|
||||
|
||||
export interface TriageClassification {
|
||||
file: string;
|
||||
potentially_flawed: boolean;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface TriageResult {
|
||||
classifications: TriageClassification[];
|
||||
}
|
||||
|
||||
export const TRIAGE_SCHEMA = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
classifications: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
file: { type: 'string' },
|
||||
potentially_flawed: { type: 'boolean' },
|
||||
reason: { type: 'string' },
|
||||
},
|
||||
required: ['file', 'potentially_flawed', 'reason'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['classifications'],
|
||||
additionalProperties: false,
|
||||
} as const satisfies Record<string, unknown>;
|
||||
@@ -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}`;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
// 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 { readFile } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
import { Type } from 'typebox';
|
||||
import { loadArtifactRef, sha256Bytes, stableJson } from '../artifacts.js';
|
||||
import { CapellaRetryableError } from '../errors.js';
|
||||
import {
|
||||
ARCHITECTURE_TOOLS,
|
||||
buildCodePathScopeSnippet,
|
||||
buildKnowledgeBaseContext,
|
||||
PLAN_TOOLS,
|
||||
THREAT_MODEL_TOOLS,
|
||||
} from '../prompt-context.js';
|
||||
import { createCapellaPromptLoader } from '../prompt-loader.js';
|
||||
import {
|
||||
ARCHITECTURE_SCHEMA,
|
||||
type KbEntity,
|
||||
type KbResult,
|
||||
PLAN_SCHEMA,
|
||||
THREAT_MODEL_SCHEMA,
|
||||
type ThreatModelResult,
|
||||
} from '../schemas.js';
|
||||
import type {
|
||||
ArchitectureValue,
|
||||
CapellaStageInput,
|
||||
CapellaStageRuntime,
|
||||
CompletedStage,
|
||||
PlanStageInput,
|
||||
PlanValue,
|
||||
ThreatModelStageInput,
|
||||
ThreatModelValue,
|
||||
} from '../types.js';
|
||||
import {
|
||||
isArchitectureValue,
|
||||
isPlanValue,
|
||||
isThreatModelResult,
|
||||
isThreatModelValue,
|
||||
salvageKbResult,
|
||||
salvagePlanResult,
|
||||
} from '../validation.js';
|
||||
import {
|
||||
artifactLineage,
|
||||
buildStageFingerprint,
|
||||
completeStage,
|
||||
maybeReuseStage,
|
||||
publishTextAsset,
|
||||
resolveStageIdentity,
|
||||
} from './shared.js';
|
||||
|
||||
// Per-session caps on model turns, sized to how much repository exploration each
|
||||
// stage legitimately needs before its structured output is due.
|
||||
const ARCHITECTURE_MAX_TURNS = 400;
|
||||
const THREAT_MODEL_MAX_TURNS = 150;
|
||||
const PLAN_MAX_TURNS = 150;
|
||||
const KB_CONTENT_HASH_LENGTH = 12;
|
||||
|
||||
function compareText(left: string, right: string): number {
|
||||
if (left < right) return -1;
|
||||
if (left > right) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function slugName(name: string): string {
|
||||
return (
|
||||
name
|
||||
.replace(/\.md$/i, '')
|
||||
.replace(/[^A-Za-z0-9._-]+/g, '-')
|
||||
.replace(/-{2,}/g, '-')
|
||||
.replace(/^-|-$/g, '') || 'entity'
|
||||
);
|
||||
}
|
||||
|
||||
async function publishKnowledgeBase(input: CapellaStageInput, knowledgeBase: KbResult): Promise<void> {
|
||||
await publishTextAsset(input, 'kb/architecture.md', knowledgeBase.architecture);
|
||||
await publishTextAsset(input, 'kb/index.md', knowledgeBase.index);
|
||||
await publishTextAsset(input, 'kb/dependencies.json', stableJson(knowledgeBase.dependencies));
|
||||
|
||||
async function publishEntities(subdirectory: string, entities: readonly KbEntity[]): Promise<void> {
|
||||
for (const asset of knowledgeBaseEntityAssets(subdirectory, entities).assets) {
|
||||
await publishTextAsset(input, asset.relativePath, asset.entity.content);
|
||||
}
|
||||
}
|
||||
|
||||
await publishEntities('entities', knowledgeBase.entities);
|
||||
await publishEntities('vulnerabilities', knowledgeBase.vulnerabilities);
|
||||
}
|
||||
|
||||
interface KnowledgeBaseEntityAsset {
|
||||
readonly entity: KbEntity;
|
||||
readonly relativePath: string;
|
||||
}
|
||||
|
||||
interface KnowledgeBaseEntityAssets {
|
||||
readonly assets: KnowledgeBaseEntityAsset[];
|
||||
readonly uniqueEntities: KbEntity[];
|
||||
readonly duplicateCount: number;
|
||||
}
|
||||
|
||||
/** Deterministic collision-safe KB names, scoped independently to each subdirectory. */
|
||||
export function knowledgeBaseEntityAssets(
|
||||
subdirectory: string,
|
||||
entities: readonly KbEntity[],
|
||||
): KnowledgeBaseEntityAssets {
|
||||
const groups = new Map<string, Array<{ entity: KbEntity; contentHash: string }>>();
|
||||
for (const entity of entities) {
|
||||
const baseSlug = slugName(entity.name);
|
||||
const group = groups.get(baseSlug) ?? [];
|
||||
group.push({ entity, contentHash: sha256Bytes(entity.content) });
|
||||
groups.set(baseSlug, group);
|
||||
}
|
||||
|
||||
const assets: KnowledgeBaseEntityAsset[] = [];
|
||||
const uniqueEntities: KbEntity[] = [];
|
||||
let duplicateCount = 0;
|
||||
for (const baseSlug of [...groups.keys()].sort(compareText)) {
|
||||
const group = groups.get(baseSlug) ?? [];
|
||||
group.sort(
|
||||
(left, right) =>
|
||||
compareText(left.entity.name, right.entity.name) ||
|
||||
compareText(left.contentHash, right.contentHash) ||
|
||||
compareText(left.entity.content, right.entity.content),
|
||||
);
|
||||
const unique = group.filter((entry, index) => {
|
||||
const previous = group[index - 1];
|
||||
const duplicate =
|
||||
previous !== undefined &&
|
||||
previous.entity.name === entry.entity.name &&
|
||||
previous.entity.content === entry.entity.content;
|
||||
if (duplicate) duplicateCount += 1;
|
||||
return !duplicate;
|
||||
});
|
||||
const suffixCounts = new Map<string, number>();
|
||||
for (const entry of unique) {
|
||||
const shortHash = entry.contentHash.slice(0, KB_CONTENT_HASH_LENGTH);
|
||||
suffixCounts.set(shortHash, (suffixCounts.get(shortHash) ?? 0) + 1);
|
||||
}
|
||||
const suffixOrdinals = new Map<string, number>();
|
||||
for (const entry of unique) {
|
||||
const shortHash = entry.contentHash.slice(0, KB_CONTENT_HASH_LENGTH);
|
||||
let filename = baseSlug;
|
||||
if (unique.length > 1) {
|
||||
filename = `${baseSlug}-${shortHash}`;
|
||||
// A truncated hash prefix can still collide between two genuinely distinct contents; an
|
||||
// ordinal suffix breaks that tie instead of one entity silently overwriting the other's file.
|
||||
if ((suffixCounts.get(shortHash) ?? 0) > 1) {
|
||||
const ordinal = (suffixOrdinals.get(shortHash) ?? 0) + 1;
|
||||
suffixOrdinals.set(shortHash, ordinal);
|
||||
filename = `${filename}-${String(ordinal)}`;
|
||||
}
|
||||
}
|
||||
uniqueEntities.push(entry.entity);
|
||||
assets.push({ entity: entry.entity, relativePath: `kb/${subdirectory}/${filename}.md` });
|
||||
}
|
||||
}
|
||||
return { assets, uniqueEntities, duplicateCount };
|
||||
}
|
||||
|
||||
export async function runArchitectureStage(
|
||||
input: CapellaStageInput,
|
||||
runtime: CapellaStageRuntime,
|
||||
): Promise<CompletedStage<ArchitectureValue>> {
|
||||
const startedAt = Date.now();
|
||||
const identity = await resolveStageIdentity(input);
|
||||
const loader = createCapellaPromptLoader(input.promptDir);
|
||||
const prompt = loader.render(
|
||||
'sast.capella.architecture',
|
||||
{
|
||||
...ARCHITECTURE_TOOLS,
|
||||
LANGUAGE_CONTEXT: '',
|
||||
BOUNDARY_CONTEXT: buildCodePathScopeSnippet(input.codePathFocus, input.codePathAvoids),
|
||||
},
|
||||
{ pipelineTestingMode: input.pipelineTestingMode },
|
||||
);
|
||||
const fingerprint = buildStageFingerprint('architecture', identity, prompt, {
|
||||
schema: ARCHITECTURE_SCHEMA,
|
||||
});
|
||||
const reused = await maybeReuseStage(input, 'architecture', fingerprint, isArchitectureValue, identity, startedAt);
|
||||
if (reused) return reused;
|
||||
|
||||
const response = await runtime.executor.run<unknown>({
|
||||
stage: 'architecture',
|
||||
role: 'large',
|
||||
cwd: input.repoPath,
|
||||
systemPrompt:
|
||||
'You are the knowledge-base synthesizer of a security audit. Describe the security-relevant architecture ' +
|
||||
'of the codebase and return the complete knowledge base as structured output.',
|
||||
userPrompt: prompt,
|
||||
maxTurns: ARCHITECTURE_MAX_TURNS,
|
||||
timeoutMs: input.timeoutMs,
|
||||
tools: runtime.repositoryTools,
|
||||
outputSchema: Type.Unsafe(ARCHITECTURE_SCHEMA),
|
||||
signal: runtime.signal,
|
||||
});
|
||||
const salvaged = salvageKbResult(response.output);
|
||||
if (!salvaged) {
|
||||
throw new CapellaRetryableError('Capella architecture output failed core validation', 'ARCHITECTURE_SCHEMA');
|
||||
}
|
||||
|
||||
const entityAssets = knowledgeBaseEntityAssets('entities', salvaged.value.entities);
|
||||
const vulnerabilityAssets = knowledgeBaseEntityAssets('vulnerabilities', salvaged.value.vulnerabilities);
|
||||
const omittedEntityCount =
|
||||
salvaged.omittedEntityCount + entityAssets.duplicateCount + vulnerabilityAssets.duplicateCount;
|
||||
const knowledgeBase: KbResult = {
|
||||
...salvaged.value,
|
||||
entities: entityAssets.uniqueEntities,
|
||||
vulnerabilities: vulnerabilityAssets.uniqueEntities,
|
||||
};
|
||||
const reduced = omittedEntityCount + salvaged.omittedDependencyCount > 0;
|
||||
|
||||
const value: ArchitectureValue = {
|
||||
knowledgeBase,
|
||||
componentCount: knowledgeBase.entities.length,
|
||||
...(reduced && {
|
||||
reduction: {
|
||||
stage: 'architecture',
|
||||
reason: 'invalid_architecture_items',
|
||||
entityCount: salvaged.consideredEntityCount,
|
||||
omittedEntityCount,
|
||||
dependencyCount: salvaged.consideredDependencyCount,
|
||||
omittedDependencyCount: salvaged.omittedDependencyCount,
|
||||
},
|
||||
}),
|
||||
};
|
||||
await publishKnowledgeBase(input, knowledgeBase);
|
||||
return completeStage(input, 'architecture', fingerprint, response.usage, value, identity, startedAt);
|
||||
}
|
||||
|
||||
export async function runThreatModelStage(
|
||||
input: ThreatModelStageInput,
|
||||
runtime: CapellaStageRuntime,
|
||||
): Promise<CompletedStage<ThreatModelValue>> {
|
||||
const startedAt = Date.now();
|
||||
const architecture = await loadArtifactRef(
|
||||
input.artifactRoot,
|
||||
input.architectureArtifact,
|
||||
'architecture',
|
||||
isArchitectureValue,
|
||||
);
|
||||
const identity = await resolveStageIdentity(input);
|
||||
const loader = createCapellaPromptLoader(input.promptDir);
|
||||
// The prompt template expects a knowledge-base directory; the KB is inlined below
|
||||
// the prompt instead, so KB_DIR redirects the model to that inline context.
|
||||
const prompt = `${loader.render(
|
||||
'sast.capella.threat_model',
|
||||
{ ...THREAT_MODEL_TOOLS, KB_DIR: 'the host-provided context below' },
|
||||
{ pipelineTestingMode: input.pipelineTestingMode },
|
||||
)}\n\n${buildKnowledgeBaseContext(architecture.value.knowledgeBase)}`;
|
||||
const fingerprint = buildStageFingerprint('threat-model', identity, prompt, {
|
||||
architecture: artifactLineage(input.architectureArtifact),
|
||||
schema: THREAT_MODEL_SCHEMA,
|
||||
});
|
||||
const reused = await maybeReuseStage(
|
||||
input,
|
||||
'threat-model',
|
||||
fingerprint,
|
||||
isThreatModelValue,
|
||||
identity,
|
||||
startedAt,
|
||||
// Reuse is valid only while the published THREAT_MODEL.md still matches the
|
||||
// artifact byte for byte; the fingerprint cannot see edits to the published asset.
|
||||
async (value) => {
|
||||
const expectedPath = resolve(input.artifactRoot, 'kb', 'THREAT_MODEL.md');
|
||||
if (value.threatModelPath !== expectedPath) return false;
|
||||
try {
|
||||
return (await readFile(expectedPath, 'utf8')) === value.threatModel;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
);
|
||||
if (reused) return reused;
|
||||
|
||||
const response = await runtime.executor.run<ThreatModelResult>({
|
||||
stage: 'threat-model',
|
||||
role: 'medium',
|
||||
cwd: input.repoPath,
|
||||
systemPrompt:
|
||||
'You are the security architect of a security audit. Synthesize the threat model from the supplied knowledge ' +
|
||||
'base and return the deployment-intent verdict as its own field.',
|
||||
userPrompt: prompt,
|
||||
maxTurns: THREAT_MODEL_MAX_TURNS,
|
||||
timeoutMs: input.timeoutMs,
|
||||
tools: runtime.repositoryTools,
|
||||
outputSchema: Type.Unsafe(THREAT_MODEL_SCHEMA),
|
||||
signal: runtime.signal,
|
||||
});
|
||||
if (!isThreatModelResult(response.output)) {
|
||||
throw new CapellaRetryableError(
|
||||
'Capella threat-model output failed schema or intent validation',
|
||||
'THREAT_MODEL_SCHEMA',
|
||||
);
|
||||
}
|
||||
const threatModelPath = resolve(input.artifactRoot, 'kb', 'THREAT_MODEL.md');
|
||||
await publishTextAsset(input, 'kb/THREAT_MODEL.md', response.output.threatModel);
|
||||
const value: ThreatModelValue = { ...response.output, threatModelPath };
|
||||
return completeStage(input, 'threat-model', fingerprint, response.usage, value, identity, startedAt);
|
||||
}
|
||||
|
||||
export async function runPlanStage(
|
||||
input: PlanStageInput,
|
||||
runtime: CapellaStageRuntime,
|
||||
): Promise<CompletedStage<PlanValue>> {
|
||||
const startedAt = Date.now();
|
||||
const architecture = await loadArtifactRef(
|
||||
input.artifactRoot,
|
||||
input.architectureArtifact,
|
||||
'architecture',
|
||||
isArchitectureValue,
|
||||
);
|
||||
const threatModel = await loadArtifactRef(
|
||||
input.artifactRoot,
|
||||
input.threatModelArtifact,
|
||||
'threat-model',
|
||||
isThreatModelValue,
|
||||
);
|
||||
const identity = await resolveStageIdentity(input);
|
||||
const loader = createCapellaPromptLoader(input.promptDir);
|
||||
const knowledgeBase = {
|
||||
...architecture.value.knowledgeBase,
|
||||
threatModel: threatModel.value.threatModel,
|
||||
intent: threatModel.value.intent,
|
||||
};
|
||||
const prompt = `${loader.render(
|
||||
'sast.capella.plan',
|
||||
{
|
||||
...PLAN_TOOLS,
|
||||
KB_DIR: 'the host-provided context below',
|
||||
LANGUAGE_CONTEXT: '',
|
||||
BOUNDARY_CONTEXT: buildCodePathScopeSnippet(input.codePathFocus, input.codePathAvoids),
|
||||
},
|
||||
{ pipelineTestingMode: input.pipelineTestingMode },
|
||||
)}\n\n${buildKnowledgeBaseContext(architecture.value.knowledgeBase)}\n\n<capella_threat_model>\n${threatModel.value.threatModel}\n</capella_threat_model>`;
|
||||
const fingerprint = buildStageFingerprint('plan', identity, prompt, {
|
||||
architecture: artifactLineage(input.architectureArtifact),
|
||||
threatModel: artifactLineage(input.threatModelArtifact),
|
||||
schema: PLAN_SCHEMA,
|
||||
knowledgeBaseDigest: stableJson(knowledgeBase),
|
||||
});
|
||||
const reused = await maybeReuseStage(input, 'plan', fingerprint, isPlanValue, identity, startedAt);
|
||||
if (reused) return reused;
|
||||
|
||||
const response = await runtime.executor.run<unknown>({
|
||||
stage: 'plan',
|
||||
role: 'medium',
|
||||
cwd: input.repoPath,
|
||||
systemPrompt:
|
||||
'You are the strategist of a security audit. Produce an adaptive review roadmap that covers the production ' +
|
||||
'code and return it as structured output.',
|
||||
userPrompt: prompt,
|
||||
maxTurns: PLAN_MAX_TURNS,
|
||||
timeoutMs: input.timeoutMs,
|
||||
tools: runtime.repositoryTools,
|
||||
outputSchema: Type.Unsafe(PLAN_SCHEMA),
|
||||
signal: runtime.signal,
|
||||
});
|
||||
const salvaged = salvagePlanResult(response.output);
|
||||
if (!salvaged || salvaged.value.investigations.length === 0) {
|
||||
throw new CapellaRetryableError('Capella plan output contained no usable investigations', 'PLAN_SCHEMA');
|
||||
}
|
||||
const value: PlanValue = {
|
||||
investigations: [...salvaged.value.investigations],
|
||||
investigationCount: salvaged.value.investigations.length,
|
||||
...(salvaged.omittedCount > 0 && {
|
||||
reduction: {
|
||||
stage: 'plan',
|
||||
reason: 'invalid_investigations',
|
||||
consideredCount: salvaged.consideredCount,
|
||||
usableCount: salvaged.value.investigations.length,
|
||||
omittedCount: salvaged.omittedCount,
|
||||
},
|
||||
}),
|
||||
};
|
||||
await publishTextAsset(input, 'plan.json', stableJson(salvaged.value));
|
||||
return completeStage(input, 'plan', fingerprint, response.usage, value, identity, startedAt);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
// 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 { readFile } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
import type { AgenticSastReduction } from '../../types.js';
|
||||
import {
|
||||
loadArtifactRef,
|
||||
loadCompletedArtifact,
|
||||
recordStageCompletion,
|
||||
sha256Bytes,
|
||||
stageArtifactPath,
|
||||
} from '../artifacts.js';
|
||||
import { SastContractError } from '../errors.js';
|
||||
import { exportCapellaFindings } from '../sarif-exporter.js';
|
||||
import { type CompletedStage, type ExportStageInput, type ExportValue, ZERO_CAPELLA_USAGE } from '../types.js';
|
||||
import { isExportValue, isRawFindingSetValue } from '../validation.js';
|
||||
import { artifactLineage, buildStageFingerprint, completeStage, resolveStageIdentity } from './shared.js';
|
||||
|
||||
/**
|
||||
* A cached export envelope proves only that an export once completed. The SARIF and
|
||||
* report files live outside that envelope, so their presence and the SARIF digest are
|
||||
* re-verified before the cache is adopted; adopting a gutted artifact root would
|
||||
* report success with nothing on disk.
|
||||
*/
|
||||
async function verifyPublishedExport(input: ExportStageInput, value: ExportValue): Promise<void> {
|
||||
const expectedSarifPath = resolve(input.artifactRoot, 'capella.sarif');
|
||||
const expectedReportPath = resolve(input.artifactRoot, 'report.md');
|
||||
if (value.sarif.path !== expectedSarifPath || value.reportPath !== expectedReportPath) {
|
||||
throw new SastContractError('Cached Capella export references an unexpected path', 'SARIF_REFERENCE');
|
||||
}
|
||||
|
||||
let bytes: Buffer;
|
||||
try {
|
||||
bytes = await readFile(expectedSarifPath);
|
||||
await readFile(expectedReportPath);
|
||||
} catch {
|
||||
throw new SastContractError('Cached Capella export is incomplete', 'SARIF_READ');
|
||||
}
|
||||
if (sha256Bytes(bytes) !== value.sarif.sha256) {
|
||||
throw new SastContractError('Cached Capella SARIF digest mismatch', 'SARIF_DIGEST');
|
||||
}
|
||||
}
|
||||
|
||||
// The findings source is all-or-nothing: an export with no source is the legitimate
|
||||
// short circuit for a run with nothing to report, while a half-specified source is
|
||||
// always a caller bug.
|
||||
function assertExportSource(input: ExportStageInput): void {
|
||||
const hasArtifact = input.findingsArtifact !== undefined;
|
||||
const hasStage = input.findingsStage !== undefined;
|
||||
if (hasArtifact !== hasStage) {
|
||||
throw new SastContractError(
|
||||
'Capella export requires both a finding artifact and its source stage',
|
||||
'EXPORT_SOURCE',
|
||||
);
|
||||
}
|
||||
const hasFallbackReduction = input.fallbackReduction !== undefined;
|
||||
const hasFallbackFailure = input.fallbackFailure !== undefined;
|
||||
if (hasFallbackReduction !== hasFallbackFailure || input.fallbackReduction?.stage !== input.fallbackFailure?.stage) {
|
||||
throw new SastContractError(
|
||||
'Capella fallback export requires one matching reduction and original failure',
|
||||
'EXPORT_FALLBACK',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function throwIfCancelled(signal: AbortSignal | undefined): void {
|
||||
if (!signal?.aborted) return;
|
||||
// Rethrowing the signal's own reason keeps a Temporal CancelledFailure's identity
|
||||
// intact through this boundary instead of degrading it into a generic abort.
|
||||
if (signal.reason instanceof Error) throw signal.reason;
|
||||
throw new DOMException('Capella export cancelled.', 'AbortError');
|
||||
}
|
||||
|
||||
function exportCompletionReductions(
|
||||
value: ExportValue,
|
||||
fallbackReduction: ExportStageInput['fallbackReduction'],
|
||||
): readonly AgenticSastReduction[] {
|
||||
const reductions: AgenticSastReduction[] = [];
|
||||
if (value.reduction !== undefined) reductions.push(value.reduction);
|
||||
reductions.push(...fallbackCompletionReductions(fallbackReduction));
|
||||
return reductions;
|
||||
}
|
||||
|
||||
function fallbackCompletionReductions(
|
||||
fallbackReduction: ExportStageInput['fallbackReduction'],
|
||||
): readonly AgenticSastReduction[] {
|
||||
return fallbackReduction === undefined ? [] : [fallbackReduction];
|
||||
}
|
||||
|
||||
/** Export a prior finding set, or a valid empty set for a workflow short circuit. */
|
||||
export async function runExportStage(
|
||||
input: ExportStageInput,
|
||||
cancellationSignal?: AbortSignal,
|
||||
): Promise<CompletedStage<ExportValue>> {
|
||||
const startedAt = Date.now();
|
||||
throwIfCancelled(cancellationSignal);
|
||||
assertExportSource(input);
|
||||
const identity = await resolveStageIdentity(input);
|
||||
const sourceLineage = input.findingsArtifact ? artifactLineage(input.findingsArtifact) : null;
|
||||
const fingerprint = buildStageFingerprint('export', identity, 'deterministic Capella SARIF export', {
|
||||
sourceStage: input.findingsStage ?? null,
|
||||
findings: sourceLineage,
|
||||
repositoryLabel: input.repositoryLabel,
|
||||
});
|
||||
|
||||
const completedPath = stageArtifactPath(input.artifactRoot, 'export');
|
||||
const cached = await loadCompletedArtifact(input.artifactRoot, completedPath, 'export', fingerprint, isExportValue);
|
||||
if (cached) {
|
||||
await verifyPublishedExport(input, cached.value);
|
||||
// Last checkpoint before the durable run-record write; a cancelled scan must not
|
||||
// re-record completion.
|
||||
throwIfCancelled(cancellationSignal);
|
||||
await recordStageCompletion(
|
||||
input,
|
||||
identity.runInputFingerprint,
|
||||
'export',
|
||||
cached.usage,
|
||||
cached.value.warnings,
|
||||
cached.value.sarif,
|
||||
exportCompletionReductions(cached.value, input.fallbackReduction),
|
||||
);
|
||||
return {
|
||||
status: 'completed',
|
||||
durationMs: Date.now() - startedAt,
|
||||
reused: true,
|
||||
usage: cached.usage,
|
||||
artifact: cached.ref,
|
||||
value: cached.value,
|
||||
};
|
||||
}
|
||||
|
||||
let findings: readonly unknown[] = [];
|
||||
if (input.findingsArtifact && input.findingsStage) {
|
||||
const source = await loadArtifactRef(
|
||||
input.artifactRoot,
|
||||
input.findingsArtifact,
|
||||
input.findingsStage,
|
||||
isRawFindingSetValue,
|
||||
);
|
||||
findings = source.value.findings;
|
||||
}
|
||||
|
||||
const exported = await exportCapellaFindings(findings, {
|
||||
artifactRoot: input.artifactRoot,
|
||||
repositoryLabel: input.repositoryLabel,
|
||||
codePathAvoids: input.codePathAvoids,
|
||||
...(cancellationSignal && { cancellationSignal }),
|
||||
});
|
||||
// The exporter has written capella.sarif, but completion is not yet recorded.
|
||||
// Cancelling here leaves a reusable artifact without marking the stage complete.
|
||||
throwIfCancelled(cancellationSignal);
|
||||
const value: ExportValue = {
|
||||
sarif: exported.sarif,
|
||||
findingCount: exported.findingCount,
|
||||
coverage: exported.coverage,
|
||||
warnings: [...exported.warnings],
|
||||
reportPath: exported.reportPath,
|
||||
...(exported.reduction !== undefined && { reduction: exported.reduction }),
|
||||
};
|
||||
const completed = await completeStage(
|
||||
input,
|
||||
'export',
|
||||
fingerprint,
|
||||
ZERO_CAPELLA_USAGE,
|
||||
value,
|
||||
identity,
|
||||
startedAt,
|
||||
value.warnings,
|
||||
value.sarif,
|
||||
fallbackCompletionReductions(input.fallbackReduction),
|
||||
);
|
||||
return completed;
|
||||
}
|
||||
@@ -0,0 +1,548 @@
|
||||
// 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 { resolve } from 'node:path';
|
||||
import { Type } from 'typebox';
|
||||
import type { AgenticSastResearchReduction } from '../../types.js';
|
||||
import {
|
||||
addUsage,
|
||||
buildFingerprint,
|
||||
loadArtifactRef,
|
||||
loadCompletedArtifact,
|
||||
publishCheckpointArtifact,
|
||||
stableJson,
|
||||
} from '../artifacts.js';
|
||||
import { createFindingCollector } from '../collectors.js';
|
||||
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,
|
||||
type CapellaStageRuntime,
|
||||
type CompletedStage,
|
||||
type ResearchAuditCoverage,
|
||||
type ResearchCoverage,
|
||||
type ResearchStageInput,
|
||||
type ResearchValue,
|
||||
ZERO_CAPELLA_USAGE,
|
||||
} from '../types.js';
|
||||
import { isArchitectureValue, isFindingSetValue, isPlanValue, isResearchValue, isTriageResult } from '../validation.js';
|
||||
import {
|
||||
artifactLineage,
|
||||
completeStage,
|
||||
maybeReuseStage,
|
||||
publishRawFindingAssets,
|
||||
resolveStageIdentity,
|
||||
runCollectorSession,
|
||||
withTemporaryFindings,
|
||||
} from './shared.js';
|
||||
|
||||
const AUDIT_MAX_TURNS = 200;
|
||||
const TRIAGE_MAX_TURNS = 100;
|
||||
const TRIAGE_REPAIR_POLICY_VERSION = 1;
|
||||
|
||||
interface TriageCheckpoint {
|
||||
readonly batchId: string;
|
||||
readonly files: string[];
|
||||
readonly classifications: TriageResult['classifications'];
|
||||
}
|
||||
|
||||
interface AuditCheckpoint {
|
||||
readonly investigationId: string;
|
||||
readonly title: string;
|
||||
readonly findings: CapellaFinding[];
|
||||
readonly salvagedTurnLimit: boolean;
|
||||
}
|
||||
|
||||
interface ResearchConcurrency {
|
||||
readonly triage: number;
|
||||
readonly audit: number;
|
||||
}
|
||||
|
||||
interface PoolItemResult<T> {
|
||||
readonly value: T;
|
||||
readonly reused: boolean;
|
||||
}
|
||||
|
||||
interface PoolSuccess<T> {
|
||||
readonly status: 'succeeded';
|
||||
readonly value: T;
|
||||
}
|
||||
|
||||
interface PoolFailure {
|
||||
readonly status: 'failed';
|
||||
readonly error: unknown;
|
||||
}
|
||||
|
||||
type PoolOutcome<T> = PoolSuccess<T> | PoolFailure;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isTriageCheckpoint(value: unknown): value is TriageCheckpoint {
|
||||
if (
|
||||
!isRecord(value) ||
|
||||
typeof value.batchId !== 'string' ||
|
||||
!Array.isArray(value.files) ||
|
||||
!value.files.every((file) => typeof file === 'string') ||
|
||||
!isTriageResult({ classifications: value.classifications })
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const classifications = value.classifications as TriageResult['classifications'];
|
||||
const assigned = new Set(value.files as string[]);
|
||||
const classified = classifications.map((classification) => classification.file);
|
||||
// Assigned files are unique, and every stored classification names a distinct assigned path.
|
||||
// The set may be incomplete (fewer classifications than assigned) but is never inflated by a
|
||||
// duplicate path or an unexpected path, so reusing it cannot overstate coverage.
|
||||
return (
|
||||
assigned.size === value.files.length &&
|
||||
new Set(classified).size === classified.length &&
|
||||
classified.every((file) => assigned.has(file))
|
||||
);
|
||||
}
|
||||
|
||||
function isAuditCheckpoint(value: unknown): value is AuditCheckpoint {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
/^[0-9a-f]{20}$/.test(String(value.investigationId)) &&
|
||||
typeof value.title === 'string' &&
|
||||
value.title.length > 0 &&
|
||||
typeof value.salvagedTurnLimit === 'boolean' &&
|
||||
isFindingSetValue({ findings: value.findings })
|
||||
);
|
||||
}
|
||||
|
||||
function compareText(left: string, right: string): number {
|
||||
if (left < right) return -1;
|
||||
if (left > right) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function batchFiles(files: readonly string[]): string[][] {
|
||||
if (files.length === 0) return [];
|
||||
// At most one batch per triage worker. Batch membership feeds the checkpoint
|
||||
// fingerprints, so for a given file set and concurrency the batches are stable.
|
||||
const size = Math.max(1, Math.ceil(files.length / CAPELLA_TRIAGE_CONCURRENCY));
|
||||
const batches: string[][] = [];
|
||||
for (let index = 0; index < files.length; index += size) batches.push(files.slice(index, index + size));
|
||||
return batches;
|
||||
}
|
||||
|
||||
function missingAssignedFiles(
|
||||
assignedFiles: readonly string[],
|
||||
classifications: TriageResult['classifications'],
|
||||
): string[] {
|
||||
const classified = new Set(classifications.map((classification) => classification.file));
|
||||
return assignedFiles.filter((file) => !classified.has(file));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fingerprint for the research stage. Concurrency is a real input: triage batch
|
||||
* membership derives from it, so a different concurrency yields different checkpoint
|
||||
* fingerprints and must invalidate the stage artifact rather than half-reuse it.
|
||||
*/
|
||||
export function buildResearchFingerprint(
|
||||
runInputFingerprint: string,
|
||||
renderedPromptsSha256: string,
|
||||
architectureLineage: Record<string, string>,
|
||||
planLineage: Record<string, string>,
|
||||
concurrency: ResearchConcurrency = {
|
||||
triage: CAPELLA_TRIAGE_CONCURRENCY,
|
||||
audit: CAPELLA_AUDIT_CONCURRENCY,
|
||||
},
|
||||
): string {
|
||||
return buildFingerprint({
|
||||
stage: 'research',
|
||||
runInputFingerprint,
|
||||
renderedPromptsSha256,
|
||||
architecture: architectureLineage,
|
||||
plan: planLineage,
|
||||
concurrency,
|
||||
triageRepairPolicyVersion: TRIAGE_REPAIR_POLICY_VERSION,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Settle every unit and preserve input order. Callers decide which typed failures
|
||||
* are tolerable at their own stage boundary.
|
||||
*/
|
||||
export async function runSettledPool<T, R>(
|
||||
items: readonly T[],
|
||||
concurrency: number,
|
||||
run: (item: T, index: number) => Promise<R>,
|
||||
): Promise<Array<PoolOutcome<R>>> {
|
||||
const results: Array<PoolOutcome<R>> = [];
|
||||
let cursor = 0;
|
||||
const workerCount = Math.min(concurrency, items.length);
|
||||
const workers = Array.from({ length: workerCount }, async () => {
|
||||
while (true) {
|
||||
const index = cursor;
|
||||
cursor += 1;
|
||||
const item = items[index];
|
||||
if (item === undefined) return;
|
||||
try {
|
||||
results[index] = { status: 'succeeded', value: await run(item, index) };
|
||||
} catch (error) {
|
||||
results[index] = { status: 'failed', error };
|
||||
}
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
return results;
|
||||
}
|
||||
|
||||
function successfulPoolValues<T>(outcomes: readonly PoolOutcome<T>[]): T[] {
|
||||
return outcomes.flatMap((outcome) => (outcome.status === 'succeeded' ? [outcome.value] : []));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce a triage batch's raw classifications to the usable set: one classification per assigned
|
||||
* path, in first-seen order. Unexpected paths and duplicate classifications are dropped, so they
|
||||
* can never inflate coverage. A schema-valid batch that omits some assigned files yields fewer
|
||||
* usable classifications rather than a failure.
|
||||
*/
|
||||
export function usableClassifications(
|
||||
assignedFiles: readonly string[],
|
||||
classifications: TriageResult['classifications'],
|
||||
): TriageResult['classifications'] {
|
||||
const assigned = new Set(assignedFiles);
|
||||
const seen = new Set<string>();
|
||||
const usable: TriageResult['classifications'] = [];
|
||||
for (const classification of classifications) {
|
||||
if (!assigned.has(classification.file) || seen.has(classification.file)) continue;
|
||||
seen.add(classification.file);
|
||||
usable.push(classification);
|
||||
}
|
||||
return usable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the deterministic triage-coverage result once, from the exact assigned file set and the
|
||||
* usable classifications each batch produced. `missingFiles` (sorted) is the private evidence of
|
||||
* which assigned paths went unclassified.
|
||||
*/
|
||||
export function computeTriageCoverage(checkpoints: readonly TriageCheckpoint[]): ResearchCoverage {
|
||||
const consideredFiles = new Set<string>();
|
||||
const classifiedFiles = new Set<string>();
|
||||
let affectedBatchCount = 0;
|
||||
for (const checkpoint of checkpoints) {
|
||||
for (const file of checkpoint.files) consideredFiles.add(file);
|
||||
for (const classification of checkpoint.classifications) classifiedFiles.add(classification.file);
|
||||
if (checkpoint.classifications.length < checkpoint.files.length) affectedBatchCount += 1;
|
||||
}
|
||||
const consideredCount = consideredFiles.size;
|
||||
const classifiedCount = classifiedFiles.size;
|
||||
return {
|
||||
consideredCount,
|
||||
classifiedCount,
|
||||
omittedCount: consideredCount - classifiedCount,
|
||||
affectedBatchCount,
|
||||
missingFiles: [...consideredFiles].filter((file) => !classifiedFiles.has(file)).sort(compareText),
|
||||
};
|
||||
}
|
||||
|
||||
/** Counts-only aggregate research reduction; carries no path, id, or model text. */
|
||||
export function buildResearchReduction(
|
||||
triage: ResearchCoverage,
|
||||
audit: ResearchAuditCoverage,
|
||||
): AgenticSastResearchReduction {
|
||||
return {
|
||||
stage: 'research',
|
||||
reason: 'incomplete_research',
|
||||
triageConsideredCount: triage.consideredCount,
|
||||
triageClassifiedCount: triage.classifiedCount,
|
||||
triageOmittedCount: triage.omittedCount,
|
||||
affectedTriageBatchCount: triage.affectedBatchCount,
|
||||
auditUnitCount: audit.consideredCount,
|
||||
salvagedAuditSessionCount: audit.salvagedSessionCount,
|
||||
};
|
||||
}
|
||||
|
||||
function investigationId(investigation: Investigation): string {
|
||||
return buildFingerprint({ investigation }).slice(0, 20);
|
||||
}
|
||||
|
||||
function combineFindings(checkpoints: readonly AuditCheckpoint[]): CapellaFinding[] {
|
||||
// Different investigations can report the same finding id. Ordering by id and then
|
||||
// serialized body before first-wins insertion makes the surviving body deterministic.
|
||||
const candidates = checkpoints
|
||||
.flatMap((checkpoint) => checkpoint.findings)
|
||||
.sort((left, right) => compareText(left.id, right.id) || compareText(stableJson(left), stableJson(right)));
|
||||
const byId = new Map<string, CapellaFinding>();
|
||||
for (const finding of candidates) {
|
||||
if (!byId.has(finding.id)) byId.set(finding.id, finding);
|
||||
}
|
||||
return [...byId.values()].sort((left, right) => compareText(left.id, right.id));
|
||||
}
|
||||
|
||||
export async function runResearchStage(
|
||||
input: ResearchStageInput,
|
||||
runtime: CapellaStageRuntime,
|
||||
): Promise<CompletedStage<ResearchValue>> {
|
||||
const startedAt = Date.now();
|
||||
const architecture = await loadArtifactRef(
|
||||
input.artifactRoot,
|
||||
input.architectureArtifact,
|
||||
'architecture',
|
||||
isArchitectureValue,
|
||||
);
|
||||
const plan = await loadArtifactRef(input.artifactRoot, input.planArtifact, 'plan', isPlanValue);
|
||||
const identity = await resolveStageIdentity(input);
|
||||
const loader = createCapellaPromptLoader(input.promptDir);
|
||||
const scope = buildCodePathScopeSnippet(input.codePathFocus, input.codePathAvoids);
|
||||
const triageBasePrompt = loader.render(
|
||||
'sast.capella.triage',
|
||||
{ ...TRIAGE_TOOLS, LANGUAGE_CONTEXT: '', BOUNDARY_CONTEXT: scope, TARGET_FILES: '' },
|
||||
{ pipelineTestingMode: input.pipelineTestingMode },
|
||||
);
|
||||
const auditBasePrompt = loader.render(
|
||||
'sast.capella.research',
|
||||
{ ...RESEARCH_TOOLS, LANGUAGE_CONTEXT: '', BOUNDARY_CONTEXT: scope },
|
||||
{ pipelineTestingMode: input.pipelineTestingMode },
|
||||
);
|
||||
const renderedPromptsSha256 = buildFingerprint({ triageBasePrompt, auditBasePrompt });
|
||||
const fingerprint = buildResearchFingerprint(
|
||||
identity.runInputFingerprint,
|
||||
renderedPromptsSha256,
|
||||
artifactLineage(input.architectureArtifact),
|
||||
artifactLineage(input.planArtifact),
|
||||
);
|
||||
const reused = await maybeReuseStage(input, 'research', fingerprint, isResearchValue, identity, startedAt);
|
||||
if (reused) return reused;
|
||||
|
||||
const allFiles = [...new Set(plan.value.investigations.flatMap((investigation) => investigation.target_files))].sort(
|
||||
compareText,
|
||||
);
|
||||
const batches = batchFiles(allFiles).map((files) => ({
|
||||
files,
|
||||
batchId: buildFingerprint({ files }).slice(0, 20),
|
||||
}));
|
||||
|
||||
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(
|
||||
input.artifactRoot,
|
||||
checkpointPath,
|
||||
'research',
|
||||
checkpointFingerprint,
|
||||
isTriageCheckpoint,
|
||||
);
|
||||
if (cached) return { value: cached.value, usage: cached.usage, reused: true };
|
||||
|
||||
const userPrompt = `${triageBasePrompt}\n\nAssigned files:\n${batch.files.map((file) => `- ${file}`).join('\n')}`;
|
||||
const primaryResponse = await runtime.executor.run<TriageResult>({
|
||||
stage: 'research',
|
||||
role: 'small',
|
||||
cwd: input.repoPath,
|
||||
systemPrompt: 'You are a rapid triage auditor. Classify every assigned file and optimize for recall.',
|
||||
userPrompt,
|
||||
maxTurns: TRIAGE_MAX_TURNS,
|
||||
timeoutMs: input.timeoutMs,
|
||||
tools: runtime.repositoryTools,
|
||||
outputSchema: Type.Unsafe(TRIAGE_SCHEMA),
|
||||
signal: runtime.signal,
|
||||
sessionLabel,
|
||||
});
|
||||
let usage = primaryResponse.usage;
|
||||
const primaryIsValid = isTriageResult(primaryResponse.output);
|
||||
let classifications = primaryIsValid
|
||||
? usableClassifications(batch.files, primaryResponse.output.classifications)
|
||||
: [];
|
||||
const missingFiles = missingAssignedFiles(batch.files, classifications);
|
||||
if (missingFiles.length > 0) {
|
||||
const repairPrompt = [
|
||||
triageBasePrompt,
|
||||
'Repair pass: the previous session was invalid or omitted the assigned files below. Classify every listed file.',
|
||||
missingFiles.map((file) => `- ${file}`).join('\n'),
|
||||
].join('\n\n');
|
||||
const repairResponse = await runtime.executor.run<TriageResult>({
|
||||
stage: 'research',
|
||||
role: 'small',
|
||||
cwd: input.repoPath,
|
||||
systemPrompt: 'You are a rapid triage repair auditor. Classify every assigned file and optimize for recall.',
|
||||
userPrompt: repairPrompt,
|
||||
maxTurns: TRIAGE_MAX_TURNS,
|
||||
timeoutMs: input.timeoutMs,
|
||||
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);
|
||||
classifications = usableClassifications(batch.files, [...classifications, ...repaired]);
|
||||
}
|
||||
usage = addUsage(usage, repairResponse.usage);
|
||||
}
|
||||
|
||||
// A primary or repair response can remain invalid or incomplete after the one repair session.
|
||||
// Publish only usable classifications and let the deterministic coverage summary disclose the
|
||||
// remaining reduction.
|
||||
const value: TriageCheckpoint = {
|
||||
batchId: batch.batchId,
|
||||
files: [...batch.files],
|
||||
classifications,
|
||||
};
|
||||
await publishCheckpointArtifact(
|
||||
input.artifactRoot,
|
||||
checkpointPath,
|
||||
'research',
|
||||
checkpointFingerprint,
|
||||
usage,
|
||||
value,
|
||||
);
|
||||
return { value, usage, reused: false };
|
||||
});
|
||||
|
||||
const triageFailure = triageOutcomes.find((outcome) => outcome.status === 'failed');
|
||||
if (triageFailure?.status === 'failed') throw triageFailure.error;
|
||||
const triageResults = successfulPoolValues(triageOutcomes);
|
||||
|
||||
const flaggedFiles = [
|
||||
...new Set(
|
||||
triageResults.flatMap((result) =>
|
||||
result.value.classifications
|
||||
.filter((classification) => classification.potentially_flawed)
|
||||
.map((classification) => classification.file),
|
||||
),
|
||||
),
|
||||
].sort(compareText);
|
||||
const flaggedSet = new Set(flaggedFiles);
|
||||
const audits = plan.value.investigations
|
||||
.map((investigation) => ({
|
||||
investigation,
|
||||
investigationId: investigationId(investigation),
|
||||
flaggedFiles: investigation.target_files.filter((file) => flaggedSet.has(file)),
|
||||
}))
|
||||
.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,
|
||||
wave: 'audit',
|
||||
investigationId: audit.investigationId,
|
||||
flaggedFiles: [...audit.flaggedFiles].sort(compareText),
|
||||
});
|
||||
const cached = await loadCompletedArtifact(
|
||||
input.artifactRoot,
|
||||
checkpointPath,
|
||||
'research',
|
||||
checkpointFingerprint,
|
||||
isAuditCheckpoint,
|
||||
);
|
||||
if (cached) return { value: cached.value, usage: cached.usage, reused: true };
|
||||
|
||||
const value = await withTemporaryFindings(input.artifactRoot, [], async (findingsDir) => {
|
||||
const collector = createFindingCollector({ findingsDir });
|
||||
const userPrompt = `${auditBasePrompt}\n\n${buildResearchAssignment(
|
||||
audit.investigation,
|
||||
audit.flaggedFiles,
|
||||
architecture.value.knowledgeBase,
|
||||
)}`;
|
||||
const session = await runCollectorSession(
|
||||
() =>
|
||||
runtime.executor.run<void>({
|
||||
stage: 'research',
|
||||
role: 'medium',
|
||||
cwd: input.repoPath,
|
||||
systemPrompt:
|
||||
'You are a deep security auditor. Audit the assigned hotspots and report each finding through ' +
|
||||
'report_finding. A finding without one bare CWE cannot be reported.',
|
||||
userPrompt,
|
||||
maxTurns: AUDIT_MAX_TURNS,
|
||||
timeoutMs: input.timeoutMs,
|
||||
tools: [...runtime.repositoryTools, ...collector.tools],
|
||||
signal: runtime.signal,
|
||||
sessionLabel,
|
||||
}),
|
||||
() => collector.getFindings().length,
|
||||
);
|
||||
const checkpoint: AuditCheckpoint = {
|
||||
investigationId: audit.investigationId,
|
||||
title: audit.investigation.title,
|
||||
findings: collector.getFindings().sort((left, right) => compareText(left.id, right.id)),
|
||||
salvagedTurnLimit: session.salvagedTurnLimit,
|
||||
};
|
||||
return { checkpoint, usage: session.usage };
|
||||
});
|
||||
await publishCheckpointArtifact(
|
||||
input.artifactRoot,
|
||||
checkpointPath,
|
||||
'research',
|
||||
checkpointFingerprint,
|
||||
value.usage,
|
||||
value.checkpoint,
|
||||
);
|
||||
return { value: value.checkpoint, usage: value.usage, reused: false };
|
||||
});
|
||||
|
||||
const auditFailure = auditOutcomes.find((outcome) => outcome.status === 'failed');
|
||||
if (auditFailure?.status === 'failed') throw auditFailure.error;
|
||||
const auditResults = successfulPoolValues(auditOutcomes);
|
||||
|
||||
const findings = combineFindings(auditResults.map((result) => result.value));
|
||||
// Durable traces of what triage flagged and which investigations actually ran,
|
||||
// published for inspection of a finished or resumed scan.
|
||||
await publishCheckpointArtifact(
|
||||
input.artifactRoot,
|
||||
resolve(input.artifactRoot, 'research', 'flagged.json'),
|
||||
'research',
|
||||
buildFingerprint({ researchFingerprint: fingerprint, flaggedFiles }),
|
||||
ZERO_CAPELLA_USAGE,
|
||||
{ flaggedFiles },
|
||||
);
|
||||
await publishCheckpointArtifact(
|
||||
input.artifactRoot,
|
||||
resolve(input.artifactRoot, 'research', 'audited.json'),
|
||||
'research',
|
||||
buildFingerprint({
|
||||
researchFingerprint: fingerprint,
|
||||
investigationIds: auditResults.map((result) => result.value.investigationId),
|
||||
}),
|
||||
ZERO_CAPELLA_USAGE,
|
||||
{ investigationIds: auditResults.map((result) => result.value.investigationId).sort(compareText) },
|
||||
);
|
||||
await publishRawFindingAssets(input, findings);
|
||||
|
||||
const allUnits: Array<PoolItemResult<unknown> & { usage: typeof ZERO_CAPELLA_USAGE }> = [
|
||||
...triageResults,
|
||||
...auditResults,
|
||||
];
|
||||
const usage = allUnits.reduce((total, result) => addUsage(total, result.usage), ZERO_CAPELLA_USAGE);
|
||||
const triageCoverage = computeTriageCoverage(triageResults.map((result) => result.value));
|
||||
const auditCoverage: ResearchAuditCoverage = {
|
||||
consideredCount: audits.length,
|
||||
completedCount: auditResults.length,
|
||||
salvagedSessionCount: auditResults.filter((result) => result.value.salvagedTurnLimit).length,
|
||||
};
|
||||
const reduced = triageCoverage.omittedCount > 0 || auditCoverage.salvagedSessionCount > 0;
|
||||
const coverage = reduced ? 'reduced' : 'complete';
|
||||
const reduction = reduced ? buildResearchReduction(triageCoverage, auditCoverage) : undefined;
|
||||
const value: ResearchValue = {
|
||||
findings,
|
||||
flaggedFiles,
|
||||
dispatchedCount: auditResults.filter((result) => !result.reused).length,
|
||||
resumedCount: auditResults.filter((result) => result.reused).length,
|
||||
coverage,
|
||||
triageCoverage,
|
||||
auditCoverage,
|
||||
...(reduction !== undefined && { reduction }),
|
||||
};
|
||||
return completeStage(input, 'research', fingerprint, usage, value, identity, startedAt);
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
// 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 { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
import { CapellaAgentError } from '../../../pi/capella-agent-executor.js';
|
||||
import type { CapellaAgentResponse } from '../../../pi/capella-agent-types.js';
|
||||
import type { AgenticSastReduction, CapellaStage, CapellaUsage, SarifRef } from '../../types.js';
|
||||
import {
|
||||
atomicPublishBytes,
|
||||
buildFingerprint,
|
||||
buildRunInputFingerprint,
|
||||
loadCompletedArtifact,
|
||||
publishStageArtifact,
|
||||
recordStageCompletion,
|
||||
repositoryIdentity,
|
||||
sha256Bytes,
|
||||
stableJson,
|
||||
stageArtifactPath,
|
||||
} from '../artifacts.js';
|
||||
import { SastContractError } from '../errors.js';
|
||||
import type { CapellaFinding } from '../finding-types.js';
|
||||
import type { CapellaArtifactRef, CapellaStageInput, CompletedStage, StageArtifactValidator } from '../types.js';
|
||||
import { isAgenticSastReduction, isCapellaFinding } from '../validation.js';
|
||||
|
||||
export interface StageIdentity {
|
||||
readonly repositoryIdentity: string;
|
||||
readonly runInputFingerprint: string;
|
||||
}
|
||||
|
||||
function reductionFromStageValue(value: unknown, stage: CapellaStage): AgenticSastReduction | undefined {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value) || !('reduction' in value)) return undefined;
|
||||
const reduction = (value as { readonly reduction?: unknown }).reduction;
|
||||
if (reduction === undefined) return undefined;
|
||||
if (!isAgenticSastReduction(reduction) || reduction.stage !== stage) {
|
||||
throw new SastContractError('Capella stage value carried an invalid reduction', 'REDUCTION_SCHEMA');
|
||||
}
|
||||
return reduction;
|
||||
}
|
||||
|
||||
function completionReductions(
|
||||
value: unknown,
|
||||
stage: CapellaStage,
|
||||
additionalReductions: readonly AgenticSastReduction[],
|
||||
): readonly AgenticSastReduction[] {
|
||||
const stageReduction = reductionFromStageValue(value, stage);
|
||||
return stageReduction === undefined ? additionalReductions : [stageReduction, ...additionalReductions];
|
||||
}
|
||||
|
||||
export async function resolveStageIdentity(input: CapellaStageInput): Promise<StageIdentity> {
|
||||
const identity = await repositoryIdentity(input.repoPath);
|
||||
return {
|
||||
repositoryIdentity: identity,
|
||||
runInputFingerprint: buildRunInputFingerprint(input, identity),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fingerprint that decides artifact reuse for a stage. Everything that can change the
|
||||
* stage's model-visible behavior must flow in through the rendered prompt or stageInputs;
|
||||
* an input missing here lets a stale artifact be adopted on resume.
|
||||
*/
|
||||
export function buildStageFingerprint(
|
||||
stage: CapellaStage,
|
||||
identity: StageIdentity,
|
||||
prompt: string,
|
||||
stageInputs: Record<string, unknown>,
|
||||
): string {
|
||||
return buildFingerprint({
|
||||
stage,
|
||||
runInputFingerprint: identity.runInputFingerprint,
|
||||
renderedPromptSha256: sha256Bytes(prompt),
|
||||
...stageInputs,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Adopt a previously published stage artifact when its fingerprint matches. Returns
|
||||
* undefined on any miss: absent or corrupt artifact, fingerprint mismatch, or a
|
||||
* reuseGuard veto. The optional reuseGuard re-checks on-disk side effects that the
|
||||
* artifact envelope cannot see. A hit atomically re-records completion and its
|
||||
* reduction in run.json, which heals a crash that landed between artifact publication
|
||||
* and the run-record write.
|
||||
*/
|
||||
export async function maybeReuseStage<T>(
|
||||
input: CapellaStageInput,
|
||||
stage: CapellaStage,
|
||||
fingerprint: string,
|
||||
validate: StageArtifactValidator<T>,
|
||||
identity: StageIdentity,
|
||||
startedAt: number,
|
||||
reuseGuard?: (value: T) => Promise<boolean>,
|
||||
): Promise<CompletedStage<T> | undefined> {
|
||||
const loaded = await loadCompletedArtifact(
|
||||
input.artifactRoot,
|
||||
stageArtifactPath(input.artifactRoot, stage),
|
||||
stage,
|
||||
fingerprint,
|
||||
validate,
|
||||
);
|
||||
if (!loaded) return undefined;
|
||||
if (reuseGuard && !(await reuseGuard(loaded.value))) return undefined;
|
||||
await recordStageCompletion(
|
||||
input,
|
||||
identity.runInputFingerprint,
|
||||
stage,
|
||||
loaded.usage,
|
||||
[],
|
||||
undefined,
|
||||
completionReductions(loaded.value, stage, []),
|
||||
);
|
||||
return {
|
||||
status: 'completed',
|
||||
durationMs: Date.now() - startedAt,
|
||||
reused: true,
|
||||
usage: loaded.usage,
|
||||
artifact: loaded.ref,
|
||||
value: loaded.value,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish the stage artifact, then atomically record completion and reductions in
|
||||
* run.json. The order matters: run.json must never name a stage whose artifact is
|
||||
* missing from disk, while the reverse gap (artifact without record) is healed by
|
||||
* maybeReuseStage on the next attempt.
|
||||
*/
|
||||
export async function completeStage<T>(
|
||||
input: CapellaStageInput,
|
||||
stage: CapellaStage,
|
||||
fingerprint: string,
|
||||
usage: CapellaUsage,
|
||||
value: T,
|
||||
identity: StageIdentity,
|
||||
startedAt: number,
|
||||
warnings: readonly string[] = [],
|
||||
sarif?: SarifRef,
|
||||
additionalReductions: readonly AgenticSastReduction[] = [],
|
||||
): Promise<CompletedStage<T>> {
|
||||
const artifact = await publishStageArtifact(input.artifactRoot, stage, fingerprint, usage, value);
|
||||
await recordStageCompletion(
|
||||
input,
|
||||
identity.runInputFingerprint,
|
||||
stage,
|
||||
usage,
|
||||
warnings,
|
||||
sarif,
|
||||
completionReductions(value, stage, additionalReductions),
|
||||
);
|
||||
return {
|
||||
status: 'completed',
|
||||
durationMs: Date.now() - startedAt,
|
||||
reused: false,
|
||||
usage,
|
||||
artifact,
|
||||
value,
|
||||
};
|
||||
}
|
||||
|
||||
export async function publishTextAsset(input: CapellaStageInput, relativePath: string, text: string): Promise<void> {
|
||||
await atomicPublishBytes(input.artifactRoot, resolve(input.artifactRoot, relativePath), text);
|
||||
}
|
||||
|
||||
function safeFindingFilename(id: string): string {
|
||||
if (!id || id.includes('/') || id.includes('\\') || id.includes('..')) {
|
||||
throw new SastContractError('Capella finding id cannot be used as an artifact filename');
|
||||
}
|
||||
return `${id}.json`;
|
||||
}
|
||||
|
||||
/** Publish readable raw finding files only after the complete research stage succeeds. */
|
||||
export async function publishRawFindingAssets(
|
||||
input: CapellaStageInput,
|
||||
findings: readonly CapellaFinding[],
|
||||
): Promise<void> {
|
||||
const findingsDir = resolve(input.artifactRoot, 'findings');
|
||||
const assets = [...findings]
|
||||
.sort((left, right) => {
|
||||
if (left.id < right.id) return -1;
|
||||
if (left.id > right.id) return 1;
|
||||
return 0;
|
||||
})
|
||||
.map((finding) => ({ finding, path: resolve(findingsDir, safeFindingFilename(finding.id)) }));
|
||||
// A prior attempt may have published findings whose ids this run no longer produces;
|
||||
// clearing the directory keeps those orphans out of the published set.
|
||||
await rm(findingsDir, { recursive: true, force: true });
|
||||
for (const asset of assets) {
|
||||
await atomicPublishBytes(input.artifactRoot, asset.path, stableJson(asset.finding));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a stage against a private scratch copy of the findings so collector tools can
|
||||
* delete and rewrite files without touching published artifacts. Each attempt gets its
|
||||
* own directory under .attempts. Cleanup is best-effort: a leftover scratch directory
|
||||
* is harmless, while a thrown cleanup error would mask the stage result.
|
||||
*/
|
||||
export async function withTemporaryFindings<T>(
|
||||
artifactRoot: string,
|
||||
findings: readonly CapellaFinding[],
|
||||
run: (findingsDir: string) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const attemptsRoot = resolve(artifactRoot, '.attempts');
|
||||
await mkdir(attemptsRoot, { recursive: true });
|
||||
const attemptRoot = await mkdtemp(resolve(attemptsRoot, 'stage-'));
|
||||
const findingsDir = resolve(attemptRoot, 'findings');
|
||||
await mkdir(findingsDir, { recursive: true });
|
||||
try {
|
||||
for (const finding of findings) {
|
||||
await writeFile(resolve(findingsDir, safeFindingFilename(finding.id)), stableJson(finding), {
|
||||
encoding: 'utf8',
|
||||
mode: 0o600,
|
||||
});
|
||||
}
|
||||
return await run(findingsDir);
|
||||
} finally {
|
||||
await rm(attemptRoot, { recursive: true, force: true }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
export interface CollectorSessionOutcome {
|
||||
readonly usage: CapellaUsage;
|
||||
readonly salvagedTurnLimit: boolean;
|
||||
}
|
||||
|
||||
/** Preserve collector mutations only for a turn-limit that carries usage and accepted work. */
|
||||
export async function runCollectorSession(
|
||||
run: () => Promise<CapellaAgentResponse<void>>,
|
||||
acceptedMutationCount: () => number,
|
||||
): Promise<CollectorSessionOutcome> {
|
||||
const acceptedBefore = acceptedMutationCount();
|
||||
try {
|
||||
const response = await run();
|
||||
return { usage: response.usage, salvagedTurnLimit: false };
|
||||
} catch (error) {
|
||||
const acceptedAfter = acceptedMutationCount();
|
||||
const canSalvage =
|
||||
error instanceof CapellaAgentError &&
|
||||
error.code === 'TURN_LIMIT' &&
|
||||
error.usage !== undefined &&
|
||||
acceptedAfter > acceptedBefore;
|
||||
if (!canSalvage) throw error;
|
||||
return { usage: error.usage, salvagedTurnLimit: true };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the surviving findings back from a scratch findings directory. Collector tools
|
||||
* mutate that directory in place while the model works, so the files present after the
|
||||
* session, and not the collector return values, are the source of truth for survivors.
|
||||
* Sorted by id so downstream fingerprints stay deterministic.
|
||||
*/
|
||||
export interface ActiveFindingOmission {
|
||||
readonly filename: string;
|
||||
readonly reason: 'invalid_json' | 'invalid_schema';
|
||||
}
|
||||
|
||||
export interface ActiveFindingsResult {
|
||||
readonly findings: CapellaFinding[];
|
||||
readonly omissions: ActiveFindingOmission[];
|
||||
}
|
||||
|
||||
export async function readActiveFindings(findingsDir: string): Promise<ActiveFindingsResult> {
|
||||
const findings: CapellaFinding[] = [];
|
||||
const omissions: ActiveFindingOmission[] = [];
|
||||
for (const file of (await readdir(findingsDir)).filter((entry) => entry.endsWith('.json')).sort()) {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(await readFile(resolve(findingsDir, file), 'utf8'));
|
||||
} catch {
|
||||
omissions.push({ filename: file, reason: 'invalid_json' });
|
||||
continue;
|
||||
}
|
||||
if (!isCapellaFinding(parsed)) {
|
||||
omissions.push({ filename: file, reason: 'invalid_schema' });
|
||||
continue;
|
||||
}
|
||||
findings.push(parsed);
|
||||
}
|
||||
return {
|
||||
findings: findings.sort((left, right) => {
|
||||
if (left.id < right.id) return -1;
|
||||
if (left.id > right.id) return 1;
|
||||
return 0;
|
||||
}),
|
||||
omissions,
|
||||
};
|
||||
}
|
||||
|
||||
export function artifactLineage(ref: CapellaArtifactRef): Record<string, string> {
|
||||
return { path: ref.path, sha256: ref.sha256, fingerprint: ref.fingerprint };
|
||||
}
|
||||
@@ -0,0 +1,620 @@
|
||||
// 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 { addUsage, loadArtifactRef } from '../artifacts.js';
|
||||
import {
|
||||
createCalibrationCollector,
|
||||
createConfirmationCollector,
|
||||
createDuplicateCollector,
|
||||
createReviewCollector,
|
||||
createViabilityCollector,
|
||||
quarantineUngradedReviewFindings,
|
||||
type VerdictRejectionCounts,
|
||||
} from '../collectors.js';
|
||||
import type { CapellaFinding } from '../finding-types.js';
|
||||
import {
|
||||
buildFindingsContext,
|
||||
buildKnowledgeBaseContext,
|
||||
CALIBRATE_TOOLS,
|
||||
CONFIRM_TOOLS,
|
||||
CRITIC_TOOLS,
|
||||
DEDUPE_TOOLS,
|
||||
REVIEW_TOOLS,
|
||||
} from '../prompt-context.js';
|
||||
import { type CapellaPromptId, createCapellaPromptLoader } from '../prompt-loader.js';
|
||||
import type {
|
||||
CalibrateValue,
|
||||
CapellaStageRuntime,
|
||||
CompletedStage,
|
||||
ConfirmValue,
|
||||
CriticValue,
|
||||
DedupeValue,
|
||||
FindingStageInput,
|
||||
KnowledgeFindingStageInput,
|
||||
ResearchValue,
|
||||
ReviewValue,
|
||||
} from '../types.js';
|
||||
import {
|
||||
calculateVerdictSetDetails,
|
||||
isArchitectureValue,
|
||||
isCalibrateValue,
|
||||
isConfirmValue,
|
||||
isCriticValue,
|
||||
isDedupeValue,
|
||||
isResearchValue,
|
||||
isReviewValue,
|
||||
isThreatModelValue,
|
||||
} from '../validation.js';
|
||||
import {
|
||||
type ActiveFindingsResult,
|
||||
artifactLineage,
|
||||
buildStageFingerprint,
|
||||
completeStage,
|
||||
maybeReuseStage,
|
||||
readActiveFindings,
|
||||
resolveStageIdentity,
|
||||
runCollectorSession,
|
||||
withTemporaryFindings,
|
||||
} from './shared.js';
|
||||
|
||||
const DEDUPE_MAX_TURNS = 200;
|
||||
const REVIEW_MAX_TURNS = 400;
|
||||
const CRITIC_MAX_TURNS = 300;
|
||||
const CONFIRM_MAX_TURNS = 300;
|
||||
const CALIBRATE_MAX_TURNS = 200;
|
||||
|
||||
interface VerdictReductionCounts {
|
||||
readonly consideredCount: number;
|
||||
readonly gradedCount: number;
|
||||
readonly missingCount: number;
|
||||
readonly unreadableCount: number;
|
||||
readonly rejectedUnexpectedCount: number;
|
||||
readonly rejectedDuplicateCount: number;
|
||||
readonly salvagedTurnLimitCount: number;
|
||||
}
|
||||
|
||||
function verdictReductionCounts(
|
||||
expectedIds: readonly string[],
|
||||
acceptedIds: readonly string[],
|
||||
active: ActiveFindingsResult,
|
||||
rejected: VerdictRejectionCounts,
|
||||
salvagedTurnLimitCount: number,
|
||||
): VerdictReductionCounts {
|
||||
const details = calculateVerdictSetDetails(expectedIds, acceptedIds);
|
||||
return {
|
||||
consideredCount: expectedIds.length,
|
||||
gradedCount: acceptedIds.length,
|
||||
missingCount: details.missingIds.length,
|
||||
unreadableCount: active.omissions.length,
|
||||
rejectedUnexpectedCount: rejected.unexpected,
|
||||
rejectedDuplicateCount: rejected.duplicate,
|
||||
salvagedTurnLimitCount,
|
||||
};
|
||||
}
|
||||
|
||||
function verdictWasReduced(counts: VerdictReductionCounts): boolean {
|
||||
return counts.missingCount > 0 || counts.unreadableCount > 0 || counts.salvagedTurnLimitCount > 0;
|
||||
}
|
||||
|
||||
function renderFindingsPrompt(
|
||||
input: FindingStageInput,
|
||||
promptId: CapellaPromptId,
|
||||
context: Readonly<Record<string, unknown>>,
|
||||
findings: readonly CapellaFinding[],
|
||||
extraContext = '',
|
||||
): string {
|
||||
const loader = createCapellaPromptLoader(input.promptDir);
|
||||
return [
|
||||
loader.render(promptId, context, { pipelineTestingMode: input.pipelineTestingMode }),
|
||||
buildFindingsContext(findings),
|
||||
extraContext,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
function renderVerdictRepairPrompt(
|
||||
input: FindingStageInput,
|
||||
promptId: CapellaPromptId,
|
||||
context: Readonly<Record<string, unknown>>,
|
||||
missingFindings: readonly CapellaFinding[],
|
||||
extraContext = '',
|
||||
): string {
|
||||
return [
|
||||
'Repair pass: submit exactly one decision for every finding below. Do not submit any other finding ID.',
|
||||
renderFindingsPrompt(input, promptId, context, missingFindings, extraContext),
|
||||
].join('\n\n');
|
||||
}
|
||||
|
||||
function missingFindings(
|
||||
expectedFindings: readonly CapellaFinding[],
|
||||
acceptedIds: readonly string[],
|
||||
): CapellaFinding[] {
|
||||
const expectedIds = expectedFindings.map((finding) => finding.id);
|
||||
const missingIds = new Set(calculateVerdictSetDetails(expectedIds, acceptedIds).missingIds);
|
||||
return expectedFindings.filter((finding) => missingIds.has(finding.id));
|
||||
}
|
||||
|
||||
async function loadResearchFindings(input: FindingStageInput): Promise<ResearchValue> {
|
||||
return (await loadArtifactRef(input.artifactRoot, input.findingsArtifact, 'research', isResearchValue)).value;
|
||||
}
|
||||
|
||||
async function loadDedupeFindings(input: FindingStageInput): Promise<DedupeValue> {
|
||||
return (await loadArtifactRef(input.artifactRoot, input.findingsArtifact, 'dedupe', isDedupeValue)).value;
|
||||
}
|
||||
|
||||
async function loadReviewFindings(input: FindingStageInput): Promise<ReviewValue> {
|
||||
return (await loadArtifactRef(input.artifactRoot, input.findingsArtifact, 'review', isReviewValue)).value;
|
||||
}
|
||||
|
||||
async function loadCriticFindings(input: FindingStageInput): Promise<CriticValue> {
|
||||
return (await loadArtifactRef(input.artifactRoot, input.findingsArtifact, 'critic', isCriticValue)).value;
|
||||
}
|
||||
|
||||
async function loadConfirmFindings(input: FindingStageInput): Promise<ConfirmValue> {
|
||||
return (await loadArtifactRef(input.artifactRoot, input.findingsArtifact, 'confirm', isConfirmValue)).value;
|
||||
}
|
||||
|
||||
async function knowledgeContext(input: KnowledgeFindingStageInput): Promise<string> {
|
||||
const architecture = await loadArtifactRef(
|
||||
input.artifactRoot,
|
||||
input.architectureArtifact,
|
||||
'architecture',
|
||||
isArchitectureValue,
|
||||
);
|
||||
const threatModel = await loadArtifactRef(
|
||||
input.artifactRoot,
|
||||
input.threatModelArtifact,
|
||||
'threat-model',
|
||||
isThreatModelValue,
|
||||
);
|
||||
return `${buildKnowledgeBaseContext(architecture.value.knowledgeBase)}\n\n<capella_threat_model>\n${threatModel.value.threatModel}\n</capella_threat_model>`;
|
||||
}
|
||||
|
||||
export async function runDedupeStage(
|
||||
input: FindingStageInput,
|
||||
runtime: CapellaStageRuntime,
|
||||
): Promise<CompletedStage<DedupeValue>> {
|
||||
const startedAt = Date.now();
|
||||
const source = await loadResearchFindings(input);
|
||||
const identity = await resolveStageIdentity(input);
|
||||
const prompt = renderFindingsPrompt(input, 'sast.capella.dedupe', DEDUPE_TOOLS, source.findings);
|
||||
const fingerprint = buildStageFingerprint('dedupe', identity, prompt, {
|
||||
findings: artifactLineage(input.findingsArtifact),
|
||||
});
|
||||
const reused = await maybeReuseStage(input, 'dedupe', fingerprint, isDedupeValue, identity, startedAt);
|
||||
if (reused) return reused;
|
||||
|
||||
const outcome = await withTemporaryFindings(input.artifactRoot, source.findings, async (findingsDir) => {
|
||||
const collector = createDuplicateCollector({ findingsDir });
|
||||
const session = await runCollectorSession(
|
||||
() =>
|
||||
runtime.executor.run<void>({
|
||||
stage: 'dedupe',
|
||||
role: 'small',
|
||||
cwd: input.repoPath,
|
||||
systemPrompt:
|
||||
'You consolidate duplicate security findings. Findings at different lines in the same file are distinct.',
|
||||
userPrompt: prompt,
|
||||
maxTurns: DEDUPE_MAX_TURNS,
|
||||
timeoutMs: input.timeoutMs,
|
||||
tools: [...runtime.repositoryTools, ...collector.tools],
|
||||
signal: runtime.signal,
|
||||
sessionLabel: 'primary',
|
||||
}),
|
||||
() => collector.getDuplicates().length,
|
||||
);
|
||||
const active = await readActiveFindings(findingsDir);
|
||||
const salvagedTurnLimitCount = session.salvagedTurnLimit ? 1 : 0;
|
||||
const reduced = active.omissions.length > 0 || salvagedTurnLimitCount > 0;
|
||||
const value: DedupeValue = {
|
||||
findings: active.findings,
|
||||
duplicateCount: collector.getDuplicates().length,
|
||||
survivorCount: active.findings.length,
|
||||
...(reduced && {
|
||||
reduction: {
|
||||
stage: 'dedupe',
|
||||
reason: 'incomplete_dedupe',
|
||||
consideredCount: source.findings.length,
|
||||
survivorCount: active.findings.length,
|
||||
unreadableCount: active.omissions.length,
|
||||
salvagedTurnLimitCount,
|
||||
},
|
||||
}),
|
||||
};
|
||||
return { usage: session.usage, value };
|
||||
});
|
||||
return completeStage(input, 'dedupe', fingerprint, outcome.usage, outcome.value, identity, startedAt);
|
||||
}
|
||||
|
||||
export async function runReviewStage(
|
||||
input: FindingStageInput,
|
||||
runtime: CapellaStageRuntime,
|
||||
): Promise<CompletedStage<ReviewValue>> {
|
||||
const startedAt = Date.now();
|
||||
const source = await loadDedupeFindings(input);
|
||||
const identity = await resolveStageIdentity(input);
|
||||
const prompt = renderFindingsPrompt(input, 'sast.capella.review', REVIEW_TOOLS, source.findings);
|
||||
const fingerprint = buildStageFingerprint('review', identity, prompt, {
|
||||
findings: artifactLineage(input.findingsArtifact),
|
||||
});
|
||||
const reused = await maybeReuseStage(input, 'review', fingerprint, isReviewValue, identity, startedAt);
|
||||
if (reused) return reused;
|
||||
const expectedIds = source.findings.map((finding) => finding.id);
|
||||
|
||||
const outcome = await withTemporaryFindings(input.artifactRoot, source.findings, async (findingsDir) => {
|
||||
const collector = createReviewCollector({ findingsDir, expectedIds });
|
||||
const systemPrompt =
|
||||
"You are the independent validator. Assume every finding is false until the source disproves it. Ignore the finder's prose reasoning.";
|
||||
const primary = await runCollectorSession(
|
||||
() =>
|
||||
runtime.executor.run<void>({
|
||||
stage: 'review',
|
||||
role: 'medium',
|
||||
cwd: input.repoPath,
|
||||
systemPrompt,
|
||||
userPrompt: prompt,
|
||||
maxTurns: REVIEW_MAX_TURNS,
|
||||
timeoutMs: input.timeoutMs,
|
||||
tools: [...runtime.repositoryTools, ...collector.tools],
|
||||
signal: runtime.signal,
|
||||
sessionLabel: 'primary',
|
||||
}),
|
||||
() => collector.getAcceptedIds().length,
|
||||
);
|
||||
let usage = primary.usage;
|
||||
let salvagedTurnLimitCount = primary.salvagedTurnLimit ? 1 : 0;
|
||||
// One bounded repair pass, scoped to only the findings the primary session skipped: this
|
||||
// recovers a session that ran out of turns or omitted a few findings without re-running the
|
||||
// full finding set, which would double the cost of every unaffected verdict alongside it.
|
||||
const missing = missingFindings(source.findings, collector.getAcceptedIds());
|
||||
if (missing.length > 0) {
|
||||
const repair = await runCollectorSession(
|
||||
() =>
|
||||
runtime.executor.run<void>({
|
||||
stage: 'review',
|
||||
role: 'medium',
|
||||
cwd: input.repoPath,
|
||||
systemPrompt,
|
||||
userPrompt: renderVerdictRepairPrompt(input, 'sast.capella.review', REVIEW_TOOLS, missing),
|
||||
maxTurns: REVIEW_MAX_TURNS,
|
||||
timeoutMs: input.timeoutMs,
|
||||
tools: [...runtime.repositoryTools, ...collector.tools],
|
||||
signal: runtime.signal,
|
||||
sessionLabel: 'repair',
|
||||
}),
|
||||
() => collector.getAcceptedIds().length,
|
||||
);
|
||||
usage = addUsage(usage, repair.usage);
|
||||
if (repair.salvagedTurnLimit) salvagedTurnLimitCount += 1;
|
||||
}
|
||||
const verdicts = collector.getVerdicts();
|
||||
const details = calculateVerdictSetDetails(expectedIds, collector.getAcceptedIds());
|
||||
const quarantinedCount = quarantineUngradedReviewFindings(findingsDir, details.missingIds);
|
||||
const active = await readActiveFindings(findingsDir);
|
||||
const rejected = collector.getRejectionCounts();
|
||||
const reductionCounts = verdictReductionCounts(
|
||||
expectedIds,
|
||||
collector.getAcceptedIds(),
|
||||
active,
|
||||
rejected,
|
||||
salvagedTurnLimitCount,
|
||||
);
|
||||
const value: ReviewValue = {
|
||||
findings: active.findings,
|
||||
validCount: verdicts.filter((verdict) => verdict.status === 'VALID').length,
|
||||
provisionalCount: verdicts.filter((verdict) => verdict.status === 'PROVISIONALLY_VALID').length,
|
||||
falsePositiveCount: verdicts.filter((verdict) => verdict.status === 'FALSE_POSITIVE').length,
|
||||
rejectedUnexpectedCount: rejected.unexpected,
|
||||
rejectedDuplicateCount: rejected.duplicate,
|
||||
...(verdictWasReduced(reductionCounts) && {
|
||||
reduction: {
|
||||
stage: 'review',
|
||||
reason: 'incomplete_review',
|
||||
...reductionCounts,
|
||||
quarantinedCount,
|
||||
},
|
||||
}),
|
||||
};
|
||||
return { usage, value };
|
||||
});
|
||||
return completeStage(input, 'review', fingerprint, outcome.usage, outcome.value, identity, startedAt);
|
||||
}
|
||||
|
||||
export async function runCriticStage(
|
||||
input: KnowledgeFindingStageInput,
|
||||
runtime: CapellaStageRuntime,
|
||||
): Promise<CompletedStage<CriticValue>> {
|
||||
const startedAt = Date.now();
|
||||
const source = await loadReviewFindings(input);
|
||||
const kbContext = await knowledgeContext(input);
|
||||
const identity = await resolveStageIdentity(input);
|
||||
const prompt = renderFindingsPrompt(
|
||||
input,
|
||||
'sast.capella.critic',
|
||||
{ ...CRITIC_TOOLS, KB_DIR: 'the host-provided context below' },
|
||||
source.findings,
|
||||
kbContext,
|
||||
);
|
||||
const fingerprint = buildStageFingerprint('critic', identity, prompt, {
|
||||
findings: artifactLineage(input.findingsArtifact),
|
||||
architecture: artifactLineage(input.architectureArtifact),
|
||||
threatModel: artifactLineage(input.threatModelArtifact),
|
||||
});
|
||||
const reused = await maybeReuseStage(input, 'critic', fingerprint, isCriticValue, identity, startedAt);
|
||||
if (reused) return reused;
|
||||
// The model sees every finding for context, but a viability verdict is owed only
|
||||
// for the findings that survived review.
|
||||
const expected = source.findings.filter(
|
||||
(finding) => finding.status === 'VALID' || finding.status === 'PROVISIONALLY_VALID',
|
||||
);
|
||||
|
||||
const outcome = await withTemporaryFindings(input.artifactRoot, source.findings, async (findingsDir) => {
|
||||
const expectedIds = expected.map((finding) => finding.id);
|
||||
const collector = createViabilityCollector({ findingsDir, expectedIds });
|
||||
const systemPrompt =
|
||||
'You are the production-viability expert. Adopt a skeptical stance and independently re-verify each survivor.';
|
||||
const primary = await runCollectorSession(
|
||||
() =>
|
||||
runtime.executor.run<void>({
|
||||
stage: 'critic',
|
||||
role: 'medium',
|
||||
cwd: input.repoPath,
|
||||
systemPrompt,
|
||||
userPrompt: prompt,
|
||||
maxTurns: CRITIC_MAX_TURNS,
|
||||
timeoutMs: input.timeoutMs,
|
||||
tools: [...runtime.repositoryTools, ...collector.tools],
|
||||
signal: runtime.signal,
|
||||
sessionLabel: 'primary',
|
||||
}),
|
||||
() => collector.getAcceptedIds().length,
|
||||
);
|
||||
let usage = primary.usage;
|
||||
let salvagedTurnLimitCount = primary.salvagedTurnLimit ? 1 : 0;
|
||||
const missing = missingFindings(expected, collector.getAcceptedIds());
|
||||
if (missing.length > 0) {
|
||||
const repair = await runCollectorSession(
|
||||
() =>
|
||||
runtime.executor.run<void>({
|
||||
stage: 'critic',
|
||||
role: 'medium',
|
||||
cwd: input.repoPath,
|
||||
systemPrompt,
|
||||
userPrompt: renderVerdictRepairPrompt(
|
||||
input,
|
||||
'sast.capella.critic',
|
||||
{ ...CRITIC_TOOLS, KB_DIR: 'the host-provided context below' },
|
||||
missing,
|
||||
kbContext,
|
||||
),
|
||||
maxTurns: CRITIC_MAX_TURNS,
|
||||
timeoutMs: input.timeoutMs,
|
||||
tools: [...runtime.repositoryTools, ...collector.tools],
|
||||
signal: runtime.signal,
|
||||
sessionLabel: 'repair',
|
||||
}),
|
||||
() => collector.getAcceptedIds().length,
|
||||
);
|
||||
usage = addUsage(usage, repair.usage);
|
||||
if (repair.salvagedTurnLimit) salvagedTurnLimitCount += 1;
|
||||
}
|
||||
const viabilities = collector.getViabilities();
|
||||
const active = await readActiveFindings(findingsDir);
|
||||
const rejected = collector.getRejectionCounts();
|
||||
const reductionCounts = verdictReductionCounts(
|
||||
expectedIds,
|
||||
collector.getAcceptedIds(),
|
||||
active,
|
||||
rejected,
|
||||
salvagedTurnLimitCount,
|
||||
);
|
||||
const value: CriticValue = {
|
||||
findings: active.findings,
|
||||
viableCount: viabilities.filter(
|
||||
(verdict) => verdict.viability === 'VIABLE' || verdict.viability === 'CONDITIONAL_VIABLE',
|
||||
).length,
|
||||
rejectedUnexpectedCount: rejected.unexpected,
|
||||
rejectedDuplicateCount: rejected.duplicate,
|
||||
...(verdictWasReduced(reductionCounts) && {
|
||||
reduction: { stage: 'critic', reason: 'incomplete_critic', ...reductionCounts },
|
||||
}),
|
||||
};
|
||||
return { usage, value };
|
||||
});
|
||||
return completeStage(input, 'critic', fingerprint, outcome.usage, outcome.value, identity, startedAt);
|
||||
}
|
||||
|
||||
export async function runConfirmStage(
|
||||
input: FindingStageInput,
|
||||
runtime: CapellaStageRuntime,
|
||||
): Promise<CompletedStage<ConfirmValue>> {
|
||||
const startedAt = Date.now();
|
||||
const source = await loadCriticFindings(input);
|
||||
const identity = await resolveStageIdentity(input);
|
||||
const prompt = renderFindingsPrompt(input, 'sast.capella.confirm', CONFIRM_TOOLS, source.findings);
|
||||
const fingerprint = buildStageFingerprint('confirm', identity, prompt, {
|
||||
findings: artifactLineage(input.findingsArtifact),
|
||||
});
|
||||
const reused = await maybeReuseStage(input, 'confirm', fingerprint, isConfirmValue, identity, startedAt);
|
||||
if (reused) return reused;
|
||||
// Critic assigns production viability without changing status, so the set owed a
|
||||
// confirmation verdict is still the review survivors.
|
||||
const expected = source.findings.filter(
|
||||
(finding) => finding.status === 'VALID' || finding.status === 'PROVISIONALLY_VALID',
|
||||
);
|
||||
|
||||
const outcome = await withTemporaryFindings(input.artifactRoot, source.findings, async (findingsDir) => {
|
||||
const expectedIds = expected.map((finding) => finding.id);
|
||||
const collector = createConfirmationCollector({ findingsDir, expectedIds });
|
||||
const systemPrompt =
|
||||
'You statically confirm survivors against source. This engine has no execution sandbox, so reached-sink source evidence is required.';
|
||||
const primary = await runCollectorSession(
|
||||
() =>
|
||||
runtime.executor.run<void>({
|
||||
stage: 'confirm',
|
||||
role: 'medium',
|
||||
cwd: input.repoPath,
|
||||
systemPrompt,
|
||||
userPrompt: prompt,
|
||||
maxTurns: CONFIRM_MAX_TURNS,
|
||||
timeoutMs: input.timeoutMs,
|
||||
tools: [...runtime.repositoryTools, ...collector.tools],
|
||||
signal: runtime.signal,
|
||||
sessionLabel: 'primary',
|
||||
}),
|
||||
() => collector.getAcceptedIds().length,
|
||||
);
|
||||
let usage = primary.usage;
|
||||
let salvagedTurnLimitCount = primary.salvagedTurnLimit ? 1 : 0;
|
||||
const missing = missingFindings(expected, collector.getAcceptedIds());
|
||||
if (missing.length > 0) {
|
||||
const repair = await runCollectorSession(
|
||||
() =>
|
||||
runtime.executor.run<void>({
|
||||
stage: 'confirm',
|
||||
role: 'medium',
|
||||
cwd: input.repoPath,
|
||||
systemPrompt,
|
||||
userPrompt: renderVerdictRepairPrompt(input, 'sast.capella.confirm', CONFIRM_TOOLS, missing),
|
||||
maxTurns: CONFIRM_MAX_TURNS,
|
||||
timeoutMs: input.timeoutMs,
|
||||
tools: [...runtime.repositoryTools, ...collector.tools],
|
||||
signal: runtime.signal,
|
||||
sessionLabel: 'repair',
|
||||
}),
|
||||
() => collector.getAcceptedIds().length,
|
||||
);
|
||||
usage = addUsage(usage, repair.usage);
|
||||
if (repair.salvagedTurnLimit) salvagedTurnLimitCount += 1;
|
||||
}
|
||||
const confirmations = collector.getConfirmations();
|
||||
const active = await readActiveFindings(findingsDir);
|
||||
const rejected = collector.getRejectionCounts();
|
||||
const reductionCounts = verdictReductionCounts(
|
||||
expectedIds,
|
||||
collector.getAcceptedIds(),
|
||||
active,
|
||||
rejected,
|
||||
salvagedTurnLimitCount,
|
||||
);
|
||||
const value: ConfirmValue = {
|
||||
findings: active.findings,
|
||||
confirmedCount: confirmations.filter((confirmation) => confirmation.promoted).length,
|
||||
rejectedUnexpectedCount: rejected.unexpected,
|
||||
rejectedDuplicateCount: rejected.duplicate,
|
||||
...(verdictWasReduced(reductionCounts) && {
|
||||
reduction: { stage: 'confirm', reason: 'incomplete_confirm', ...reductionCounts },
|
||||
}),
|
||||
};
|
||||
return { usage, value };
|
||||
});
|
||||
return completeStage(input, 'confirm', fingerprint, outcome.usage, outcome.value, identity, startedAt);
|
||||
}
|
||||
|
||||
export async function runCalibrateStage(
|
||||
input: KnowledgeFindingStageInput,
|
||||
runtime: CapellaStageRuntime,
|
||||
): Promise<CompletedStage<CalibrateValue>> {
|
||||
const startedAt = Date.now();
|
||||
const source = await loadConfirmFindings(input);
|
||||
const kbContext = await knowledgeContext(input);
|
||||
const identity = await resolveStageIdentity(input);
|
||||
const prompt = renderFindingsPrompt(
|
||||
input,
|
||||
'sast.capella.calibrate',
|
||||
{ ...CALIBRATE_TOOLS, KB_DIR: 'the host-provided context below' },
|
||||
source.findings,
|
||||
kbContext,
|
||||
);
|
||||
const fingerprint = buildStageFingerprint('calibrate', identity, prompt, {
|
||||
findings: artifactLineage(input.findingsArtifact),
|
||||
architecture: artifactLineage(input.architectureArtifact),
|
||||
threatModel: artifactLineage(input.threatModelArtifact),
|
||||
});
|
||||
const reused = await maybeReuseStage(input, 'calibrate', fingerprint, isCalibrateValue, identity, startedAt);
|
||||
if (reused) return reused;
|
||||
// Calibration covers review survivors that remain viable in production. This allow-list
|
||||
// keeps future statuses out until they are explicitly made reportable.
|
||||
const expected = source.findings.filter(
|
||||
(finding) =>
|
||||
(finding.status === 'VALID' || finding.status === 'PROVISIONALLY_VALID') &&
|
||||
finding.production_viability !== 'NON_VIABLE',
|
||||
);
|
||||
|
||||
const outcome = await withTemporaryFindings(input.artifactRoot, source.findings, async (findingsDir) => {
|
||||
const expectedIds = expected.map((finding) => finding.id);
|
||||
const collector = createCalibrationCollector({ findingsDir, expectedIds });
|
||||
const systemPrompt =
|
||||
'You calibrate report-only risk scores. Do not change exported severity, status, or export eligibility.';
|
||||
const primary = await runCollectorSession(
|
||||
() =>
|
||||
runtime.executor.run<void>({
|
||||
stage: 'calibrate',
|
||||
role: 'small',
|
||||
cwd: input.repoPath,
|
||||
systemPrompt,
|
||||
userPrompt: prompt,
|
||||
maxTurns: CALIBRATE_MAX_TURNS,
|
||||
timeoutMs: input.timeoutMs,
|
||||
tools: [...runtime.repositoryTools, ...collector.tools],
|
||||
signal: runtime.signal,
|
||||
sessionLabel: 'primary',
|
||||
}),
|
||||
() => collector.getAcceptedIds().length,
|
||||
);
|
||||
let usage = primary.usage;
|
||||
let salvagedTurnLimitCount = primary.salvagedTurnLimit ? 1 : 0;
|
||||
const missing = missingFindings(expected, collector.getAcceptedIds());
|
||||
if (missing.length > 0) {
|
||||
const repair = await runCollectorSession(
|
||||
() =>
|
||||
runtime.executor.run<void>({
|
||||
stage: 'calibrate',
|
||||
role: 'small',
|
||||
cwd: input.repoPath,
|
||||
systemPrompt,
|
||||
userPrompt: renderVerdictRepairPrompt(
|
||||
input,
|
||||
'sast.capella.calibrate',
|
||||
{ ...CALIBRATE_TOOLS, KB_DIR: 'the host-provided context below' },
|
||||
missing,
|
||||
kbContext,
|
||||
),
|
||||
maxTurns: CALIBRATE_MAX_TURNS,
|
||||
timeoutMs: input.timeoutMs,
|
||||
tools: [...runtime.repositoryTools, ...collector.tools],
|
||||
signal: runtime.signal,
|
||||
sessionLabel: 'repair',
|
||||
}),
|
||||
() => collector.getAcceptedIds().length,
|
||||
);
|
||||
usage = addUsage(usage, repair.usage);
|
||||
if (repair.salvagedTurnLimit) salvagedTurnLimitCount += 1;
|
||||
}
|
||||
const calibrations = collector.getCalibrations();
|
||||
const active = await readActiveFindings(findingsDir);
|
||||
const rejected = collector.getRejectionCounts();
|
||||
const reductionCounts = verdictReductionCounts(
|
||||
expectedIds,
|
||||
collector.getAcceptedIds(),
|
||||
active,
|
||||
rejected,
|
||||
salvagedTurnLimitCount,
|
||||
);
|
||||
const value: CalibrateValue = {
|
||||
findings: active.findings,
|
||||
calibratedCount: calibrations.length,
|
||||
rejectedUnexpectedCount: rejected.unexpected,
|
||||
rejectedDuplicateCount: rejected.duplicate,
|
||||
...(verdictWasReduced(reductionCounts) && {
|
||||
reduction: { stage: 'calibrate', reason: 'incomplete_calibrate', ...reductionCounts },
|
||||
}),
|
||||
};
|
||||
return { usage, value };
|
||||
});
|
||||
return completeStage(input, 'calibrate', fingerprint, outcome.usage, outcome.value, identity, startedAt);
|
||||
}
|
||||
@@ -0,0 +1,859 @@
|
||||
// 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 { createHash } from 'node:crypto';
|
||||
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 {
|
||||
buildRunInputFingerprint,
|
||||
recordRunFailure,
|
||||
recordStageUsageAccounting,
|
||||
repositoryIdentity,
|
||||
stableJson,
|
||||
} from '../artifacts.js';
|
||||
import {
|
||||
CapellaRetryableError,
|
||||
ConfigurationError,
|
||||
capellaClassifiedFailureCode,
|
||||
capellaFailureCode,
|
||||
InvalidInputError,
|
||||
SastContractError,
|
||||
} from '../errors.js';
|
||||
import { capellaSafeFailureMessage } from '../safe-failures.js';
|
||||
import { runArchitectureStage, runPlanStage, runThreatModelStage } from '../stages/architecture.js';
|
||||
import { runExportStage } from '../stages/export.js';
|
||||
import { runResearchStage } from '../stages/research.js';
|
||||
import {
|
||||
runCalibrateStage,
|
||||
runConfirmStage,
|
||||
runCriticStage,
|
||||
runDedupeStage,
|
||||
runReviewStage,
|
||||
} from '../stages/verdicts.js';
|
||||
import { createCapellaRepositoryTools } from '../tools/repository-tools.js';
|
||||
import type { CapellaStageInput, CapellaStageRuntime, CompletedStage, StageUsageSummary } from '../types.js';
|
||||
import { usageAccountingWarning, ZERO_CAPELLA_USAGE } from '../types.js';
|
||||
import {
|
||||
CAPELLA_ACTIVITY_POLICIES,
|
||||
type CapellaActivityFailureDetails,
|
||||
type CapellaActivityInput,
|
||||
type CapellaActivityResult,
|
||||
type CapellaArchitectureActivityResult,
|
||||
type CapellaCalibrateActivityResult,
|
||||
type CapellaConfirmActivityResult,
|
||||
type CapellaCriticActivityResult,
|
||||
type CapellaDedupeActivityResult,
|
||||
type CapellaExportActivityInput,
|
||||
type CapellaExportActivityResult,
|
||||
type CapellaFindingActivityInput,
|
||||
type CapellaKnowledgeFindingActivityInput,
|
||||
type CapellaPlanActivityInput,
|
||||
type CapellaPlanActivityResult,
|
||||
type CapellaResearchActivityInput,
|
||||
type CapellaResearchActivityResult,
|
||||
type CapellaReviewActivityResult,
|
||||
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;
|
||||
const USAGE_RECORD_SCHEMA_VERSION = 1;
|
||||
|
||||
/**
|
||||
* Scan-local directories that the pentest writes into the target repository while Capella is
|
||||
* reading it. Each pattern denies the directory and everything beneath it. These are
|
||||
* confinement-only: they never join the user's avoid rules, prompts, export filtering, input
|
||||
* fingerprints, or public configuration.
|
||||
*/
|
||||
const CONFINEMENT_ONLY_DENIED_PATHS: readonly string[] = Object.freeze(['.shannon/**', '.playwright/**']);
|
||||
|
||||
interface UsageRecordIdentity {
|
||||
readonly inputFingerprint: string;
|
||||
readonly stage: CapellaStage;
|
||||
readonly executionKey: string;
|
||||
readonly attempt: number;
|
||||
readonly workloadId: string;
|
||||
readonly sessionNumber: number;
|
||||
}
|
||||
|
||||
interface StartedUsageRecord extends UsageRecordIdentity {
|
||||
readonly schemaVersion: 1;
|
||||
readonly state: 'started';
|
||||
}
|
||||
|
||||
interface FinalUsageRecord extends UsageRecordIdentity {
|
||||
readonly schemaVersion: 1;
|
||||
readonly state: 'final';
|
||||
readonly usage: CapellaUsage;
|
||||
readonly complete: boolean;
|
||||
}
|
||||
|
||||
interface ActivityAttemptRecord {
|
||||
readonly schemaVersion: 1;
|
||||
readonly state: 'activity-attempt';
|
||||
readonly inputFingerprint: string;
|
||||
readonly stage: CapellaStage;
|
||||
readonly executionKey: string;
|
||||
readonly attempt: number;
|
||||
}
|
||||
|
||||
interface ClassifiedActivityError {
|
||||
readonly type: string;
|
||||
readonly code: string;
|
||||
readonly retryable: boolean;
|
||||
readonly message: string;
|
||||
}
|
||||
|
||||
type StageRunner<T> = (runtime: CapellaStageRuntime) => Promise<CompletedStage<T>>;
|
||||
|
||||
function addUsage(left: CapellaUsage, right: CapellaUsage): CapellaUsage {
|
||||
return {
|
||||
inputTokens: left.inputTokens + right.inputTokens,
|
||||
outputTokens: left.outputTokens + right.outputTokens,
|
||||
cacheReadTokens: left.cacheReadTokens + right.cacheReadTokens,
|
||||
cacheWriteTokens: left.cacheWriteTokens + right.cacheWriteTokens,
|
||||
costUsd: left.costUsd + right.costUsd,
|
||||
turns: left.turns + right.turns,
|
||||
};
|
||||
}
|
||||
|
||||
function isUsage(value: unknown): value is CapellaUsage {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
||||
const record = value as Record<string, unknown>;
|
||||
const integerFields = ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens', 'turns'];
|
||||
return (
|
||||
integerFields.every((field) => Number.isSafeInteger(record[field]) && Number(record[field]) >= 0) &&
|
||||
typeof record.costUsd === 'number' &&
|
||||
Number.isFinite(record.costUsd) &&
|
||||
record.costUsd >= 0
|
||||
);
|
||||
}
|
||||
|
||||
function sha256Parts(...parts: readonly string[]): string {
|
||||
const hash = createHash('sha256');
|
||||
for (const part of parts) {
|
||||
hash.update(part);
|
||||
hash.update('\0');
|
||||
}
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
function usageRecordDirectory(artifactRoot: string, identity: UsageRecordIdentity): string {
|
||||
return resolve(
|
||||
artifactRoot,
|
||||
'.usage',
|
||||
identity.inputFingerprint,
|
||||
identity.stage,
|
||||
identity.executionKey,
|
||||
`attempt-${identity.attempt}`,
|
||||
);
|
||||
}
|
||||
|
||||
function usageRecordPath(artifactRoot: string, identity: UsageRecordIdentity, state: 'started' | 'final'): string {
|
||||
return resolve(
|
||||
usageRecordDirectory(artifactRoot, identity),
|
||||
`${identity.workloadId}-${identity.sessionNumber}.${state}.json`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write-once ledger entry. Opening with 'wx' plus a byte comparison on EEXIST makes
|
||||
* retries idempotent: an identical replay is adopted silently, while different bytes
|
||||
* under the same identity mean two executions claimed one slot, which is terminal.
|
||||
* Transient I/O surfaces as retryable so Temporal re-drives the attempt.
|
||||
*/
|
||||
async function writeImmutableUsageRecord(
|
||||
artifactRoot: string,
|
||||
identity: UsageRecordIdentity,
|
||||
record: StartedUsageRecord | FinalUsageRecord,
|
||||
): Promise<void> {
|
||||
const directory = usageRecordDirectory(artifactRoot, identity);
|
||||
const path = usageRecordPath(artifactRoot, identity, record.state);
|
||||
const bytes = stableJson(record);
|
||||
await mkdir(directory, { recursive: true });
|
||||
let handle: Awaited<ReturnType<typeof open>> | undefined;
|
||||
try {
|
||||
handle = await open(path, 'wx', 0o600);
|
||||
await handle.writeFile(bytes);
|
||||
await handle.sync();
|
||||
await handle.close();
|
||||
handle = undefined;
|
||||
} catch (error) {
|
||||
await handle?.close().catch(() => undefined);
|
||||
if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
|
||||
let existing: string;
|
||||
try {
|
||||
existing = await readFile(path, 'utf8');
|
||||
} catch {
|
||||
throw new CapellaRetryableError('Capella usage record could not be verified', 'USAGE_LEDGER_IO');
|
||||
}
|
||||
if (existing === bytes) return;
|
||||
throw new SastContractError('Capella usage record conflicts with immutable bytes', 'USAGE_RECORD_CONFLICT');
|
||||
}
|
||||
throw new CapellaRetryableError('Capella usage record could not be published', 'USAGE_LEDGER_IO');
|
||||
}
|
||||
}
|
||||
|
||||
/** Same write-once discipline as usage records, keyed by activity attempt so retries stay visible in the ledger. */
|
||||
async function writeActivityAttemptRecord(artifactRoot: string, record: ActivityAttemptRecord): Promise<void> {
|
||||
const directory = resolve(artifactRoot, '.usage', record.inputFingerprint, record.stage, 'activity-attempts');
|
||||
const path = resolve(directory, `${record.executionKey}-${record.attempt}.activity-attempt.json`);
|
||||
const bytes = stableJson(record);
|
||||
await mkdir(directory, { recursive: true });
|
||||
let handle: Awaited<ReturnType<typeof open>> | undefined;
|
||||
try {
|
||||
handle = await open(path, 'wx', 0o600);
|
||||
await handle.writeFile(bytes);
|
||||
await handle.sync();
|
||||
await handle.close();
|
||||
handle = undefined;
|
||||
} catch (error) {
|
||||
await handle?.close().catch(() => undefined);
|
||||
if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
|
||||
let existing: string;
|
||||
try {
|
||||
existing = await readFile(path, 'utf8');
|
||||
} catch {
|
||||
throw new CapellaRetryableError('Capella attempt record could not be verified', 'ATTEMPT_LEDGER_IO');
|
||||
}
|
||||
if (existing === bytes) return;
|
||||
throw new SastContractError('Capella attempt record conflicts with immutable bytes', 'ATTEMPT_RECORD_CONFLICT');
|
||||
}
|
||||
throw new CapellaRetryableError('Capella attempt record could not be published', 'ATTEMPT_LEDGER_IO');
|
||||
}
|
||||
}
|
||||
|
||||
function isFinalUsageRecord(value: unknown, inputFingerprint: string, stage: CapellaStage): value is FinalUsageRecord {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
||||
const record = value as Record<string, unknown>;
|
||||
return (
|
||||
record.schemaVersion === USAGE_RECORD_SCHEMA_VERSION &&
|
||||
record.state === 'final' &&
|
||||
record.inputFingerprint === inputFingerprint &&
|
||||
record.stage === stage &&
|
||||
typeof record.executionKey === 'string' &&
|
||||
/^[0-9a-f]{32}$/.test(record.executionKey) &&
|
||||
Number.isSafeInteger(record.attempt) &&
|
||||
Number(record.attempt) >= 1 &&
|
||||
typeof record.workloadId === 'string' &&
|
||||
/^[0-9a-f]{32}$/.test(record.workloadId) &&
|
||||
Number.isSafeInteger(record.sessionNumber) &&
|
||||
Number(record.sessionNumber) >= 1 &&
|
||||
typeof record.complete === 'boolean' &&
|
||||
isUsage(record.usage)
|
||||
);
|
||||
}
|
||||
|
||||
async function collectFiles(directory: string): Promise<string[]> {
|
||||
let entries: Dirent<string>[];
|
||||
try {
|
||||
entries = await readdir(directory, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [];
|
||||
throw error;
|
||||
}
|
||||
|
||||
const files: string[] = [];
|
||||
for (const entry of entries) {
|
||||
const child = resolve(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...(await collectFiles(child)));
|
||||
} else if (entry.isFile()) {
|
||||
files.push(child);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold the stage's ledger into a spend summary. `complete` requires a valid, matching
|
||||
* final record for every started one. `retried` reports whether more than one activity
|
||||
* attempt touched the stage; callers downgrade usageComplete on any retry because an
|
||||
* attempt that died mid-session cannot prove its spend was fully captured.
|
||||
*/
|
||||
async function aggregateStageUsage(
|
||||
artifactRoot: string,
|
||||
inputFingerprint: string,
|
||||
stage: CapellaStage,
|
||||
): Promise<StageUsageSummary> {
|
||||
const root = resolve(artifactRoot, '.usage', inputFingerprint, stage);
|
||||
const files = await collectFiles(root);
|
||||
const started = files.filter((path) => path.endsWith('.started.json'));
|
||||
const final = files.filter((path) => path.endsWith('.final.json'));
|
||||
const activityAttempts = files.filter((path) => path.endsWith('.activity-attempt.json'));
|
||||
let usage = ZERO_CAPELLA_USAGE;
|
||||
let complete = started.length === final.length;
|
||||
let retried = activityAttempts.length > 1;
|
||||
|
||||
for (const path of activityAttempts.sort()) {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(await readFile(path, 'utf8'));
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
complete = false;
|
||||
continue;
|
||||
}
|
||||
const record = parsed as Record<string, unknown>;
|
||||
if (
|
||||
record.schemaVersion !== USAGE_RECORD_SCHEMA_VERSION ||
|
||||
record.state !== 'activity-attempt' ||
|
||||
record.inputFingerprint !== inputFingerprint ||
|
||||
record.stage !== stage ||
|
||||
typeof record.executionKey !== 'string' ||
|
||||
!/^[0-9a-f]{32}$/.test(record.executionKey) ||
|
||||
!Number.isSafeInteger(record.attempt) ||
|
||||
Number(record.attempt) < 1
|
||||
) {
|
||||
complete = false;
|
||||
continue;
|
||||
}
|
||||
retried ||= Number(record.attempt) > 1;
|
||||
} catch {
|
||||
complete = false;
|
||||
}
|
||||
}
|
||||
|
||||
for (const path of final.sort()) {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(await readFile(path, 'utf8'));
|
||||
if (!isFinalUsageRecord(parsed, inputFingerprint, stage)) {
|
||||
complete = false;
|
||||
continue;
|
||||
}
|
||||
usage = addUsage(usage, parsed.usage);
|
||||
complete &&= parsed.complete;
|
||||
} catch {
|
||||
complete = false;
|
||||
}
|
||||
}
|
||||
return { usage, complete, retried };
|
||||
}
|
||||
|
||||
function usageFromError(error: unknown): CapellaUsage | undefined {
|
||||
if (error instanceof CapellaAgentError && error.usage) return error.usage;
|
||||
if (!error || typeof error !== 'object' || !('usage' in error)) return undefined;
|
||||
const usage = (error as { readonly usage?: unknown }).usage;
|
||||
return isUsage(usage) ? usage : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decorates the Capella agent executor with ledger writes on both sides of every
|
||||
* session. workloadId identifies the logical model call (stage, role, prompts) across
|
||||
* attempts; sessionNumber separates repeats of that call within one activity attempt.
|
||||
*/
|
||||
class UsageRecordingExecutor implements CapellaAgentExecutor {
|
||||
private readonly sessionCounts = new Map<string, number>();
|
||||
|
||||
constructor(
|
||||
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);
|
||||
const identity: UsageRecordIdentity = { ...this.baseIdentity, workloadId, sessionNumber };
|
||||
const started: StartedUsageRecord = {
|
||||
schemaVersion: USAGE_RECORD_SCHEMA_VERSION,
|
||||
state: 'started',
|
||||
...identity,
|
||||
};
|
||||
await writeImmutableUsageRecord(this.artifactRoot, identity, started);
|
||||
|
||||
const sessionLog: CapellaTraceLog | undefined = this.stageTrace?.forSession(request.sessionLabel);
|
||||
let response: CapellaAgentResponse<T> | undefined;
|
||||
let caught: unknown;
|
||||
try {
|
||||
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);
|
||||
const final: FinalUsageRecord = {
|
||||
schemaVersion: USAGE_RECORD_SCHEMA_VERSION,
|
||||
state: 'final',
|
||||
...identity,
|
||||
usage: response?.usage ?? errorUsage ?? ZERO_CAPELLA_USAGE,
|
||||
complete: response !== undefined || errorUsage !== undefined,
|
||||
};
|
||||
try {
|
||||
await writeImmutableUsageRecord(this.artifactRoot, identity, final);
|
||||
} catch (recordError) {
|
||||
// A ledger failure after a successful session fails the activity: using the
|
||||
// response while its spend went unrecorded would corrupt accounting. When the
|
||||
// session itself already failed, the original error stays primary and the
|
||||
// ledger gap surfaces later as incomplete usage.
|
||||
if (caught === undefined) {
|
||||
const terminal = recordError instanceof SastContractError;
|
||||
throw new CapellaAgentError(
|
||||
terminal ? 'SastContractError' : 'AgentExecutionError',
|
||||
'USAGE_LEDGER_FAILURE',
|
||||
terminal ? 'Capella usage ledger conflicted with immutable bytes.' : 'Capella usage ledger write failed.',
|
||||
!terminal,
|
||||
response?.usage,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (caught !== undefined) throw caught;
|
||||
if (!response) throw new SastContractError('Capella executor returned no response');
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
function classifyActivityError(error: unknown): ClassifiedActivityError {
|
||||
if (error instanceof CapellaAgentError) {
|
||||
return {
|
||||
type: error.name,
|
||||
code: capellaClassifiedFailureCode(error.code, error.providerCategory),
|
||||
retryable: error.retryable,
|
||||
message: capellaSafeFailureMessage(error.name),
|
||||
};
|
||||
}
|
||||
// Matched by name so this layer stays independent of the provider harness's error classes.
|
||||
if (error instanceof Error && error.name === 'AuthenticationError') {
|
||||
return {
|
||||
type: 'AuthenticationError',
|
||||
code: 'AUTHENTICATION',
|
||||
retryable: false,
|
||||
message: capellaSafeFailureMessage('AuthenticationError'),
|
||||
};
|
||||
}
|
||||
if (error instanceof CapellaRetryableError) {
|
||||
return {
|
||||
type: 'AgentExecutionError',
|
||||
code: error.code,
|
||||
retryable: true,
|
||||
message: capellaSafeFailureMessage('AgentExecutionError'),
|
||||
};
|
||||
}
|
||||
// A confinement violation means the model requested a path outside its granted
|
||||
// root; retrying the same request cannot succeed.
|
||||
if (error instanceof Error && error.name === 'ConfinementError') {
|
||||
return {
|
||||
type: 'InvalidInputError',
|
||||
code: 'CONFINEMENT',
|
||||
retryable: false,
|
||||
message: capellaSafeFailureMessage('InvalidInputError'),
|
||||
};
|
||||
}
|
||||
if (error instanceof ConfigurationError) {
|
||||
return {
|
||||
type: 'ConfigurationError',
|
||||
code: error.code,
|
||||
retryable: false,
|
||||
message: capellaSafeFailureMessage('ConfigurationError'),
|
||||
};
|
||||
}
|
||||
if (error instanceof InvalidInputError) {
|
||||
return {
|
||||
type: 'InvalidInputError',
|
||||
code: error.code,
|
||||
retryable: false,
|
||||
message: capellaSafeFailureMessage('InvalidInputError'),
|
||||
};
|
||||
}
|
||||
if (error instanceof SastContractError) {
|
||||
return {
|
||||
type: 'SastContractError',
|
||||
code: error.code,
|
||||
retryable: false,
|
||||
message: capellaSafeFailureMessage('SastContractError'),
|
||||
};
|
||||
}
|
||||
if (error instanceof ApplicationFailure) {
|
||||
const type = error.type ?? 'AgentExecutionError';
|
||||
return {
|
||||
type,
|
||||
code: capellaFailureCode(error, 'APPLICATION_FAILURE'),
|
||||
retryable: !error.nonRetryable,
|
||||
message: capellaSafeFailureMessage(type),
|
||||
};
|
||||
}
|
||||
// Anything unrecognized is presumed transient; the policy's attempt cap bounds the retries.
|
||||
return {
|
||||
type: 'AgentExecutionError',
|
||||
code: capellaFailureCode(error, 'ACTIVITY_FAILURE'),
|
||||
retryable: true,
|
||||
message: capellaSafeFailureMessage('AgentExecutionError'),
|
||||
};
|
||||
}
|
||||
|
||||
function cancellationInCauseChain(error: unknown): CancelledFailure | undefined {
|
||||
let current = error;
|
||||
const seen = new Set<unknown>();
|
||||
let depth = 0;
|
||||
while (current && typeof current === 'object' && !seen.has(current) && depth < 20) {
|
||||
if (current instanceof CancelledFailure) return current;
|
||||
seen.add(current);
|
||||
current = 'cause' in current ? (current as { readonly cause?: unknown }).cause : undefined;
|
||||
depth += 1;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Treat an error as cancellation only when Temporal's own signal has fired. Provider
|
||||
* timeouts and aborted requests can look like cancellation but must stay ordinary
|
||||
* failures, or a timed-out stage would be reported as a cancelled scan.
|
||||
*/
|
||||
function activityCancellation(error: unknown, signal: AbortSignal): CancelledFailure | undefined {
|
||||
if (!signal.aborted) return undefined;
|
||||
return cancellationInCauseChain(signal.reason) ?? cancellationInCauseChain(error);
|
||||
}
|
||||
|
||||
async function stageInputFingerprint(input: CapellaStageInput): Promise<string> {
|
||||
return buildRunInputFingerprint(input, await repositoryIdentity(input.repoPath));
|
||||
}
|
||||
|
||||
async function runStageActivity<T, V>(
|
||||
stage: CapellaStage,
|
||||
input: CapellaStageInput,
|
||||
run: StageRunner<T>,
|
||||
compact: (value: T) => V,
|
||||
): Promise<CapellaActivityResult<V>> {
|
||||
const context = Context.current();
|
||||
const attempt = context.info.attempt;
|
||||
const signal = context.cancellationSignal;
|
||||
const policy = Object.values(CAPELLA_ACTIVITY_POLICIES).find((candidate) => candidate.stage === stage);
|
||||
const maximumAttempts = policy?.retry.maximumAttempts ?? attempt;
|
||||
const startedAt = Date.now();
|
||||
let heartbeatInterval: NodeJS.Timeout | undefined;
|
||||
let inputFingerprint: string | undefined;
|
||||
let stageTrace: CapellaStageTrace | undefined;
|
||||
let completedStageReturned = false;
|
||||
|
||||
// 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 });
|
||||
heartbeatInterval = setInterval(() => {
|
||||
heartbeat({ stage, attempt, elapsedSeconds: Math.floor((Date.now() - startedAt) / 1_000) });
|
||||
}, HEARTBEAT_INTERVAL_MS);
|
||||
}
|
||||
|
||||
const initialCancellation = activityCancellation(signal.reason, signal);
|
||||
if (initialCancellation) throw initialCancellation;
|
||||
|
||||
inputFingerprint = await stageInputFingerprint(input);
|
||||
const fallbackFailure = stage === 'export' ? (input as CapellaExportActivityInput).fallbackFailure : undefined;
|
||||
if (fallbackFailure !== undefined) {
|
||||
await recordRunFailure(input, inputFingerprint, fallbackFailure, true);
|
||||
}
|
||||
const executionKey = sha256Parts(context.info.workflowExecution.runId, context.info.activityId).slice(0, 32);
|
||||
await writeActivityAttemptRecord(input.artifactRoot, {
|
||||
schemaVersion: USAGE_RECORD_SCHEMA_VERSION,
|
||||
state: 'activity-attempt',
|
||||
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],
|
||||
});
|
||||
const result = await run({ executor, repositoryTools, signal });
|
||||
// Stage runners return only after they publish their artifact and run.json completion.
|
||||
// Later accounting or logging failures must not overwrite that terminal success, while
|
||||
// a failed cache verification before this point must still replace stale success state.
|
||||
completedStageReturned = true;
|
||||
// A stage that finished while cancellation raced in must not report success;
|
||||
// the workflow would record a completed stage on a cancelled scan.
|
||||
const completionCancellation = activityCancellation(signal.reason, signal);
|
||||
if (completionCancellation) throw completionCancellation;
|
||||
|
||||
const summary = await aggregateStageUsage(input.artifactRoot, inputFingerprint, stage);
|
||||
// Heal run.json's per-stage figure, written from the successful attempt alone, to the
|
||||
// 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,
|
||||
reused: result.reused,
|
||||
artifact: result.artifact,
|
||||
value: compactValue,
|
||||
attempts: attempt,
|
||||
usage: summary.usage,
|
||||
usageComplete: !summary.retried && summary.complete,
|
||||
};
|
||||
} catch (error) {
|
||||
const cancellation = activityCancellation(error, signal);
|
||||
if (cancellation) {
|
||||
await WorkflowLogger.logAgenticSastCancelled(input.workflowLogPath, stage, attempt, maximumAttempts);
|
||||
throw cancellation;
|
||||
}
|
||||
|
||||
const classified = classifyActivityError(error);
|
||||
let summary: StageUsageSummary = { usage: ZERO_CAPELLA_USAGE, complete: true, retried: attempt > 1 };
|
||||
if (inputFingerprint) {
|
||||
summary = await aggregateStageUsage(input.artifactRoot, inputFingerprint, stage).catch(() => ({
|
||||
usage: ZERO_CAPELLA_USAGE,
|
||||
complete: false,
|
||||
retried: true,
|
||||
}));
|
||||
const terminal = !classified.retryable || attempt >= maximumAttempts;
|
||||
// run.json shows 'failed' only for a terminal failure; a live retry keeps
|
||||
// finalState 'running' so an operator does not read an in-flight recovery as a
|
||||
// dead scan. Best-effort: failing to record the failure must not replace it.
|
||||
const fallbackFailure = stage === 'export' ? (input as CapellaExportActivityInput).fallbackFailure : undefined;
|
||||
if (fallbackFailure !== undefined) {
|
||||
await recordRunFailure(input, inputFingerprint, fallbackFailure, true, undefined, {
|
||||
preserveExistingFailure: true,
|
||||
preserveExistingSuccess: completedStageReturned,
|
||||
}).catch(() => undefined);
|
||||
} else {
|
||||
await recordRunFailure(
|
||||
input,
|
||||
inputFingerprint,
|
||||
{
|
||||
stage,
|
||||
code: classified.code,
|
||||
error: classified.message,
|
||||
attempt,
|
||||
retryable: classified.retryable,
|
||||
},
|
||||
terminal,
|
||||
summary,
|
||||
{ preserveExistingSuccess: completedStageReturned },
|
||||
).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
const stageComplete = !summary.retried && summary.complete;
|
||||
const details: CapellaActivityFailureDetails & { readonly code: string } = {
|
||||
stage,
|
||||
code: classified.code,
|
||||
attempts: attempt,
|
||||
usage: summary.usage,
|
||||
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({
|
||||
message: classified.message,
|
||||
type: classified.type,
|
||||
nonRetryable: !classified.retryable,
|
||||
details: [details],
|
||||
});
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
|
||||
export async function capellaArchitecture(input: CapellaActivityInput): Promise<CapellaArchitectureActivityResult> {
|
||||
return runStageActivity(
|
||||
'architecture',
|
||||
input,
|
||||
(runtime) => runArchitectureStage(input, runtime),
|
||||
(value) => ({
|
||||
componentCount: value.componentCount,
|
||||
...(value.reduction !== undefined && { reduction: value.reduction }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function capellaThreatModel(
|
||||
input: CapellaThreatModelActivityInput,
|
||||
): Promise<CapellaThreatModelActivityResult> {
|
||||
return runStageActivity(
|
||||
'threat-model',
|
||||
input,
|
||||
(runtime) => runThreatModelStage(input, runtime),
|
||||
(value) => ({
|
||||
intent: value.intent,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function capellaPlan(input: CapellaPlanActivityInput): Promise<CapellaPlanActivityResult> {
|
||||
return runStageActivity(
|
||||
'plan',
|
||||
input,
|
||||
(runtime) => runPlanStage(input, runtime),
|
||||
(value) => ({
|
||||
investigationCount: value.investigationCount,
|
||||
...(value.reduction !== undefined && { reduction: value.reduction }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function capellaResearch(input: CapellaResearchActivityInput): Promise<CapellaResearchActivityResult> {
|
||||
return runStageActivity(
|
||||
'research',
|
||||
input,
|
||||
(runtime) => runResearchStage(input, runtime),
|
||||
(value) => ({
|
||||
findingCount: value.findings.length,
|
||||
flaggedFileCount: value.flaggedFiles.length,
|
||||
dispatchedCount: value.dispatchedCount,
|
||||
resumedCount: value.resumedCount,
|
||||
coverage: value.coverage,
|
||||
...(value.reduction !== undefined && { reduction: value.reduction }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function capellaDedupe(input: CapellaFindingActivityInput): Promise<CapellaDedupeActivityResult> {
|
||||
return runStageActivity(
|
||||
'dedupe',
|
||||
input,
|
||||
(runtime) => runDedupeStage(input, runtime),
|
||||
(value) => ({
|
||||
findingCount: value.findings.length,
|
||||
duplicateCount: value.duplicateCount,
|
||||
survivorCount: value.survivorCount,
|
||||
...(value.reduction !== undefined && { reduction: value.reduction }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function capellaReview(input: CapellaFindingActivityInput): Promise<CapellaReviewActivityResult> {
|
||||
return runStageActivity(
|
||||
'review',
|
||||
input,
|
||||
(runtime) => runReviewStage(input, runtime),
|
||||
(value) => ({
|
||||
findingCount: value.findings.length,
|
||||
validCount: value.validCount,
|
||||
provisionalCount: value.provisionalCount,
|
||||
falsePositiveCount: value.falsePositiveCount,
|
||||
...(value.reduction !== undefined && { reduction: value.reduction }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function capellaCritic(input: CapellaKnowledgeFindingActivityInput): Promise<CapellaCriticActivityResult> {
|
||||
return runStageActivity(
|
||||
'critic',
|
||||
input,
|
||||
(runtime) => runCriticStage(input, runtime),
|
||||
(value) => ({
|
||||
findingCount: value.findings.length,
|
||||
viableCount: value.viableCount,
|
||||
...(value.reduction !== undefined && { reduction: value.reduction }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function capellaConfirm(input: CapellaFindingActivityInput): Promise<CapellaConfirmActivityResult> {
|
||||
return runStageActivity(
|
||||
'confirm',
|
||||
input,
|
||||
(runtime) => runConfirmStage(input, runtime),
|
||||
(value) => ({
|
||||
findingCount: value.findings.length,
|
||||
confirmedCount: value.confirmedCount,
|
||||
...(value.reduction !== undefined && { reduction: value.reduction }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function capellaCalibrate(
|
||||
input: CapellaKnowledgeFindingActivityInput,
|
||||
): Promise<CapellaCalibrateActivityResult> {
|
||||
return runStageActivity(
|
||||
'calibrate',
|
||||
input,
|
||||
(runtime) => runCalibrateStage(input, runtime),
|
||||
(value) => ({
|
||||
findingCount: value.findings.length,
|
||||
calibratedCount: value.calibratedCount,
|
||||
...(value.reduction !== undefined && { reduction: value.reduction }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function capellaExport(input: CapellaExportActivityInput): Promise<CapellaExportActivityResult> {
|
||||
return runStageActivity(
|
||||
'export',
|
||||
input,
|
||||
async (runtime) => {
|
||||
let repositoryLabel: string;
|
||||
try {
|
||||
repositoryLabel = basename(await realpath(input.repoPath));
|
||||
} catch {
|
||||
throw new InvalidInputError('Capella repository root does not exist', 'REPOSITORY_UNAVAILABLE');
|
||||
}
|
||||
const stageInput = { ...input, repositoryLabel };
|
||||
return runExportStage(stageInput, runtime.signal);
|
||||
},
|
||||
(value) => ({
|
||||
sarif: value.sarif,
|
||||
findingCount: value.findingCount,
|
||||
coverage: value.coverage,
|
||||
warnings: [...value.warnings],
|
||||
...(value.reduction !== undefined && { reduction: value.reduction }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
// 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.
|
||||
|
||||
/** Workflow-safe contracts shared by the Capella child and its activity registry. */
|
||||
|
||||
import type { ModelRole } from '../../../model-host.js';
|
||||
import type {
|
||||
AgenticSastArchitectureReduction,
|
||||
AgenticSastCalibrateReduction,
|
||||
AgenticSastConfirmReduction,
|
||||
AgenticSastCriticReduction,
|
||||
AgenticSastDedupeReduction,
|
||||
AgenticSastFallbackReduction,
|
||||
AgenticSastPlanReduction,
|
||||
AgenticSastResearchReduction,
|
||||
AgenticSastReviewReduction,
|
||||
CapellaFallbackStage,
|
||||
CapellaStage,
|
||||
CapellaUsage,
|
||||
SarifRef,
|
||||
} from '../../types.js';
|
||||
import { CAPELLA_NON_RETRYABLE_ERROR_TYPES } from '../error-contract.js';
|
||||
import type {
|
||||
ArchitectureValue,
|
||||
CalibrateValue,
|
||||
CapellaArtifactRef,
|
||||
ConfirmValue,
|
||||
CriticValue,
|
||||
DedupeValue,
|
||||
ExportValue,
|
||||
PlanValue,
|
||||
ResearchValue,
|
||||
ReviewValue,
|
||||
ThreatModelValue,
|
||||
} from '../types.js';
|
||||
|
||||
export interface CapellaWorkflowInput {
|
||||
readonly repoPath: string;
|
||||
readonly artifactRoot: string;
|
||||
readonly workflowLogPath: string;
|
||||
readonly promptDir: string;
|
||||
readonly codePathAvoids: readonly string[];
|
||||
readonly codePathFocus: readonly string[];
|
||||
readonly modelSpec: string;
|
||||
readonly capellaFormatVersion: string;
|
||||
readonly promptSetVersion: string;
|
||||
readonly pipelineTestingMode: boolean;
|
||||
}
|
||||
|
||||
export interface CapellaActivityInput extends CapellaWorkflowInput {
|
||||
readonly timeoutMs: number;
|
||||
}
|
||||
|
||||
export interface CapellaThreatModelActivityInput extends CapellaActivityInput {
|
||||
readonly architectureArtifact: CapellaArtifactRef;
|
||||
}
|
||||
|
||||
export interface CapellaPlanActivityInput extends CapellaActivityInput {
|
||||
readonly architectureArtifact: CapellaArtifactRef;
|
||||
readonly threatModelArtifact: CapellaArtifactRef;
|
||||
}
|
||||
|
||||
export interface CapellaResearchActivityInput extends CapellaActivityInput {
|
||||
readonly architectureArtifact: CapellaArtifactRef;
|
||||
readonly planArtifact: CapellaArtifactRef;
|
||||
}
|
||||
|
||||
export interface CapellaFindingActivityInput extends CapellaActivityInput {
|
||||
readonly findingsArtifact: CapellaArtifactRef;
|
||||
}
|
||||
|
||||
export interface CapellaKnowledgeFindingActivityInput extends CapellaFindingActivityInput {
|
||||
readonly architectureArtifact: CapellaArtifactRef;
|
||||
readonly threatModelArtifact: CapellaArtifactRef;
|
||||
}
|
||||
|
||||
export type CapellaExportSourceStage = 'research' | 'dedupe' | 'review' | 'critic' | 'confirm' | 'calibrate';
|
||||
|
||||
export interface CapellaExportActivityInput extends CapellaActivityInput {
|
||||
readonly findingsArtifact?: CapellaArtifactRef;
|
||||
readonly findingsStage?: CapellaExportSourceStage;
|
||||
readonly fallbackReduction?: AgenticSastFallbackReduction;
|
||||
readonly fallbackFailure?: CapellaFallbackFailure;
|
||||
}
|
||||
|
||||
export interface CapellaFallbackFailure {
|
||||
readonly stage: CapellaFallbackStage;
|
||||
readonly code: string;
|
||||
readonly error: string;
|
||||
readonly attempt: number;
|
||||
readonly retryable: boolean;
|
||||
}
|
||||
|
||||
export interface CapellaActivityResult<T> {
|
||||
readonly status: 'completed';
|
||||
readonly durationMs: number;
|
||||
readonly reused: boolean;
|
||||
readonly artifact: CapellaArtifactRef;
|
||||
readonly value: T;
|
||||
readonly attempts: number;
|
||||
readonly usage: CapellaUsage;
|
||||
/** True only when the usage ledger accounts for every session and no retry occurred; false means usage is a lower bound. */
|
||||
readonly usageComplete: boolean;
|
||||
}
|
||||
|
||||
export interface CapellaArchitectureActivityValue {
|
||||
readonly componentCount: ArchitectureValue['componentCount'];
|
||||
readonly reduction?: AgenticSastArchitectureReduction;
|
||||
}
|
||||
|
||||
export interface CapellaThreatModelActivityValue {
|
||||
readonly intent: ThreatModelValue['intent'];
|
||||
}
|
||||
|
||||
export interface CapellaPlanActivityValue {
|
||||
readonly investigationCount: PlanValue['investigationCount'];
|
||||
readonly reduction?: AgenticSastPlanReduction;
|
||||
}
|
||||
|
||||
export interface CapellaResearchActivityValue {
|
||||
readonly findingCount: number;
|
||||
readonly flaggedFileCount: number;
|
||||
readonly dispatchedCount: ResearchValue['dispatchedCount'];
|
||||
readonly resumedCount: ResearchValue['resumedCount'];
|
||||
readonly coverage: ResearchValue['coverage'];
|
||||
// Present only when coverage is 'reduced'. Counts only — no assigned/missing file path crosses
|
||||
// this boundary, so nothing model-authored or path-bearing can reach a public surface.
|
||||
readonly reduction?: AgenticSastResearchReduction;
|
||||
}
|
||||
|
||||
export interface CapellaDedupeActivityValue {
|
||||
readonly findingCount: number;
|
||||
readonly duplicateCount: DedupeValue['duplicateCount'];
|
||||
readonly survivorCount: DedupeValue['survivorCount'];
|
||||
readonly reduction?: AgenticSastDedupeReduction;
|
||||
}
|
||||
|
||||
export interface CapellaReviewActivityValue {
|
||||
readonly findingCount: number;
|
||||
readonly validCount: ReviewValue['validCount'];
|
||||
readonly provisionalCount: ReviewValue['provisionalCount'];
|
||||
readonly falsePositiveCount: ReviewValue['falsePositiveCount'];
|
||||
readonly reduction?: AgenticSastReviewReduction;
|
||||
}
|
||||
|
||||
export interface CapellaCriticActivityValue {
|
||||
readonly findingCount: number;
|
||||
readonly viableCount: CriticValue['viableCount'];
|
||||
readonly reduction?: AgenticSastCriticReduction;
|
||||
}
|
||||
|
||||
export interface CapellaConfirmActivityValue {
|
||||
readonly findingCount: number;
|
||||
readonly confirmedCount: ConfirmValue['confirmedCount'];
|
||||
readonly reduction?: AgenticSastConfirmReduction;
|
||||
}
|
||||
|
||||
export interface CapellaCalibrateActivityValue {
|
||||
readonly findingCount: number;
|
||||
readonly calibratedCount: CalibrateValue['calibratedCount'];
|
||||
readonly reduction?: AgenticSastCalibrateReduction;
|
||||
}
|
||||
|
||||
export interface CapellaExportActivityValue {
|
||||
readonly sarif: SarifRef;
|
||||
readonly findingCount: ExportValue['findingCount'];
|
||||
readonly coverage: ExportValue['coverage'];
|
||||
readonly warnings: readonly string[];
|
||||
readonly reduction?: ExportValue['reduction'];
|
||||
}
|
||||
|
||||
export type CapellaArchitectureActivityResult = CapellaActivityResult<CapellaArchitectureActivityValue>;
|
||||
export type CapellaThreatModelActivityResult = CapellaActivityResult<CapellaThreatModelActivityValue>;
|
||||
export type CapellaPlanActivityResult = CapellaActivityResult<CapellaPlanActivityValue>;
|
||||
export type CapellaResearchActivityResult = CapellaActivityResult<CapellaResearchActivityValue>;
|
||||
export type CapellaDedupeActivityResult = CapellaActivityResult<CapellaDedupeActivityValue>;
|
||||
export type CapellaReviewActivityResult = CapellaActivityResult<CapellaReviewActivityValue>;
|
||||
export type CapellaCriticActivityResult = CapellaActivityResult<CapellaCriticActivityValue>;
|
||||
export type CapellaConfirmActivityResult = CapellaActivityResult<CapellaConfirmActivityValue>;
|
||||
export type CapellaCalibrateActivityResult = CapellaActivityResult<CapellaCalibrateActivityValue>;
|
||||
export type CapellaExportActivityResult = CapellaActivityResult<CapellaExportActivityValue>;
|
||||
|
||||
/**
|
||||
* Bounded failure payload carried as the first `details` entry of the activity's
|
||||
* ApplicationFailure. The child workflow revalidates the shape before trusting it,
|
||||
* so a field added here is ignored until that validator learns it.
|
||||
*/
|
||||
export interface CapellaActivityFailureDetails {
|
||||
readonly stage: CapellaStage;
|
||||
/** Bounded internal or provider-category machine code identifying the classified failure. */
|
||||
readonly code: string;
|
||||
readonly attempts: number;
|
||||
readonly usage: CapellaUsage;
|
||||
readonly usageComplete: boolean;
|
||||
/** Reasons the stage's usage accounting could not be trusted; empty when the ledger reconciled. */
|
||||
readonly warnings: readonly string[];
|
||||
}
|
||||
|
||||
export interface CapellaActivityRegistry {
|
||||
readonly capellaArchitecture: (input: CapellaActivityInput) => Promise<CapellaArchitectureActivityResult>;
|
||||
readonly capellaThreatModel: (input: CapellaThreatModelActivityInput) => Promise<CapellaThreatModelActivityResult>;
|
||||
readonly capellaPlan: (input: CapellaPlanActivityInput) => Promise<CapellaPlanActivityResult>;
|
||||
readonly capellaResearch: (input: CapellaResearchActivityInput) => Promise<CapellaResearchActivityResult>;
|
||||
readonly capellaDedupe: (input: CapellaFindingActivityInput) => Promise<CapellaDedupeActivityResult>;
|
||||
readonly capellaReview: (input: CapellaFindingActivityInput) => Promise<CapellaReviewActivityResult>;
|
||||
readonly capellaCritic: (input: CapellaKnowledgeFindingActivityInput) => Promise<CapellaCriticActivityResult>;
|
||||
readonly capellaConfirm: (input: CapellaFindingActivityInput) => Promise<CapellaConfirmActivityResult>;
|
||||
readonly capellaCalibrate: (input: CapellaKnowledgeFindingActivityInput) => Promise<CapellaCalibrateActivityResult>;
|
||||
readonly capellaExport: (input: CapellaExportActivityInput) => Promise<CapellaExportActivityResult>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ten Capella names inside the worker's frozen activity registry. Worker startup
|
||||
* asserts the registered set against this list, and running workflows refer to
|
||||
* activities by these strings, so a rename breaks resume of in-flight scans.
|
||||
*/
|
||||
export const CAPELLA_ACTIVITY_NAMES = Object.freeze([
|
||||
'capellaArchitecture',
|
||||
'capellaThreatModel',
|
||||
'capellaPlan',
|
||||
'capellaResearch',
|
||||
'capellaDedupe',
|
||||
'capellaReview',
|
||||
'capellaCritic',
|
||||
'capellaConfirm',
|
||||
'capellaCalibrate',
|
||||
'capellaExport',
|
||||
] as const satisfies readonly (keyof CapellaActivityRegistry)[]);
|
||||
|
||||
export { CAPELLA_NON_RETRYABLE_ERROR_TYPES } from '../error-contract.js';
|
||||
|
||||
export interface CapellaActivityPolicy {
|
||||
readonly stage: CapellaStage;
|
||||
readonly startToCloseTimeoutMs: number;
|
||||
readonly scheduleToCloseTimeoutMs: number;
|
||||
/** Null disables heartbeating entirely, including the activity wrapper's background heartbeat loop. */
|
||||
readonly heartbeatTimeoutMs: number | null;
|
||||
readonly retry: {
|
||||
readonly initialIntervalMs: number;
|
||||
readonly maximumIntervalMs: number;
|
||||
readonly backoffCoefficient: number;
|
||||
readonly maximumAttempts: number;
|
||||
readonly nonRetryableErrorTypes: readonly string[];
|
||||
};
|
||||
readonly role: ModelRole | 'small + medium' | 'none';
|
||||
}
|
||||
|
||||
const MINUTE_MS = 60 * 1_000;
|
||||
const HOUR_MS = 60 * MINUTE_MS;
|
||||
|
||||
function policy(
|
||||
stage: CapellaStage,
|
||||
startToCloseTimeoutMs: number,
|
||||
scheduleToCloseTimeoutMs: number,
|
||||
heartbeatTimeoutMs: number | null,
|
||||
maximumAttempts: number,
|
||||
role: ModelRole | 'small + medium' | 'none',
|
||||
): Readonly<CapellaActivityPolicy> {
|
||||
return Object.freeze({
|
||||
stage,
|
||||
startToCloseTimeoutMs,
|
||||
scheduleToCloseTimeoutMs,
|
||||
heartbeatTimeoutMs,
|
||||
retry: Object.freeze({
|
||||
initialIntervalMs: MINUTE_MS,
|
||||
maximumIntervalMs: 5 * MINUTE_MS,
|
||||
backoffCoefficient: 2,
|
||||
maximumAttempts,
|
||||
nonRetryableErrorTypes: CAPELLA_NON_RETRYABLE_ERROR_TYPES,
|
||||
}),
|
||||
role,
|
||||
});
|
||||
}
|
||||
|
||||
export const CAPELLA_ACTIVITY_POLICIES = Object.freeze({
|
||||
capellaArchitecture: policy('architecture', 60 * MINUTE_MS, 60 * MINUTE_MS, 5 * MINUTE_MS, 3, 'large'),
|
||||
capellaThreatModel: policy('threat-model', 30 * MINUTE_MS, 30 * MINUTE_MS, 5 * MINUTE_MS, 2, 'medium'),
|
||||
capellaPlan: policy('plan', 30 * MINUTE_MS, 90 * MINUTE_MS, 5 * MINUTE_MS, 2, 'medium'),
|
||||
capellaResearch: policy('research', 3 * HOUR_MS, 4.5 * HOUR_MS, 5 * MINUTE_MS, 2, 'small + medium'),
|
||||
capellaDedupe: policy('dedupe', 30 * MINUTE_MS, 45 * MINUTE_MS, 5 * MINUTE_MS, 2, 'small'),
|
||||
capellaReview: policy('review', 2 * HOUR_MS, 2 * HOUR_MS, 5 * MINUTE_MS, 2, 'medium'),
|
||||
capellaCritic: policy('critic', 60 * MINUTE_MS, 60 * MINUTE_MS, 5 * MINUTE_MS, 2, 'medium'),
|
||||
capellaConfirm: policy('confirm', 60 * MINUTE_MS, 60 * MINUTE_MS, 5 * MINUTE_MS, 2, 'medium'),
|
||||
capellaCalibrate: policy('calibrate', 45 * MINUTE_MS, 45 * MINUTE_MS, 5 * MINUTE_MS, 2, 'small'),
|
||||
// Export runs no model but writes final artifacts; a heartbeat keeps it cancellable mid-run
|
||||
// instead of letting a cancelled scan keep materializing SARIF for up to its start-to-close.
|
||||
capellaExport: policy('export', 5 * MINUTE_MS, 10 * MINUTE_MS, MINUTE_MS, 2, 'none'),
|
||||
} as const satisfies Readonly<Record<keyof CapellaActivityRegistry, Readonly<CapellaActivityPolicy>>>);
|
||||
|
||||
/** Bounds the whole child pipeline, including every stage's retries and backoff. */
|
||||
export const CAPELLA_CHILD_WORKFLOW_TIMEOUT_MS = 15 * HOUR_MS;
|
||||
@@ -0,0 +1,67 @@
|
||||
// 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 {
|
||||
capellaArchitecture,
|
||||
capellaCalibrate,
|
||||
capellaConfirm,
|
||||
capellaCritic,
|
||||
capellaDedupe,
|
||||
capellaExport,
|
||||
capellaPlan,
|
||||
capellaResearch,
|
||||
capellaReview,
|
||||
capellaThreatModel,
|
||||
} from './activities.js';
|
||||
import { CAPELLA_ACTIVITY_NAMES, type CapellaActivityRegistry } from './activity-types.js';
|
||||
|
||||
const registry = {
|
||||
capellaArchitecture,
|
||||
capellaThreatModel,
|
||||
capellaPlan,
|
||||
capellaResearch,
|
||||
capellaDedupe,
|
||||
capellaReview,
|
||||
capellaCritic,
|
||||
capellaConfirm,
|
||||
capellaCalibrate,
|
||||
capellaExport,
|
||||
} satisfies CapellaActivityRegistry;
|
||||
|
||||
// The satisfies clause proves the shape at compile time; this runtime check catches
|
||||
// the remaining drift risk, the name list and the object literal edited apart, and
|
||||
// stops the worker at module load instead of registering a wrong surface.
|
||||
const registeredNames = Object.keys(registry).sort();
|
||||
const expectedNames = [...CAPELLA_ACTIVITY_NAMES].sort();
|
||||
if (
|
||||
registeredNames.length !== expectedNames.length ||
|
||||
registeredNames.some((name, index) => name !== expectedNames[index])
|
||||
) {
|
||||
throw new Error('Capella activity registry does not match its frozen ten-name contract');
|
||||
}
|
||||
|
||||
/** Frozen production registry containing the ten Capella activity wrappers. */
|
||||
export const capellaActivities: Readonly<CapellaActivityRegistry> = Object.freeze(registry);
|
||||
|
||||
type UnionToIntersection<T> = (T extends unknown ? (value: T) => void : never) extends (value: infer I) => void
|
||||
? I
|
||||
: never;
|
||||
|
||||
/** Merge explicit activity registries and fail before worker startup on any collision. */
|
||||
export function mergeActivityRegistries<const T extends readonly object[]>(
|
||||
...registries: T
|
||||
): Readonly<UnionToIntersection<T[number]>> {
|
||||
const merged: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
|
||||
for (const activityRegistry of registries) {
|
||||
for (const [name, implementation] of Object.entries(activityRegistry)) {
|
||||
if (Object.hasOwn(merged, name)) {
|
||||
throw new Error(`Duplicate Temporal activity registration: ${name}`);
|
||||
}
|
||||
merged[name] = implementation;
|
||||
}
|
||||
}
|
||||
return Object.freeze(merged) as Readonly<UnionToIntersection<T[number]>>;
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
// 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.
|
||||
|
||||
/**
|
||||
* Workflow-safe Capella child.
|
||||
*
|
||||
* Everything reachable as a value import from this module must remain safe for
|
||||
* the Temporal workflow isolate. Activity implementations are imported as types.
|
||||
*/
|
||||
|
||||
import type { ActivityOptions, ChildWorkflowOptions } from '@temporalio/workflow';
|
||||
import {
|
||||
ActivityCancellationType,
|
||||
ApplicationFailure,
|
||||
ChildWorkflowCancellationType,
|
||||
getExternalWorkflowHandle,
|
||||
isCancellation,
|
||||
proxyActivities,
|
||||
workflowInfo,
|
||||
} from '@temporalio/workflow';
|
||||
import { capellaStageProgress } from '../../../../temporal/shared.js';
|
||||
import { isProviderFailureCategory } from '../../../../types/errors.js';
|
||||
import {
|
||||
type AgenticSastFallbackReduction,
|
||||
type AgenticSastReduction,
|
||||
CAPELLA_PROGRESS_STAGES,
|
||||
type CapellaRecoveredFailure,
|
||||
type CapellaRunResult,
|
||||
type CapellaStage,
|
||||
type CapellaUsage,
|
||||
} from '../../types.js';
|
||||
import { capellaSafeFailureMessage } from '../safe-failures.js';
|
||||
import { usageAccountingWarning } from '../types.js';
|
||||
import {
|
||||
CAPELLA_ACTIVITY_POLICIES,
|
||||
CAPELLA_CHILD_WORKFLOW_TIMEOUT_MS,
|
||||
type CapellaActivityFailureDetails,
|
||||
type CapellaActivityInput,
|
||||
type CapellaActivityPolicy,
|
||||
type CapellaActivityRegistry,
|
||||
type CapellaActivityResult,
|
||||
type CapellaExportActivityInput,
|
||||
type CapellaExportActivityResult,
|
||||
type CapellaExportSourceStage,
|
||||
type CapellaFallbackFailure,
|
||||
type CapellaFindingActivityInput,
|
||||
type CapellaKnowledgeFindingActivityInput,
|
||||
type CapellaPlanActivityInput,
|
||||
type CapellaResearchActivityInput,
|
||||
type CapellaThreatModelActivityInput,
|
||||
type CapellaWorkflowInput,
|
||||
} from './activity-types.js';
|
||||
|
||||
const ZERO_USAGE: CapellaUsage = {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
costUsd: 0,
|
||||
turns: 0,
|
||||
};
|
||||
|
||||
export const CAPELLA_CHILD_WORKFLOW_OPTIONS = Object.freeze({
|
||||
workflowExecutionTimeout: CAPELLA_CHILD_WORKFLOW_TIMEOUT_MS,
|
||||
cancellationType: ChildWorkflowCancellationType.WAIT_CANCELLATION_COMPLETED,
|
||||
} as const satisfies Pick<ChildWorkflowOptions, 'workflowExecutionTimeout' | 'cancellationType'>);
|
||||
|
||||
function activityOptions(policy: CapellaActivityPolicy): ActivityOptions {
|
||||
return {
|
||||
startToCloseTimeout: policy.startToCloseTimeoutMs,
|
||||
scheduleToCloseTimeout: policy.scheduleToCloseTimeoutMs,
|
||||
...(policy.heartbeatTimeoutMs === null ? {} : { heartbeatTimeout: policy.heartbeatTimeoutMs }),
|
||||
retry: {
|
||||
initialInterval: policy.retry.initialIntervalMs,
|
||||
maximumInterval: policy.retry.maximumIntervalMs,
|
||||
backoffCoefficient: policy.retry.backoffCoefficient,
|
||||
maximumAttempts: policy.retry.maximumAttempts,
|
||||
nonRetryableErrorTypes: [...policy.retry.nonRetryableErrorTypes],
|
||||
},
|
||||
cancellationType: ActivityCancellationType.WAIT_CANCELLATION_COMPLETED,
|
||||
};
|
||||
}
|
||||
|
||||
// One proxy per activity: options bind at proxy creation, and every stage carries
|
||||
// its own timeout and retry policy.
|
||||
const architectureActivities = proxyActivities<Pick<CapellaActivityRegistry, 'capellaArchitecture'>>(
|
||||
activityOptions(CAPELLA_ACTIVITY_POLICIES.capellaArchitecture),
|
||||
);
|
||||
const threatModelActivities = proxyActivities<Pick<CapellaActivityRegistry, 'capellaThreatModel'>>(
|
||||
activityOptions(CAPELLA_ACTIVITY_POLICIES.capellaThreatModel),
|
||||
);
|
||||
const planActivities = proxyActivities<Pick<CapellaActivityRegistry, 'capellaPlan'>>(
|
||||
activityOptions(CAPELLA_ACTIVITY_POLICIES.capellaPlan),
|
||||
);
|
||||
const researchActivities = proxyActivities<Pick<CapellaActivityRegistry, 'capellaResearch'>>(
|
||||
activityOptions(CAPELLA_ACTIVITY_POLICIES.capellaResearch),
|
||||
);
|
||||
const dedupeActivities = proxyActivities<Pick<CapellaActivityRegistry, 'capellaDedupe'>>(
|
||||
activityOptions(CAPELLA_ACTIVITY_POLICIES.capellaDedupe),
|
||||
);
|
||||
const reviewActivities = proxyActivities<Pick<CapellaActivityRegistry, 'capellaReview'>>(
|
||||
activityOptions(CAPELLA_ACTIVITY_POLICIES.capellaReview),
|
||||
);
|
||||
const criticActivities = proxyActivities<Pick<CapellaActivityRegistry, 'capellaCritic'>>(
|
||||
activityOptions(CAPELLA_ACTIVITY_POLICIES.capellaCritic),
|
||||
);
|
||||
const confirmActivities = proxyActivities<Pick<CapellaActivityRegistry, 'capellaConfirm'>>(
|
||||
activityOptions(CAPELLA_ACTIVITY_POLICIES.capellaConfirm),
|
||||
);
|
||||
const calibrateActivities = proxyActivities<Pick<CapellaActivityRegistry, 'capellaCalibrate'>>(
|
||||
activityOptions(CAPELLA_ACTIVITY_POLICIES.capellaCalibrate),
|
||||
);
|
||||
const exportActivities = proxyActivities<Pick<CapellaActivityRegistry, 'capellaExport'>>(
|
||||
activityOptions(CAPELLA_ACTIVITY_POLICIES.capellaExport),
|
||||
);
|
||||
|
||||
// addUsage and isUsage are duplicated from the activity side on purpose: this module
|
||||
// must stay importable inside the workflow isolate, which rules out sharing a module
|
||||
// that reaches Node APIs. Keep the twins in sync.
|
||||
function addUsage(left: CapellaUsage, right: CapellaUsage): CapellaUsage {
|
||||
return {
|
||||
inputTokens: left.inputTokens + right.inputTokens,
|
||||
outputTokens: left.outputTokens + right.outputTokens,
|
||||
cacheReadTokens: left.cacheReadTokens + right.cacheReadTokens,
|
||||
cacheWriteTokens: left.cacheWriteTokens + right.cacheWriteTokens,
|
||||
costUsd: left.costUsd + right.costUsd,
|
||||
turns: left.turns + right.turns,
|
||||
};
|
||||
}
|
||||
|
||||
function isUsage(value: unknown): value is CapellaUsage {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
||||
const record = value as Record<string, unknown>;
|
||||
const integerFields = ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens', 'turns'];
|
||||
return (
|
||||
integerFields.every((field) => Number.isSafeInteger(record[field]) && Number(record[field]) >= 0) &&
|
||||
typeof record.costUsd === 'number' &&
|
||||
Number.isFinite(record.costUsd) &&
|
||||
record.costUsd >= 0
|
||||
);
|
||||
}
|
||||
|
||||
const FAILURE_CODE_PATTERN = /^[A-Z][A-Z0-9_]{0,63}$/;
|
||||
|
||||
function isFailureCode(value: unknown): value is string {
|
||||
return typeof value === 'string' && (FAILURE_CODE_PATTERN.test(value) || isProviderFailureCategory(value));
|
||||
}
|
||||
|
||||
// Details cross the wire through the payload converter; revalidate the shape rather
|
||||
// than trust the activity's typing.
|
||||
function isActivityFailureDetails(value: unknown): value is CapellaActivityFailureDetails {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
||||
const record = value as Record<string, unknown>;
|
||||
return (
|
||||
typeof record.stage === 'string' &&
|
||||
isFailureCode(record.code) &&
|
||||
Number.isSafeInteger(record.attempts) &&
|
||||
Number(record.attempts) >= 1 &&
|
||||
typeof record.usageComplete === 'boolean' &&
|
||||
Array.isArray(record.warnings) &&
|
||||
record.warnings.every((warning) => typeof warning === 'string') &&
|
||||
isUsage(record.usage)
|
||||
);
|
||||
}
|
||||
|
||||
function applicationFailure(error: unknown): ApplicationFailure | undefined {
|
||||
let current = error;
|
||||
const seen = new Set<unknown>();
|
||||
while (current && typeof current === 'object' && !seen.has(current)) {
|
||||
if (current instanceof ApplicationFailure) return current;
|
||||
seen.add(current);
|
||||
current = 'cause' in current ? (current as { readonly cause?: unknown }).cause : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function hasCancellationInCauseChain(error: unknown): boolean {
|
||||
let current = error;
|
||||
const seen = new Set<unknown>();
|
||||
let depth = 0;
|
||||
while (current instanceof Error && !seen.has(current) && depth < 20) {
|
||||
if (isCancellation(current)) return true;
|
||||
seen.add(current);
|
||||
current = current.cause;
|
||||
depth += 1;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function failureDetails(error: unknown): CapellaActivityFailureDetails | undefined {
|
||||
const details = applicationFailure(error)?.details;
|
||||
const first = details?.[0];
|
||||
return isActivityFailureDetails(first) ? first : undefined;
|
||||
}
|
||||
|
||||
function baseInput(input: CapellaWorkflowInput, policy: CapellaActivityPolicy): CapellaActivityInput {
|
||||
return { ...input, timeoutMs: policy.startToCloseTimeoutMs };
|
||||
}
|
||||
|
||||
interface WorkflowAccumulator {
|
||||
usage: CapellaUsage;
|
||||
usageComplete: boolean;
|
||||
readonly completedStages: CapellaStage[];
|
||||
readonly warnings: string[];
|
||||
// Reduced-coverage summaries in the order stages produce them (research before export).
|
||||
readonly reductions: AgenticSastReduction[];
|
||||
}
|
||||
|
||||
function acceptStage<T>(accumulator: WorkflowAccumulator, stage: CapellaStage, result: CapellaActivityResult<T>): void {
|
||||
accumulator.usage = addUsage(accumulator.usage, result.usage);
|
||||
accumulator.usageComplete &&= result.usageComplete;
|
||||
// A stage that retried or whose ledger was incomplete drives the same warning run.json records
|
||||
// in recordStageUsageAccounting, so a retried-then-succeeded stage names its reason in every
|
||||
// downstream ledger too. succeededResult and the failed result both dedupe and sort warnings.
|
||||
if (!result.usageComplete) {
|
||||
accumulator.warnings.push(usageAccountingWarning(stage));
|
||||
}
|
||||
accumulator.completedStages.push(stage);
|
||||
if (result.value && typeof result.value === 'object' && 'reduction' in result.value) {
|
||||
const reduction = (result.value as { readonly reduction?: AgenticSastReduction }).reduction;
|
||||
if (reduction !== undefined) {
|
||||
const existingIndex = accumulator.reductions.findIndex((entry) => entry.stage === reduction.stage);
|
||||
if (existingIndex >= 0) accumulator.reductions[existingIndex] = reduction;
|
||||
else accumulator.reductions.push(reduction);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function exportInput(
|
||||
input: CapellaWorkflowInput,
|
||||
findingsArtifact?: CapellaExportActivityInput['findingsArtifact'],
|
||||
findingsStage?: CapellaExportSourceStage,
|
||||
fallbackReduction?: AgenticSastFallbackReduction,
|
||||
fallbackFailure?: CapellaFallbackFailure,
|
||||
): CapellaExportActivityInput {
|
||||
return {
|
||||
...baseInput(input, CAPELLA_ACTIVITY_POLICIES.capellaExport),
|
||||
...(findingsArtifact && findingsStage ? { findingsArtifact, findingsStage } : {}),
|
||||
...(fallbackReduction !== undefined && { fallbackReduction }),
|
||||
...(fallbackFailure !== undefined && { fallbackFailure }),
|
||||
};
|
||||
}
|
||||
|
||||
function succeededResult(
|
||||
startedAt: number,
|
||||
accumulator: WorkflowAccumulator,
|
||||
result: CapellaExportActivityResult,
|
||||
recoveredFailure?: CapellaRecoveredFailure,
|
||||
): CapellaRunResult {
|
||||
accumulator.warnings.push(...result.value.warnings);
|
||||
const reductions = [...accumulator.reductions];
|
||||
return {
|
||||
status: 'succeeded',
|
||||
sarif: result.value.sarif,
|
||||
findingCount: result.value.findingCount,
|
||||
// Any reduction (research or export) means the run's static-analysis coverage was reduced.
|
||||
coverage: reductions.length > 0 ? 'reduced' : result.value.coverage,
|
||||
durationMs: Date.now() - startedAt,
|
||||
usage: accumulator.usage,
|
||||
usageComplete: accumulator.usageComplete,
|
||||
warnings: [...new Set(accumulator.warnings)].sort(),
|
||||
...(reductions.length > 0 && { reductions }),
|
||||
...(recoveredFailure !== undefined && { recoveredFailure }),
|
||||
};
|
||||
}
|
||||
|
||||
function acceptFailureDetails(
|
||||
accumulator: WorkflowAccumulator,
|
||||
error: unknown,
|
||||
): CapellaActivityFailureDetails | undefined {
|
||||
const details = failureDetails(error);
|
||||
if (details) {
|
||||
accumulator.usage = addUsage(accumulator.usage, details.usage);
|
||||
accumulator.usageComplete &&= details.usageComplete;
|
||||
accumulator.warnings.push(...details.warnings);
|
||||
} else {
|
||||
accumulator.usageComplete = false;
|
||||
}
|
||||
return details;
|
||||
}
|
||||
|
||||
function failedResult(
|
||||
startedAt: number,
|
||||
accumulator: WorkflowAccumulator,
|
||||
stage: CapellaStage,
|
||||
error: string,
|
||||
errorCode?: string,
|
||||
): CapellaRunResult {
|
||||
return {
|
||||
status: 'failed',
|
||||
failedStage: stage,
|
||||
error,
|
||||
...(errorCode !== undefined && { errorCode }),
|
||||
durationMs: Date.now() - startedAt,
|
||||
usage: accumulator.usage,
|
||||
usageComplete: accumulator.usageComplete,
|
||||
completedStages: [...accumulator.completedStages],
|
||||
warnings: [...new Set(accumulator.warnings)].sort(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Run the isolated ten-stage Capella pipeline and return its bounded result. */
|
||||
export async function capellaWorkflow(input: CapellaWorkflowInput): Promise<CapellaRunResult> {
|
||||
const startedAt = Date.now();
|
||||
const accumulator: WorkflowAccumulator = {
|
||||
usage: ZERO_USAGE,
|
||||
usageComplete: true,
|
||||
completedStages: [],
|
||||
warnings: [],
|
||||
reductions: [],
|
||||
};
|
||||
let currentStage: CapellaStage = 'architecture';
|
||||
const stageStartedAt = new Map<CapellaStage, number>();
|
||||
let lastGoodFindings:
|
||||
| {
|
||||
readonly artifact: CapellaFindingActivityInput['findingsArtifact'];
|
||||
readonly stage: CapellaExportSourceStage;
|
||||
readonly findingCount: number;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
// Capella's activities live in this child's history, so the parent cannot observe them.
|
||||
// Each stage boundary is signalled up instead, which is what puts stage rows in
|
||||
// `shannon status`. Export is skipped: it runs no model, so the parent drops it anyway.
|
||||
const parent = workflowInfo().parent;
|
||||
async function signalStage(stage: CapellaStage, status: 'running' | 'completed' | 'failed'): Promise<void> {
|
||||
if (parent === undefined || !CAPELLA_PROGRESS_STAGES.includes(stage)) return;
|
||||
const startedAt = stageStartedAt.get(stage) ?? Date.now();
|
||||
try {
|
||||
await getExternalWorkflowHandle(parent.workflowId, parent.runId).signal(capellaStageProgress, {
|
||||
stage,
|
||||
status,
|
||||
startedAt,
|
||||
...(status !== 'running' && { durationMs: Date.now() - startedAt }),
|
||||
});
|
||||
} catch {
|
||||
// Progress reporting is cosmetic. A parent that has already closed, or a signal that
|
||||
// cannot be delivered, must never take down a SAST run that is otherwise fine.
|
||||
}
|
||||
}
|
||||
|
||||
/** Opens a stage's span and returns it, so the caller's `currentStage` cursor is a
|
||||
* visible assignment rather than a hidden write from inside this closure. */
|
||||
async function beginStage(stage: CapellaStage): Promise<CapellaStage> {
|
||||
stageStartedAt.set(stage, Date.now());
|
||||
await signalStage(stage, 'running');
|
||||
return stage;
|
||||
}
|
||||
|
||||
async function endStage<T>(stage: CapellaStage, result: CapellaActivityResult<T>): Promise<void> {
|
||||
acceptStage(accumulator, stage, result);
|
||||
await signalStage(stage, 'completed');
|
||||
}
|
||||
|
||||
try {
|
||||
currentStage = await beginStage('architecture');
|
||||
const architecture = await architectureActivities.capellaArchitecture(
|
||||
baseInput(input, CAPELLA_ACTIVITY_POLICIES.capellaArchitecture),
|
||||
);
|
||||
await endStage('architecture', architecture);
|
||||
|
||||
currentStage = await beginStage('threat-model');
|
||||
const threatModelInput: CapellaThreatModelActivityInput = {
|
||||
...baseInput(input, CAPELLA_ACTIVITY_POLICIES.capellaThreatModel),
|
||||
architectureArtifact: architecture.artifact,
|
||||
};
|
||||
const threatModel = await threatModelActivities.capellaThreatModel(threatModelInput);
|
||||
await endStage('threat-model', threatModel);
|
||||
|
||||
currentStage = await beginStage('plan');
|
||||
const planInput: CapellaPlanActivityInput = {
|
||||
...baseInput(input, CAPELLA_ACTIVITY_POLICIES.capellaPlan),
|
||||
architectureArtifact: architecture.artifact,
|
||||
threatModelArtifact: threatModel.artifact,
|
||||
};
|
||||
const plan = await planActivities.capellaPlan(planInput);
|
||||
await endStage('plan', plan);
|
||||
|
||||
if (plan.value.investigationCount === 0) {
|
||||
// Nothing to research: still run export so the scan always ends with a valid,
|
||||
// empty SARIF artifact rather than an absent one.
|
||||
currentStage = await beginStage('export');
|
||||
const exported = await exportActivities.capellaExport(exportInput(input));
|
||||
await endStage('export', exported);
|
||||
return succeededResult(startedAt, accumulator, exported);
|
||||
}
|
||||
|
||||
currentStage = await beginStage('research');
|
||||
const researchInput: CapellaResearchActivityInput = {
|
||||
...baseInput(input, CAPELLA_ACTIVITY_POLICIES.capellaResearch),
|
||||
architectureArtifact: architecture.artifact,
|
||||
planArtifact: plan.artifact,
|
||||
};
|
||||
const research = await researchActivities.capellaResearch(researchInput);
|
||||
await endStage('research', research);
|
||||
lastGoodFindings = { artifact: research.artifact, stage: 'research', findingCount: research.value.findingCount };
|
||||
|
||||
if (research.value.findingCount === 0) {
|
||||
currentStage = await beginStage('export');
|
||||
const exported = await exportActivities.capellaExport(exportInput(input));
|
||||
await endStage('export', exported);
|
||||
return succeededResult(startedAt, accumulator, exported);
|
||||
}
|
||||
|
||||
currentStage = await beginStage('dedupe');
|
||||
const dedupeInput: CapellaFindingActivityInput = {
|
||||
...baseInput(input, CAPELLA_ACTIVITY_POLICIES.capellaDedupe),
|
||||
findingsArtifact: research.artifact,
|
||||
};
|
||||
const dedupe = await dedupeActivities.capellaDedupe(dedupeInput);
|
||||
await endStage('dedupe', dedupe);
|
||||
lastGoodFindings = { artifact: dedupe.artifact, stage: 'dedupe', findingCount: dedupe.value.findingCount };
|
||||
|
||||
currentStage = await beginStage('review');
|
||||
const reviewInput: CapellaFindingActivityInput = {
|
||||
...baseInput(input, CAPELLA_ACTIVITY_POLICIES.capellaReview),
|
||||
findingsArtifact: dedupe.artifact,
|
||||
};
|
||||
const review = await reviewActivities.capellaReview(reviewInput);
|
||||
await endStage('review', review);
|
||||
lastGoodFindings = { artifact: review.artifact, stage: 'review', findingCount: review.value.findingCount };
|
||||
|
||||
let exportArtifact = review.artifact;
|
||||
let exportStage: CapellaExportSourceStage = 'review';
|
||||
const reviewedSurvivors = review.value.validCount + review.value.provisionalCount;
|
||||
if (reviewedSurvivors > 0) {
|
||||
currentStage = await beginStage('critic');
|
||||
const criticInput: CapellaKnowledgeFindingActivityInput = {
|
||||
...baseInput(input, CAPELLA_ACTIVITY_POLICIES.capellaCritic),
|
||||
findingsArtifact: review.artifact,
|
||||
architectureArtifact: architecture.artifact,
|
||||
threatModelArtifact: threatModel.artifact,
|
||||
};
|
||||
const critic = await criticActivities.capellaCritic(criticInput);
|
||||
await endStage('critic', critic);
|
||||
lastGoodFindings = { artifact: critic.artifact, stage: 'critic', findingCount: critic.value.findingCount };
|
||||
|
||||
currentStage = await beginStage('confirm');
|
||||
const confirmInput: CapellaFindingActivityInput = {
|
||||
...baseInput(input, CAPELLA_ACTIVITY_POLICIES.capellaConfirm),
|
||||
findingsArtifact: critic.artifact,
|
||||
};
|
||||
const confirm = await confirmActivities.capellaConfirm(confirmInput);
|
||||
await endStage('confirm', confirm);
|
||||
lastGoodFindings = { artifact: confirm.artifact, stage: 'confirm', findingCount: confirm.value.findingCount };
|
||||
|
||||
currentStage = await beginStage('calibrate');
|
||||
const calibrateInput: CapellaKnowledgeFindingActivityInput = {
|
||||
...baseInput(input, CAPELLA_ACTIVITY_POLICIES.capellaCalibrate),
|
||||
findingsArtifact: confirm.artifact,
|
||||
architectureArtifact: architecture.artifact,
|
||||
threatModelArtifact: threatModel.artifact,
|
||||
};
|
||||
const calibrate = await calibrateActivities.capellaCalibrate(calibrateInput);
|
||||
await endStage('calibrate', calibrate);
|
||||
lastGoodFindings = {
|
||||
artifact: calibrate.artifact,
|
||||
stage: 'calibrate',
|
||||
findingCount: calibrate.value.findingCount,
|
||||
};
|
||||
exportArtifact = calibrate.artifact;
|
||||
exportStage = 'calibrate';
|
||||
}
|
||||
|
||||
currentStage = await beginStage('export');
|
||||
const exported = await exportActivities.capellaExport(exportInput(input, exportArtifact, exportStage));
|
||||
await endStage('export', exported);
|
||||
return succeededResult(startedAt, accumulator, exported);
|
||||
} catch (error) {
|
||||
// Cancellation must escape: absorbing it into a failed result would make a
|
||||
// cancelled scan look like an accepted Capella failure. Everything else becomes a
|
||||
// bounded failed result so the parent can continue the pentest without Capella
|
||||
// findings.
|
||||
if (hasCancellationInCauseChain(error)) throw error;
|
||||
|
||||
const failedStage = currentStage;
|
||||
await signalStage(failedStage, 'failed');
|
||||
const details = acceptFailureDetails(accumulator, error);
|
||||
const safeError = capellaSafeFailureMessage(applicationFailure(error)?.type);
|
||||
if (failedStage === 'export') {
|
||||
return failedResult(startedAt, accumulator, failedStage, safeError, details?.code);
|
||||
}
|
||||
|
||||
const completedBeforeFallback = [...accumulator.completedStages];
|
||||
const fallbackReduction: AgenticSastFallbackReduction = {
|
||||
stage: failedStage,
|
||||
reason: 'failed_stage_fallback',
|
||||
fallbackFindingCount: lastGoodFindings?.findingCount ?? 0,
|
||||
};
|
||||
const failedApplication = applicationFailure(error);
|
||||
const fallbackFailure: CapellaFallbackFailure = {
|
||||
stage: failedStage,
|
||||
code: details?.code ?? 'ACTIVITY_FAILURE',
|
||||
error: safeError,
|
||||
attempt: details?.attempts ?? 1,
|
||||
retryable: failedApplication === undefined || !failedApplication.nonRetryable,
|
||||
};
|
||||
accumulator.reductions.push(fallbackReduction);
|
||||
|
||||
try {
|
||||
const fallbackExport = await exportActivities.capellaExport(
|
||||
exportInput(input, lastGoodFindings?.artifact, lastGoodFindings?.stage, fallbackReduction, fallbackFailure),
|
||||
);
|
||||
await endStage('export', fallbackExport);
|
||||
const recoveredFailure: CapellaRecoveredFailure = {
|
||||
failedStage,
|
||||
error: safeError,
|
||||
...(details !== undefined && { errorCode: details.code }),
|
||||
completedStages: completedBeforeFallback,
|
||||
};
|
||||
return succeededResult(startedAt, accumulator, fallbackExport, recoveredFailure);
|
||||
} catch (fallbackError) {
|
||||
if (hasCancellationInCauseChain(fallbackError)) throw fallbackError;
|
||||
acceptFailureDetails(accumulator, fallbackError);
|
||||
return failedResult(startedAt, accumulator, failedStage, safeError, details?.code);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,511 @@
|
||||
// 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 { type Dir, type Dirent, constants as fsConstants, type Stats } from 'node:fs';
|
||||
import { type FileHandle, lstat, open, opendir, realpath } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
const DEFAULT_OPERATION_TIMEOUT_MS = 2_000;
|
||||
const DEFAULT_MAX_DEPTH = 32;
|
||||
const DEFAULT_MAX_ENTRIES = 10_000;
|
||||
const DEFAULT_MAX_FILE_BYTES = 1_048_576;
|
||||
const MAX_OPERATION_TIMEOUT_MS = 60_000;
|
||||
const MAX_ENUMERATION_DEPTH = 64;
|
||||
const MAX_ENUMERATION_ENTRIES = 100_000;
|
||||
const MAX_CONFIGURED_FILE_BYTES = 8 * 1_048_576;
|
||||
const MAX_DENY_RULES = 1_000;
|
||||
const MAX_DENY_LENGTH = 1_024;
|
||||
|
||||
/** Stable reasons returned by confined tools without disclosing host paths. */
|
||||
export type ConfinementErrorCode =
|
||||
| 'ABORTED'
|
||||
| 'DENIED'
|
||||
| 'ESCAPE'
|
||||
| 'INVALID_PATH'
|
||||
| 'NOT_FOUND'
|
||||
| 'NOT_REGULAR_FILE'
|
||||
| 'NOT_DIRECTORY'
|
||||
| 'RACE_DETECTED'
|
||||
| 'TOO_LARGE'
|
||||
| 'TOO_MANY_ENTRIES'
|
||||
| 'TOO_DEEP'
|
||||
| 'TIMED_OUT';
|
||||
|
||||
/** A bounded confinement failure whose message never contains a requested or host path. */
|
||||
export class ConfinementError extends Error {
|
||||
override readonly name = 'ConfinementError';
|
||||
|
||||
constructor(
|
||||
readonly code: ConfinementErrorCode,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export interface RepositoryConfinementOptions {
|
||||
readonly repositoryRoot: string;
|
||||
readonly deniedPaths?: readonly string[];
|
||||
readonly operationTimeoutMs?: number;
|
||||
readonly maxDepth?: number;
|
||||
readonly maxEntries?: number;
|
||||
readonly maxFileBytes?: number;
|
||||
}
|
||||
|
||||
export interface ConfinedFile {
|
||||
readonly bytes: Buffer;
|
||||
readonly path: string;
|
||||
readonly truncated: boolean;
|
||||
}
|
||||
|
||||
export interface ConfinedEntry {
|
||||
readonly absolutePath: string;
|
||||
readonly path: string;
|
||||
readonly dirent: Dirent;
|
||||
}
|
||||
|
||||
interface CompiledDeny {
|
||||
readonly regex: RegExp;
|
||||
}
|
||||
|
||||
export interface OperationBudget {
|
||||
readonly deadline: number;
|
||||
readonly signal?: AbortSignal;
|
||||
}
|
||||
|
||||
function confinementError(code: ConfinementErrorCode): ConfinementError {
|
||||
switch (code) {
|
||||
case 'ABORTED':
|
||||
return new ConfinementError(code, 'Repository operation cancelled.');
|
||||
case 'DENIED':
|
||||
return new ConfinementError(code, 'Repository path is denied by scan policy.');
|
||||
case 'ESCAPE':
|
||||
return new ConfinementError(code, 'Repository path escapes the configured root.');
|
||||
case 'INVALID_PATH':
|
||||
return new ConfinementError(code, 'Repository path is invalid.');
|
||||
case 'NOT_FOUND':
|
||||
return new ConfinementError(code, 'Repository path does not exist.');
|
||||
case 'NOT_REGULAR_FILE':
|
||||
return new ConfinementError(code, 'Repository path is not a regular file.');
|
||||
case 'NOT_DIRECTORY':
|
||||
return new ConfinementError(code, 'Repository search root is not a directory.');
|
||||
case 'RACE_DETECTED':
|
||||
return new ConfinementError(code, 'Repository path changed during access.');
|
||||
case 'TOO_LARGE':
|
||||
return new ConfinementError(code, 'Repository file exceeds the bounded read limit.');
|
||||
case 'TOO_MANY_ENTRIES':
|
||||
return new ConfinementError(code, 'Repository enumeration exceeded its entry limit.');
|
||||
case 'TOO_DEEP':
|
||||
return new ConfinementError(code, 'Repository enumeration exceeded its depth limit.');
|
||||
case 'TIMED_OUT':
|
||||
return new ConfinementError(code, 'Repository operation exceeded its time limit.');
|
||||
}
|
||||
}
|
||||
|
||||
function toPosix(value: string): string {
|
||||
return value.split(path.sep).join('/');
|
||||
}
|
||||
|
||||
/** Lexical containment only; callers pair it with realpath to defeat symlinks. */
|
||||
function isWithin(root: string, candidate: string): boolean {
|
||||
const relativePath = path.relative(root, candidate);
|
||||
if (relativePath === '') return true;
|
||||
if (relativePath === '..' || relativePath.startsWith(`..${path.sep}`)) return false;
|
||||
return !path.isAbsolute(relativePath);
|
||||
}
|
||||
|
||||
function escapeRegex(character: string): string {
|
||||
return /[\\^$+?.()|{}[\]]/u.test(character) ? `\\${character}` : character;
|
||||
}
|
||||
|
||||
function globToRegexSource(pattern: string): string {
|
||||
let source = '';
|
||||
for (let index = 0; index < pattern.length; index += 1) {
|
||||
const character = pattern[index];
|
||||
if (character === '*') {
|
||||
if (pattern[index + 1] === '*') {
|
||||
while (pattern[index + 1] === '*') index += 1;
|
||||
if (pattern[index + 1] === '/') {
|
||||
index += 1;
|
||||
source += '(?:.*/)?';
|
||||
} else {
|
||||
source += '.*';
|
||||
}
|
||||
} else {
|
||||
source += '[^/]*';
|
||||
}
|
||||
} else if (character === '?') {
|
||||
source += '[^/]';
|
||||
} else {
|
||||
source += escapeRegex(character ?? '');
|
||||
}
|
||||
}
|
||||
return source;
|
||||
}
|
||||
|
||||
function compileDeny(rawValue: string, realRoot: string): CompiledDeny | undefined {
|
||||
const trimmed = rawValue.trim();
|
||||
if (!trimmed) return undefined;
|
||||
if (trimmed.length > MAX_DENY_LENGTH || trimmed.includes('\0') || trimmed.includes('\\')) {
|
||||
throw confinementError('INVALID_PATH');
|
||||
}
|
||||
|
||||
let normalized = trimmed
|
||||
.replace(/^\.\//u, '')
|
||||
.replace(/\/{2,}/gu, '/')
|
||||
.replace(/\/$/u, '');
|
||||
if (path.isAbsolute(normalized)) {
|
||||
const relativePath = path.relative(realRoot, path.resolve(normalized));
|
||||
// An absolute deny that resolves outside the repository root cannot match
|
||||
// anything inside the jail, so it is inert and dropped rather than rejected.
|
||||
if (!isWithin(realRoot, path.resolve(normalized))) return undefined;
|
||||
normalized = toPosix(relativePath);
|
||||
}
|
||||
|
||||
const segments = normalized.split('/');
|
||||
if (segments.some((segment) => segment === '..' || segment === '')) throw confinementError('INVALID_PATH');
|
||||
|
||||
const containsGlob = normalized.includes('*') || normalized.includes('?');
|
||||
if (containsGlob) {
|
||||
const source = globToRegexSource(normalized);
|
||||
if (normalized.endsWith('/**')) {
|
||||
const directorySource = globToRegexSource(normalized.slice(0, -3));
|
||||
return { regex: new RegExp(`^(?:${directorySource}|${source})$`, 'u') };
|
||||
}
|
||||
return { regex: new RegExp(`^(?:${source})$`, 'u') };
|
||||
}
|
||||
|
||||
const escaped = normalized
|
||||
.split('')
|
||||
.map((character) => escapeRegex(character))
|
||||
.join('');
|
||||
const prefix = normalized.includes('/') ? '' : '(?:.*/)?';
|
||||
return { regex: new RegExp(`^${prefix}${escaped}(?:/.*)?$`, 'u') };
|
||||
}
|
||||
|
||||
/** Out-of-range options are rejected outright; clamping would silently weaken a configured bound. */
|
||||
function boundedOption(value: number | undefined, fallback: number, maximum: number): number {
|
||||
if (value === undefined) return fallback;
|
||||
if (!Number.isInteger(value) || value < 1 || value > maximum) throw confinementError('INVALID_PATH');
|
||||
return value;
|
||||
}
|
||||
|
||||
function sameIdentity(left: Stats, right: Stats): boolean {
|
||||
return left.dev === right.dev && left.ino === right.ino && left.isFile() && right.isFile();
|
||||
}
|
||||
|
||||
function sameEntryIdentity(left: Stats, right: Stats): boolean {
|
||||
const sameType = (left.isFile() && right.isFile()) || (left.isDirectory() && right.isDirectory());
|
||||
return left.dev === right.dev && left.ino === right.ino && sameType;
|
||||
}
|
||||
|
||||
async function lstatForConfinement(target: string, code: ConfinementErrorCode): Promise<Stats> {
|
||||
try {
|
||||
return await lstat(target);
|
||||
} catch {
|
||||
throw confinementError(code);
|
||||
}
|
||||
}
|
||||
|
||||
async function realpathForConfinement(target: string, code: ConfinementErrorCode): Promise<string> {
|
||||
try {
|
||||
return await realpath(target);
|
||||
} catch {
|
||||
throw confinementError(code);
|
||||
}
|
||||
}
|
||||
|
||||
function validateRequestedPath(requestedPath: string, allowRoot: boolean): string {
|
||||
if (requestedPath.includes('\0') || requestedPath.includes('\\') || path.isAbsolute(requestedPath)) {
|
||||
throw confinementError('INVALID_PATH');
|
||||
}
|
||||
|
||||
const normalized = requestedPath || '.';
|
||||
const segments = normalized.split('/');
|
||||
if (segments.some((segment) => segment === '..' || segment === '')) {
|
||||
throw confinementError('INVALID_PATH');
|
||||
}
|
||||
if (!allowRoot && segments.every((segment) => segment === '.')) {
|
||||
throw confinementError('INVALID_PATH');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/** Realpath-backed, deny-aware repository view shared by every Capella read tool. */
|
||||
export class RepositoryConfinement {
|
||||
private constructor(
|
||||
readonly root: string,
|
||||
private readonly denies: readonly CompiledDeny[],
|
||||
private readonly operationTimeoutMs: number,
|
||||
private readonly maxDepth: number,
|
||||
private readonly maxEntries: number,
|
||||
private readonly maxFileBytes: number,
|
||||
) {}
|
||||
|
||||
static async create(options: RepositoryConfinementOptions): Promise<RepositoryConfinement> {
|
||||
if ((options.deniedPaths?.length ?? 0) > MAX_DENY_RULES) throw confinementError('INVALID_PATH');
|
||||
|
||||
let realRoot: string;
|
||||
try {
|
||||
realRoot = await realpath(options.repositoryRoot);
|
||||
} catch {
|
||||
throw confinementError('NOT_FOUND');
|
||||
}
|
||||
|
||||
const rootStats = await lstatForConfinement(realRoot, 'NOT_FOUND');
|
||||
if (!rootStats.isDirectory()) throw confinementError('NOT_DIRECTORY');
|
||||
|
||||
const denies = (options.deniedPaths ?? [])
|
||||
.map((value) => compileDeny(value, realRoot))
|
||||
.filter((value): value is CompiledDeny => value !== undefined);
|
||||
|
||||
return new RepositoryConfinement(
|
||||
realRoot,
|
||||
denies,
|
||||
boundedOption(options.operationTimeoutMs, DEFAULT_OPERATION_TIMEOUT_MS, MAX_OPERATION_TIMEOUT_MS),
|
||||
boundedOption(options.maxDepth, DEFAULT_MAX_DEPTH, MAX_ENUMERATION_DEPTH),
|
||||
boundedOption(options.maxEntries, DEFAULT_MAX_ENTRIES, MAX_ENUMERATION_ENTRIES),
|
||||
boundedOption(options.maxFileBytes, DEFAULT_MAX_FILE_BYTES, MAX_CONFIGURED_FILE_BYTES),
|
||||
);
|
||||
}
|
||||
|
||||
relativePath(absolutePath: string): string {
|
||||
if (!isWithin(this.root, absolutePath)) throw confinementError('ESCAPE');
|
||||
const relativePath = toPosix(path.relative(this.root, absolutePath));
|
||||
return relativePath || '.';
|
||||
}
|
||||
|
||||
isDenied(relativePath: string): boolean {
|
||||
const normalized = relativePath.replace(/^\.\//u, '');
|
||||
return this.denies.some(({ regex }) => regex.test(normalized));
|
||||
}
|
||||
|
||||
createBudget(signal?: AbortSignal): OperationBudget {
|
||||
return {
|
||||
deadline: Date.now() + this.operationTimeoutMs,
|
||||
...(signal && { signal }),
|
||||
};
|
||||
}
|
||||
|
||||
checkBudget(budget: OperationBudget): void {
|
||||
if (budget.signal?.aborted) throw confinementError('ABORTED');
|
||||
if (Date.now() > budget.deadline) throw confinementError('TIMED_OUT');
|
||||
}
|
||||
|
||||
async resolveExisting(requestedPath: string, expectDirectory: boolean, budget?: OperationBudget): Promise<string> {
|
||||
if (budget) this.checkBudget(budget);
|
||||
const normalized = validateRequestedPath(requestedPath, expectDirectory);
|
||||
const lexicalPath = path.resolve(this.root, normalized);
|
||||
if (!isWithin(this.root, lexicalPath)) throw confinementError('ESCAPE');
|
||||
|
||||
let resolvedPath: string;
|
||||
try {
|
||||
resolvedPath = await realpath(lexicalPath);
|
||||
} catch {
|
||||
throw confinementError('NOT_FOUND');
|
||||
}
|
||||
if (budget) this.checkBudget(budget);
|
||||
if (!isWithin(this.root, resolvedPath)) throw confinementError('ESCAPE');
|
||||
|
||||
const requestedRelativePath = this.relativePath(lexicalPath);
|
||||
const relativePath = this.relativePath(resolvedPath);
|
||||
if (this.isDenied(requestedRelativePath) || this.isDenied(relativePath)) throw confinementError('DENIED');
|
||||
|
||||
const stats = await lstatForConfinement(resolvedPath, 'NOT_FOUND');
|
||||
if (budget) this.checkBudget(budget);
|
||||
if (stats.isSymbolicLink()) throw confinementError('RACE_DETECTED');
|
||||
if (expectDirectory && !stats.isDirectory()) throw confinementError('NOT_DIRECTORY');
|
||||
if (!expectDirectory && !stats.isFile()) throw confinementError('NOT_REGULAR_FILE');
|
||||
return resolvedPath;
|
||||
}
|
||||
|
||||
async readFile(requestedPath: string, signal?: AbortSignal, maximumBytes = this.maxFileBytes): Promise<ConfinedFile> {
|
||||
const budget = this.createBudget(signal);
|
||||
this.checkBudget(budget);
|
||||
const resolvedPath = await this.resolveExisting(requestedPath, false, budget);
|
||||
return this.readResolvedFile(resolvedPath, budget, maximumBytes);
|
||||
}
|
||||
|
||||
async readResolvedFile(
|
||||
resolvedPath: string,
|
||||
budget: OperationBudget,
|
||||
maximumBytes = this.maxFileBytes,
|
||||
): Promise<ConfinedFile> {
|
||||
this.checkBudget(budget);
|
||||
if (!isWithin(this.root, resolvedPath)) throw confinementError('ESCAPE');
|
||||
const relativePath = this.relativePath(resolvedPath);
|
||||
if (this.isDenied(relativePath)) throw confinementError('DENIED');
|
||||
|
||||
// TOCTOU defense: lstat before opening, then require the open descriptor,
|
||||
// the re-resolved path, and (on Linux) the descriptor's /proc target to all
|
||||
// agree on one file identity inside the root. A swap at any point reads as
|
||||
// RACE_DETECTED rather than serving bytes from outside the jail.
|
||||
let before: Stats;
|
||||
try {
|
||||
before = await lstat(resolvedPath);
|
||||
} catch {
|
||||
throw confinementError('NOT_FOUND');
|
||||
}
|
||||
if (!before.isFile() || before.isSymbolicLink()) throw confinementError('NOT_REGULAR_FILE');
|
||||
|
||||
let handle: FileHandle | undefined;
|
||||
try {
|
||||
handle = await open(resolvedPath, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
|
||||
const opened = await handle.stat();
|
||||
if (!sameIdentity(before, opened)) throw confinementError('RACE_DETECTED');
|
||||
|
||||
const afterOpenPath = await realpath(resolvedPath);
|
||||
if (afterOpenPath !== resolvedPath || !isWithin(this.root, afterOpenPath)) {
|
||||
throw confinementError('RACE_DETECTED');
|
||||
}
|
||||
|
||||
if (process.platform === 'linux') {
|
||||
try {
|
||||
const descriptorPath = await realpath(`/proc/self/fd/${handle.fd}`);
|
||||
if (descriptorPath !== resolvedPath || !isWithin(this.root, descriptorPath)) {
|
||||
throw confinementError('RACE_DETECTED');
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ConfinementError) throw error;
|
||||
throw confinementError('RACE_DETECTED');
|
||||
}
|
||||
}
|
||||
|
||||
const byteLimit = Math.min(Math.max(1, maximumBytes), this.maxFileBytes);
|
||||
// One byte beyond the limit distinguishes a file exactly at the limit from a truncated one.
|
||||
const output = Buffer.allocUnsafe(byteLimit + 1);
|
||||
let offset = 0;
|
||||
while (offset < output.length) {
|
||||
this.checkBudget(budget);
|
||||
const readResult = await handle.read(output, offset, output.length - offset, offset);
|
||||
if (readResult.bytesRead === 0) break;
|
||||
offset += readResult.bytesRead;
|
||||
}
|
||||
|
||||
const afterRead = await handle.stat();
|
||||
if (!sameIdentity(opened, afterRead)) throw confinementError('RACE_DETECTED');
|
||||
const finalPath = await realpath(resolvedPath);
|
||||
if (finalPath !== resolvedPath || !isWithin(this.root, finalPath)) throw confinementError('RACE_DETECTED');
|
||||
|
||||
const truncated = offset > byteLimit;
|
||||
return {
|
||||
bytes: output.subarray(0, Math.min(offset, byteLimit)),
|
||||
path: relativePath,
|
||||
truncated,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof ConfinementError) throw error;
|
||||
// Unknown filesystem failures surface as RACE_DETECTED so no errno or host path escapes.
|
||||
throw confinementError('RACE_DETECTED');
|
||||
} finally {
|
||||
await handle?.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async enumerate(
|
||||
requestedRoot: string,
|
||||
signal?: AbortSignal,
|
||||
operationBudget?: OperationBudget,
|
||||
): Promise<readonly ConfinedEntry[]> {
|
||||
const budget = operationBudget ?? this.createBudget(signal);
|
||||
this.checkBudget(budget);
|
||||
const searchRoot = await this.resolveExisting(requestedRoot || '.', true, budget);
|
||||
const entries: ConfinedEntry[] = [];
|
||||
const pending: Array<{ absolutePath: string; depth: number }> = [{ absolutePath: searchRoot, depth: 0 }];
|
||||
let visited = 0;
|
||||
|
||||
while (pending.length > 0) {
|
||||
this.checkBudget(budget);
|
||||
const current = pending.pop();
|
||||
if (!current) break;
|
||||
if (current.depth > this.maxDepth) throw confinementError('TOO_DEEP');
|
||||
|
||||
const directoryBefore = await lstatForConfinement(current.absolutePath, 'RACE_DETECTED');
|
||||
this.checkBudget(budget);
|
||||
if (!directoryBefore.isDirectory() || directoryBefore.isSymbolicLink()) {
|
||||
throw confinementError('RACE_DETECTED');
|
||||
}
|
||||
const currentRealPath = await realpathForConfinement(current.absolutePath, 'RACE_DETECTED');
|
||||
if (currentRealPath !== current.absolutePath || !isWithin(this.root, currentRealPath)) {
|
||||
throw confinementError('RACE_DETECTED');
|
||||
}
|
||||
// A denied directory is pruned silently; its subtree simply does not exist to the tools.
|
||||
const currentRelativePath = this.relativePath(currentRealPath);
|
||||
if (this.isDenied(currentRelativePath)) continue;
|
||||
|
||||
let directory: Dir;
|
||||
try {
|
||||
directory = await opendir(currentRealPath);
|
||||
} catch {
|
||||
throw confinementError('RACE_DETECTED');
|
||||
}
|
||||
try {
|
||||
for await (const dirent of directory) {
|
||||
this.checkBudget(budget);
|
||||
visited += 1;
|
||||
if (visited > this.maxEntries) throw confinementError('TOO_MANY_ENTRIES');
|
||||
// Symlinks are skipped, not errors: the jail exposes only what physically lives under the root.
|
||||
if (dirent.isSymbolicLink()) continue;
|
||||
|
||||
const absolutePath = path.join(currentRealPath, dirent.name);
|
||||
const entryBefore = await lstatForConfinement(absolutePath, 'RACE_DETECTED');
|
||||
this.checkBudget(budget);
|
||||
if (entryBefore.isSymbolicLink()) continue;
|
||||
|
||||
const resolvedEntryPath = await realpathForConfinement(absolutePath, 'RACE_DETECTED');
|
||||
this.checkBudget(budget);
|
||||
if (resolvedEntryPath !== absolutePath || !isWithin(this.root, resolvedEntryPath)) {
|
||||
throw confinementError('RACE_DETECTED');
|
||||
}
|
||||
const entryAfter = await lstatForConfinement(resolvedEntryPath, 'RACE_DETECTED');
|
||||
this.checkBudget(budget);
|
||||
if (!sameEntryIdentity(entryBefore, entryAfter)) throw confinementError('RACE_DETECTED');
|
||||
|
||||
const relativePath = this.relativePath(resolvedEntryPath);
|
||||
if (this.isDenied(relativePath)) continue;
|
||||
|
||||
if (entryAfter.isDirectory()) {
|
||||
pending.push({ absolutePath: resolvedEntryPath, depth: current.depth + 1 });
|
||||
} else if (entryAfter.isFile()) {
|
||||
entries.push({ absolutePath: resolvedEntryPath, path: relativePath, dirent });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ConfinementError) throw error;
|
||||
throw confinementError('RACE_DETECTED');
|
||||
} finally {
|
||||
await directory.close().catch(() => undefined);
|
||||
}
|
||||
|
||||
const directoryAfter = await lstatForConfinement(currentRealPath, 'RACE_DETECTED');
|
||||
this.checkBudget(budget);
|
||||
if (!sameEntryIdentity(directoryBefore, directoryAfter)) throw confinementError('RACE_DETECTED');
|
||||
const finalDirectoryPath = await realpathForConfinement(currentRealPath, 'RACE_DETECTED');
|
||||
if (finalDirectoryPath !== currentRealPath || !isWithin(this.root, finalDirectoryPath)) {
|
||||
throw confinementError('RACE_DETECTED');
|
||||
}
|
||||
}
|
||||
|
||||
// Traversal order is a LIFO stack; sorting makes the result deterministic for callers.
|
||||
entries.sort((left, right) => left.path.localeCompare(right.path));
|
||||
return entries;
|
||||
}
|
||||
}
|
||||
|
||||
/** Convert a bounded glob accepted by find/grep into a deterministic matcher. */
|
||||
export function compileRepositoryGlob(pattern: string): RegExp {
|
||||
if (
|
||||
!pattern ||
|
||||
pattern.length > 256 ||
|
||||
pattern.includes('\0') ||
|
||||
pattern.includes('\\') ||
|
||||
path.isAbsolute(pattern) ||
|
||||
pattern.split('/').some((segment) => segment === '..' || segment === '')
|
||||
) {
|
||||
throw confinementError('INVALID_PATH');
|
||||
}
|
||||
return new RegExp(`^${globToRegexSource(pattern)}$`, 'u');
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// 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.
|
||||
|
||||
export {
|
||||
type ConfinedEntry,
|
||||
type ConfinedFile,
|
||||
ConfinementError,
|
||||
type ConfinementErrorCode,
|
||||
type OperationBudget,
|
||||
RepositoryConfinement,
|
||||
type RepositoryConfinementOptions,
|
||||
} from './confinement.js';
|
||||
export {
|
||||
CAPELLA_REPOSITORY_TOOL_NAMES,
|
||||
type CapellaRepositoryToolOptions,
|
||||
createCapellaRepositoryTools,
|
||||
isCapellaRepositoryTool,
|
||||
} from './repository-tools.js';
|
||||
@@ -0,0 +1,377 @@
|
||||
// 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 path from 'node:path';
|
||||
import { defineTool, type ToolDefinition } from '@earendil-works/pi-coding-agent';
|
||||
import { Type } from 'typebox';
|
||||
import type { CapellaTool } from '../../../pi/capella-agent-types.js';
|
||||
import {
|
||||
type ConfinedEntry,
|
||||
type ConfinedFile,
|
||||
ConfinementError,
|
||||
compileRepositoryGlob,
|
||||
type OperationBudget,
|
||||
RepositoryConfinement,
|
||||
type RepositoryConfinementOptions,
|
||||
} from './confinement.js';
|
||||
|
||||
const MAX_READ_OUTPUT_BYTES = 64 * 1024;
|
||||
const MAX_READ_LINES = 1_000;
|
||||
const MAX_FIND_RESULTS = 500;
|
||||
const DEFAULT_FIND_RESULTS = 100;
|
||||
const MAX_GREP_MATCHES = 200;
|
||||
const DEFAULT_GREP_MATCHES = 100;
|
||||
const MAX_GREP_CONTEXT = 10;
|
||||
const MAX_GREP_PATTERN_LENGTH = 256;
|
||||
const MAX_GREP_LINE_BYTES = 8 * 1024;
|
||||
const MAX_GREP_OUTPUT_BYTES = 64 * 1024;
|
||||
const MAX_GREP_SCANNED_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
export const CAPELLA_REPOSITORY_TOOL_NAMES = ['read', 'find', 'grep'] as const;
|
||||
|
||||
// Tool identity is tracked by reference so the executor can verify a read/find/grep
|
||||
// definition came from this confined factory rather than trusting its name.
|
||||
const repositoryTools = new WeakSet<object>();
|
||||
|
||||
export interface CapellaRepositoryToolOptions extends RepositoryConfinementOptions {}
|
||||
|
||||
interface ReadDetails {
|
||||
readonly path: string;
|
||||
readonly truncated: boolean;
|
||||
}
|
||||
|
||||
interface FindDetails {
|
||||
readonly count: number;
|
||||
readonly truncated: boolean;
|
||||
}
|
||||
|
||||
interface GrepDetails {
|
||||
readonly matchCount: number;
|
||||
readonly filesScanned: number;
|
||||
readonly truncated: boolean;
|
||||
}
|
||||
|
||||
function markRepositoryTool<T extends ToolDefinition>(tool: T): T {
|
||||
repositoryTools.add(tool);
|
||||
return tool;
|
||||
}
|
||||
|
||||
/** Whether a read/find/grep definition came from the confined Capella factory. */
|
||||
export function isCapellaRepositoryTool(tool: CapellaTool): boolean {
|
||||
return repositoryTools.has(tool);
|
||||
}
|
||||
|
||||
/** Truncate to a byte budget without splitting a multi-byte character. */
|
||||
function boundedText(text: string, maximumBytes: number): { text: string; truncated: boolean } {
|
||||
const bytes = Buffer.from(text, 'utf8');
|
||||
if (bytes.byteLength <= maximumBytes) return { text, truncated: false };
|
||||
const suffix = `\n[Output truncated at ${maximumBytes} bytes.]`;
|
||||
const contentLimit = Math.max(0, maximumBytes - Buffer.byteLength(suffix));
|
||||
let prefix = bytes.subarray(0, contentLimit).toString('utf8');
|
||||
while (Buffer.byteLength(prefix) > contentLimit) prefix = prefix.slice(0, -1);
|
||||
return {
|
||||
text: `${prefix}${suffix}`,
|
||||
truncated: true,
|
||||
};
|
||||
}
|
||||
|
||||
function sliceReadOutput(file: ConfinedFile, offset: number, limit: number): { text: string; truncated: boolean } {
|
||||
if (file.bytes.includes(0)) {
|
||||
throw new ConfinementError('NOT_REGULAR_FILE', 'Binary repository files are not available to Capella.');
|
||||
}
|
||||
|
||||
const normalized = file.bytes.toString('utf8').replace(/\r\n?/gu, '\n');
|
||||
const lines = normalized.split('\n');
|
||||
const start = offset - 1;
|
||||
const selected = lines.slice(start, start + limit);
|
||||
const bounded = boundedText(selected.join('\n'), MAX_READ_OUTPUT_BYTES);
|
||||
const lineTruncated = start + selected.length < lines.length;
|
||||
return { text: bounded.text, truncated: file.truncated || lineTruncated || bounded.truncated };
|
||||
}
|
||||
|
||||
function createReadTool(confinement: RepositoryConfinement): ToolDefinition {
|
||||
return markRepositoryTool(
|
||||
defineTool({
|
||||
name: 'read',
|
||||
label: 'Read repository file',
|
||||
description: 'Read a bounded UTF-8 text file inside the configured repository root.',
|
||||
promptSnippet: 'read: inspect a bounded repository text file',
|
||||
promptGuidelines: ['Use only repository-relative paths. Absolute paths and traversal are rejected.'],
|
||||
parameters: Type.Object(
|
||||
{
|
||||
path: Type.String({ minLength: 1, maxLength: 1_024 }),
|
||||
offset: Type.Optional(Type.Integer({ minimum: 1, maximum: 100_000 })),
|
||||
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: MAX_READ_LINES })),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
async execute(_toolCallId, parameters, signal) {
|
||||
const budget = confinement.createBudget(signal);
|
||||
const resolvedPath = await confinement.resolveExisting(parameters.path, false, budget);
|
||||
const file = await confinement.readResolvedFile(resolvedPath, budget);
|
||||
const output = sliceReadOutput(file, parameters.offset ?? 1, parameters.limit ?? MAX_READ_LINES);
|
||||
confinement.checkBudget(budget);
|
||||
const details: ReadDetails = { path: file.path, truncated: output.truncated };
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: output.text }],
|
||||
details,
|
||||
};
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function relativeToSearchRoot(searchRoot: string, entry: ConfinedEntry): string {
|
||||
const relativePath = path.relative(searchRoot, entry.absolutePath);
|
||||
if (relativePath === '' || relativePath === '..' || relativePath.startsWith(`..${path.sep}`)) {
|
||||
throw new ConfinementError('RACE_DETECTED', 'Repository path changed during enumeration.');
|
||||
}
|
||||
return relativePath.split(path.sep).join('/');
|
||||
}
|
||||
|
||||
function createFindTool(confinement: RepositoryConfinement): ToolDefinition {
|
||||
return markRepositoryTool(
|
||||
defineTool({
|
||||
name: 'find',
|
||||
label: 'Find repository files',
|
||||
description: 'Find bounded repository-relative file paths without following symlinks.',
|
||||
promptSnippet: 'find: list repository files matching a bounded glob',
|
||||
promptGuidelines: ['Search roots and returned paths are repository-relative.'],
|
||||
parameters: Type.Object(
|
||||
{
|
||||
pattern: Type.String({ minLength: 1, maxLength: 256 }),
|
||||
path: Type.Optional(Type.String({ minLength: 1, maxLength: 1_024 })),
|
||||
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: MAX_FIND_RESULTS })),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
async execute(_toolCallId, parameters, signal) {
|
||||
const budget = confinement.createBudget(signal);
|
||||
const searchRoot = await confinement.resolveExisting(parameters.path ?? '.', true, budget);
|
||||
const matcher = compileRepositoryGlob(parameters.pattern);
|
||||
const maximumResults = parameters.limit ?? DEFAULT_FIND_RESULTS;
|
||||
const entries = await confinement.enumerate(parameters.path ?? '.', signal, budget);
|
||||
const matches: string[] = [];
|
||||
let resultLimitReached = false;
|
||||
|
||||
for (const entry of entries) {
|
||||
confinement.checkBudget(budget);
|
||||
const relativeSearchPath = relativeToSearchRoot(searchRoot, entry);
|
||||
if (!matcher.test(relativeSearchPath)) continue;
|
||||
if (matches.length >= maximumResults) {
|
||||
resultLimitReached = true;
|
||||
break;
|
||||
}
|
||||
matches.push(entry.path);
|
||||
}
|
||||
|
||||
const bounded = boundedText(matches.join('\n') || 'No files found.', MAX_READ_OUTPUT_BYTES);
|
||||
confinement.checkBudget(budget);
|
||||
const details: FindDetails = {
|
||||
count: matches.length,
|
||||
truncated: resultLimitReached || bounded.truncated,
|
||||
};
|
||||
return { content: [{ type: 'text' as const, text: bounded.text }], details };
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restrict grep patterns to a subset with no quantifiers, alternation, groups,
|
||||
* or backreferences, so a model-authored pattern cannot trigger catastrophic
|
||||
* backtracking against repository text.
|
||||
*/
|
||||
function assertSafeRegex(pattern: string): void {
|
||||
let escaped = false;
|
||||
let insideClass = false;
|
||||
for (const character of pattern) {
|
||||
if (escaped) {
|
||||
if (/[1-9]/u.test(character)) {
|
||||
throw new ConfinementError('INVALID_PATH', 'Grep pattern is outside the bounded regular-expression subset.');
|
||||
}
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (character === '\\') {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (character === '[') {
|
||||
insideClass = true;
|
||||
continue;
|
||||
}
|
||||
if (character === ']' && insideClass) {
|
||||
insideClass = false;
|
||||
continue;
|
||||
}
|
||||
if (!insideClass && '()*+?{|}'.includes(character)) {
|
||||
throw new ConfinementError('INVALID_PATH', 'Grep pattern is outside the bounded regular-expression subset.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function compileGrepPattern(pattern: string, literal: boolean, ignoreCase: boolean): RegExp {
|
||||
if (pattern.includes('\0')) {
|
||||
throw new ConfinementError('INVALID_PATH', 'Grep pattern is invalid.');
|
||||
}
|
||||
const flags = ignoreCase ? 'iu' : 'u';
|
||||
if (literal) {
|
||||
const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
|
||||
return new RegExp(escaped, flags);
|
||||
}
|
||||
assertSafeRegex(pattern);
|
||||
try {
|
||||
return new RegExp(pattern, flags);
|
||||
} catch {
|
||||
throw new ConfinementError('INVALID_PATH', 'Grep pattern is invalid.');
|
||||
}
|
||||
}
|
||||
|
||||
function lineForSearch(line: string): string {
|
||||
const bytes = Buffer.from(line, 'utf8');
|
||||
if (bytes.byteLength <= MAX_GREP_LINE_BYTES) return line;
|
||||
return bytes.subarray(0, MAX_GREP_LINE_BYTES).toString('utf8');
|
||||
}
|
||||
|
||||
function formatGrepBlock(filePath: string, lines: readonly string[], lineIndex: number, context: number): string[] {
|
||||
const output: string[] = [];
|
||||
const start = Math.max(0, lineIndex - context);
|
||||
const end = Math.min(lines.length - 1, lineIndex + context);
|
||||
for (let index = start; index <= end; index += 1) {
|
||||
const separator = index === lineIndex ? ':' : '-';
|
||||
const bounded = boundedText(lines[index] ?? '', 1_000);
|
||||
output.push(`${filePath}${separator}${index + 1}${separator} ${bounded.text}`);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
async function grepCandidate(
|
||||
confinement: RepositoryConfinement,
|
||||
entry: ConfinedEntry,
|
||||
matcher: RegExp,
|
||||
context: number,
|
||||
remainingMatches: number,
|
||||
budget: OperationBudget,
|
||||
): Promise<{ readonly blocks: string[][]; readonly bytesScanned: number; readonly truncated: boolean }> {
|
||||
const file = await confinement.readResolvedFile(entry.absolutePath, budget);
|
||||
if (file.bytes.includes(0)) return { blocks: [], bytesScanned: file.bytes.byteLength, truncated: file.truncated };
|
||||
|
||||
const lines = file.bytes.toString('utf8').replace(/\r\n?/gu, '\n').split('\n');
|
||||
const blocks: string[][] = [];
|
||||
for (let lineIndex = 0; lineIndex < lines.length && blocks.length < remainingMatches; lineIndex += 1) {
|
||||
confinement.checkBudget(budget);
|
||||
matcher.lastIndex = 0;
|
||||
if (!matcher.test(lineForSearch(lines[lineIndex] ?? ''))) continue;
|
||||
blocks.push(formatGrepBlock(file.path, lines, lineIndex, context));
|
||||
}
|
||||
return { blocks, bytesScanned: file.bytes.byteLength, truncated: file.truncated };
|
||||
}
|
||||
|
||||
/**
|
||||
* Grep accepts a directory or a single file. Only NOT_DIRECTORY falls through
|
||||
* to the single-file path; every other confinement failure propagates.
|
||||
*/
|
||||
async function resolveGrepCandidates(
|
||||
confinement: RepositoryConfinement,
|
||||
requestedPath: string,
|
||||
budget: OperationBudget,
|
||||
): Promise<readonly ConfinedEntry[]> {
|
||||
try {
|
||||
await confinement.resolveExisting(requestedPath, true, budget);
|
||||
return confinement.enumerate(requestedPath, undefined, budget);
|
||||
} catch (error) {
|
||||
if (!(error instanceof ConfinementError) || error.code !== 'NOT_DIRECTORY') throw error;
|
||||
}
|
||||
|
||||
const resolvedPath = await confinement.resolveExisting(requestedPath, false, budget);
|
||||
return [
|
||||
{
|
||||
absolutePath: resolvedPath,
|
||||
path: confinement.relativePath(resolvedPath),
|
||||
dirent: {
|
||||
isFile: () => true,
|
||||
} as ConfinedEntry['dirent'],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function createGrepTool(confinement: RepositoryConfinement): ToolDefinition {
|
||||
return markRepositoryTool(
|
||||
defineTool({
|
||||
name: 'grep',
|
||||
label: 'Search repository contents',
|
||||
description: 'Search bounded repository text through the same no-follow read boundary as read.',
|
||||
promptSnippet: 'grep: search bounded repository text',
|
||||
promptGuidelines: ['Patterns, search roots, and optional globs are bounded and contain no shell arguments.'],
|
||||
parameters: Type.Object(
|
||||
{
|
||||
pattern: Type.String({ minLength: 1, maxLength: MAX_GREP_PATTERN_LENGTH }),
|
||||
path: Type.Optional(Type.String({ minLength: 1, maxLength: 1_024 })),
|
||||
glob: Type.Optional(Type.String({ minLength: 1, maxLength: 256 })),
|
||||
ignoreCase: Type.Optional(Type.Boolean()),
|
||||
literal: Type.Optional(Type.Boolean()),
|
||||
context: Type.Optional(Type.Integer({ minimum: 0, maximum: MAX_GREP_CONTEXT })),
|
||||
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: MAX_GREP_MATCHES })),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
async execute(_toolCallId, parameters, signal) {
|
||||
const matcher = compileGrepPattern(
|
||||
parameters.pattern,
|
||||
parameters.literal ?? false,
|
||||
parameters.ignoreCase ?? false,
|
||||
);
|
||||
const globMatcher = parameters.glob ? compileRepositoryGlob(parameters.glob) : undefined;
|
||||
const maximumMatches = parameters.limit ?? DEFAULT_GREP_MATCHES;
|
||||
const budget = confinement.createBudget(signal);
|
||||
const candidates = await resolveGrepCandidates(confinement, parameters.path ?? '.', budget);
|
||||
const blocks: string[][] = [];
|
||||
let filesScanned = 0;
|
||||
let bytesScanned = 0;
|
||||
let sourceTruncated = false;
|
||||
|
||||
for (const entry of candidates) {
|
||||
confinement.checkBudget(budget);
|
||||
if (globMatcher && !globMatcher.test(entry.path)) continue;
|
||||
if (blocks.length >= maximumMatches || bytesScanned >= MAX_GREP_SCANNED_BYTES) break;
|
||||
|
||||
const result = await grepCandidate(
|
||||
confinement,
|
||||
entry,
|
||||
matcher,
|
||||
parameters.context ?? 0,
|
||||
maximumMatches - blocks.length,
|
||||
budget,
|
||||
);
|
||||
blocks.push(...result.blocks);
|
||||
filesScanned += 1;
|
||||
bytesScanned += result.bytesScanned;
|
||||
sourceTruncated ||= result.truncated;
|
||||
}
|
||||
|
||||
const rawOutput = blocks.map((block) => block.join('\n')).join('\n--\n') || 'No matches found.';
|
||||
const bounded = boundedText(rawOutput, MAX_GREP_OUTPUT_BYTES);
|
||||
confinement.checkBudget(budget);
|
||||
const truncated =
|
||||
sourceTruncated ||
|
||||
blocks.length >= maximumMatches ||
|
||||
bytesScanned >= MAX_GREP_SCANNED_BYTES ||
|
||||
bounded.truncated;
|
||||
const details: GrepDetails = { matchCount: blocks.length, filesScanned, truncated };
|
||||
return { content: [{ type: 'text' as const, text: bounded.text }], details };
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Create the exact three Capella-owned repository tools for one immutable policy. */
|
||||
export async function createCapellaRepositoryTools(
|
||||
options: CapellaRepositoryToolOptions,
|
||||
): Promise<readonly CapellaTool[]> {
|
||||
const confinement = await RepositoryConfinement.create(options);
|
||||
return Object.freeze([createReadTool(confinement), createFindTool(confinement), createGrepTool(confinement)]);
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
// 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 type { CapellaAgentExecutor, CapellaTool } from '../../pi/capella-agent-types.js';
|
||||
import type {
|
||||
AgenticSastArchitectureReduction,
|
||||
AgenticSastCalibrateReduction,
|
||||
AgenticSastConfirmReduction,
|
||||
AgenticSastCriticReduction,
|
||||
AgenticSastDedupeReduction,
|
||||
AgenticSastFallbackReduction,
|
||||
AgenticSastPlanReduction,
|
||||
AgenticSastReduction,
|
||||
AgenticSastResearchReduction,
|
||||
AgenticSastReviewReduction,
|
||||
CapellaFallbackStage,
|
||||
CapellaStage,
|
||||
CapellaUsage,
|
||||
SarifRef,
|
||||
} from '../types.js';
|
||||
import type { CapellaFinding } from './finding-types.js';
|
||||
import type { KbResult, PlanResult, ThreatModelResult } from './schemas.js';
|
||||
|
||||
// Both versions are identity fields of every run fingerprint and run.json record. Routine
|
||||
// prompt edits are already covered by each stage's rendered-prompt digest; bump this global
|
||||
// prompt contract only when a cross-stage change must invalidate every Capella artifact.
|
||||
export const CAPELLA_FORMAT_VERSION = '1';
|
||||
export const CAPELLA_PROMPT_SET_VERSION = 'capella-prompts.v1';
|
||||
export const CAPELLA_TRIAGE_CONCURRENCY = 4;
|
||||
export const CAPELLA_AUDIT_CONCURRENCY = 2;
|
||||
|
||||
export const ZERO_CAPELLA_USAGE: CapellaUsage = {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
costUsd: 0,
|
||||
turns: 0,
|
||||
};
|
||||
|
||||
/** Immutable reference to a completed, fingerprinted Capella artifact. */
|
||||
export interface CapellaArtifactRef {
|
||||
readonly path: string;
|
||||
readonly sha256: string;
|
||||
readonly fingerprint: string;
|
||||
}
|
||||
|
||||
/** Serializable inputs shared by every activity-side stage implementation. */
|
||||
export interface CapellaStageInput {
|
||||
readonly repoPath: string;
|
||||
readonly artifactRoot: string;
|
||||
readonly workflowLogPath: string;
|
||||
readonly promptDir: string;
|
||||
readonly modelSpec: string;
|
||||
readonly capellaFormatVersion: string;
|
||||
readonly promptSetVersion: string;
|
||||
readonly codePathAvoids: readonly string[];
|
||||
readonly codePathFocus: readonly string[];
|
||||
readonly pipelineTestingMode: boolean;
|
||||
readonly timeoutMs: number;
|
||||
}
|
||||
|
||||
/** Non-serializable activity dependencies supplied by the Temporal wrapper. */
|
||||
export interface CapellaStageRuntime {
|
||||
readonly executor: CapellaAgentExecutor;
|
||||
readonly repositoryTools: readonly CapellaTool[];
|
||||
readonly signal: AbortSignal;
|
||||
}
|
||||
|
||||
export interface CompletedStage<T> {
|
||||
readonly status: 'completed';
|
||||
readonly durationMs: number;
|
||||
readonly reused: boolean;
|
||||
readonly usage: CapellaUsage;
|
||||
readonly artifact: CapellaArtifactRef;
|
||||
readonly value: T;
|
||||
}
|
||||
|
||||
export interface ArchitectureValue {
|
||||
readonly knowledgeBase: KbResult;
|
||||
readonly componentCount: number;
|
||||
readonly reduction?: AgenticSastArchitectureReduction;
|
||||
}
|
||||
|
||||
export interface ThreatModelValue extends ThreatModelResult {
|
||||
readonly threatModelPath: string;
|
||||
}
|
||||
|
||||
export interface PlanValue extends PlanResult {
|
||||
readonly investigationCount: number;
|
||||
readonly reduction?: AgenticSastPlanReduction;
|
||||
}
|
||||
|
||||
export interface FindingSetValue {
|
||||
readonly findings: CapellaFinding[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic triage-coverage result for the research stage. `missingFiles` is the private
|
||||
* detailed evidence of which assigned paths were not classified; it stays in this artifact and
|
||||
* is never projected into a public surface (only the counts are).
|
||||
*/
|
||||
export interface ResearchCoverage {
|
||||
readonly consideredCount: number;
|
||||
readonly classifiedCount: number;
|
||||
readonly omittedCount: number;
|
||||
readonly affectedBatchCount: number;
|
||||
readonly missingFiles: readonly string[];
|
||||
}
|
||||
|
||||
/** Private audit-unit evidence retained in the research artifact. */
|
||||
export interface ResearchAuditCoverage {
|
||||
readonly consideredCount: number;
|
||||
readonly completedCount: number;
|
||||
readonly salvagedSessionCount: number;
|
||||
}
|
||||
|
||||
export interface ResearchValue extends FindingSetValue {
|
||||
readonly flaggedFiles: string[];
|
||||
readonly dispatchedCount: number;
|
||||
readonly resumedCount: number;
|
||||
readonly coverage: 'complete' | 'reduced';
|
||||
readonly triageCoverage: ResearchCoverage;
|
||||
readonly auditCoverage: ResearchAuditCoverage;
|
||||
readonly reduction?: AgenticSastResearchReduction;
|
||||
}
|
||||
|
||||
export interface DedupeValue extends FindingSetValue {
|
||||
readonly duplicateCount: number;
|
||||
readonly survivorCount: number;
|
||||
readonly reduction?: AgenticSastDedupeReduction;
|
||||
}
|
||||
|
||||
interface VerdictStageDiagnostics {
|
||||
/** Private collector diagnostics; compact activity values omit these when coverage is complete. */
|
||||
readonly rejectedUnexpectedCount: number;
|
||||
readonly rejectedDuplicateCount: number;
|
||||
}
|
||||
|
||||
export interface ReviewValue extends FindingSetValue, VerdictStageDiagnostics {
|
||||
readonly validCount: number;
|
||||
readonly provisionalCount: number;
|
||||
readonly falsePositiveCount: number;
|
||||
readonly reduction?: AgenticSastReviewReduction;
|
||||
}
|
||||
|
||||
export interface CriticValue extends FindingSetValue, VerdictStageDiagnostics {
|
||||
readonly viableCount: number;
|
||||
readonly reduction?: AgenticSastCriticReduction;
|
||||
}
|
||||
|
||||
export interface ConfirmValue extends FindingSetValue, VerdictStageDiagnostics {
|
||||
readonly confirmedCount: number;
|
||||
readonly reduction?: AgenticSastConfirmReduction;
|
||||
}
|
||||
|
||||
export interface CalibrateValue extends FindingSetValue, VerdictStageDiagnostics {
|
||||
readonly calibratedCount: number;
|
||||
readonly reduction?: AgenticSastCalibrateReduction;
|
||||
}
|
||||
|
||||
export interface ExportValue {
|
||||
readonly sarif: SarifRef;
|
||||
readonly findingCount: number;
|
||||
readonly coverage: 'complete' | 'reduced';
|
||||
readonly warnings: string[];
|
||||
readonly reportPath: string;
|
||||
readonly reduction?: AgenticSastReduction;
|
||||
}
|
||||
|
||||
export interface FindingStageInput extends CapellaStageInput {
|
||||
readonly findingsArtifact: CapellaArtifactRef;
|
||||
}
|
||||
|
||||
export interface KnowledgeFindingStageInput extends FindingStageInput {
|
||||
readonly architectureArtifact: CapellaArtifactRef;
|
||||
readonly threatModelArtifact: CapellaArtifactRef;
|
||||
}
|
||||
|
||||
export interface ThreatModelStageInput extends CapellaStageInput {
|
||||
readonly architectureArtifact: CapellaArtifactRef;
|
||||
}
|
||||
|
||||
export interface PlanStageInput extends CapellaStageInput {
|
||||
readonly architectureArtifact: CapellaArtifactRef;
|
||||
readonly threatModelArtifact: CapellaArtifactRef;
|
||||
}
|
||||
|
||||
export interface ResearchStageInput extends CapellaStageInput {
|
||||
readonly architectureArtifact: CapellaArtifactRef;
|
||||
readonly planArtifact: CapellaArtifactRef;
|
||||
}
|
||||
|
||||
export type ExportSourceStage = 'research' | 'dedupe' | 'review' | 'critic' | 'confirm' | 'calibrate';
|
||||
|
||||
export interface ExportStageInput extends CapellaStageInput {
|
||||
readonly findingsArtifact?: CapellaArtifactRef;
|
||||
readonly findingsStage?: ExportSourceStage;
|
||||
readonly repositoryLabel: string;
|
||||
readonly fallbackReduction?: AgenticSastFallbackReduction;
|
||||
readonly fallbackFailure?: CapellaFallbackFailure;
|
||||
}
|
||||
|
||||
/** Bounded original failure sent back into a last-good export activity. */
|
||||
export interface CapellaFallbackFailure {
|
||||
readonly stage: CapellaFallbackStage;
|
||||
readonly code: string;
|
||||
readonly error: string;
|
||||
readonly attempt: number;
|
||||
readonly retryable: boolean;
|
||||
}
|
||||
|
||||
export interface CapellaArtifactEnvelope<T> {
|
||||
readonly schemaVersion: 1;
|
||||
readonly stage: CapellaStage;
|
||||
readonly fingerprint: string;
|
||||
readonly usage: CapellaUsage;
|
||||
readonly value: T;
|
||||
}
|
||||
|
||||
export type StageArtifactValidator<T> = (value: unknown) => value is T;
|
||||
|
||||
export interface AtomicPublishOptions {
|
||||
readonly beforeRename?: (temporaryPath: string, finalPath: string) => Promise<void> | void;
|
||||
}
|
||||
|
||||
export interface CapellaRunFailure {
|
||||
readonly stage: CapellaStage | 'workflow';
|
||||
readonly code: string;
|
||||
readonly error: string;
|
||||
readonly attempt: number;
|
||||
readonly retryable: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A stage's spend folded from its per-attempt usage ledger. `complete` requires a matching
|
||||
* final record for every started session; `retried` reports whether more than one activity
|
||||
* attempt touched the stage. Usage accounting is trusted only when `complete && !retried`,
|
||||
* because an attempt that died mid-session cannot prove its spend was fully captured.
|
||||
*/
|
||||
export interface StageUsageSummary {
|
||||
readonly usage: CapellaUsage;
|
||||
readonly complete: boolean;
|
||||
readonly retried: boolean;
|
||||
}
|
||||
|
||||
export interface CapellaRunRecord {
|
||||
readonly schemaVersion: 1;
|
||||
readonly capellaFormatVersion: string;
|
||||
readonly promptSetVersion: string;
|
||||
readonly inputFingerprint: string;
|
||||
readonly completedStages: CapellaStage[];
|
||||
readonly finalState: 'running' | 'succeeded' | 'failed';
|
||||
readonly warnings: string[];
|
||||
readonly usage: CapellaUsage;
|
||||
readonly stageUsage: Partial<Record<CapellaStage, CapellaUsage>>;
|
||||
// True only while every recorded stage's spend was captured from a clean, un-retried
|
||||
// ledger. A retried or terminally failed stage drives this false; the reason is named in
|
||||
// `warnings`. Consumers treating run.json as the billing record read this before trusting `usage`.
|
||||
readonly usageAccountingComplete: boolean;
|
||||
// Aggregate reduced-coverage summary, at most one entry per stage, in stage order. Stage
|
||||
// reductions are counts-only; export omissions may include bounded finding identity for private
|
||||
// diagnostics. Derived from the same structured facts the stage artifacts hold.
|
||||
readonly reductions?: readonly AgenticSastReduction[];
|
||||
readonly sarif?: SarifRef;
|
||||
readonly failure?: CapellaRunFailure;
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable reason a stage's usage accounting could not be fully trusted.
|
||||
*
|
||||
* The single source of the warning text: run.json (`recordRunFailure`,
|
||||
* `recordStageUsageAccounting`), the activity failure payload, and the workflow fold all
|
||||
* emit this string for a stage whose ledger did not reconcile (`complete && !retried`), so
|
||||
* every ledger surfaces the identical reason. Lives here because it is shared across the
|
||||
* activity side and the workflow isolate.
|
||||
*/
|
||||
export function usageAccountingWarning(stage: CapellaStage | 'workflow'): string {
|
||||
return `Usage accounting for stage "${stage}" is incomplete: the stage was retried or failed, so a failed attempt's spend may not be fully captured in run.json.`;
|
||||
}
|
||||
@@ -0,0 +1,607 @@
|
||||
// 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 type { AgenticSastReduction } from '../types.js';
|
||||
import { SastContractError } from './errors.js';
|
||||
import {
|
||||
CAPELLA_ATTACKER_POSITIONS,
|
||||
CAPELLA_PRIVILEGES,
|
||||
CAPELLA_REPRO_STATUSES,
|
||||
CAPELLA_SEVERITIES,
|
||||
CAPELLA_STATUSES,
|
||||
CAPELLA_USER_INTERACTIONS,
|
||||
CAPELLA_VIABILITIES,
|
||||
type CapellaFinding,
|
||||
} from './finding-types.js';
|
||||
import { isNormalizedRepositoryPath } from './paths.js';
|
||||
import type { KbResult, PlanResult, ThreatModelResult, TriageResult } from './schemas.js';
|
||||
import type {
|
||||
ArchitectureValue,
|
||||
CalibrateValue,
|
||||
ConfirmValue,
|
||||
CriticValue,
|
||||
DedupeValue,
|
||||
ExportValue,
|
||||
PlanValue,
|
||||
ResearchValue,
|
||||
ReviewValue,
|
||||
ThreatModelValue,
|
||||
} from './types.js';
|
||||
|
||||
const CWE_PATTERN = /^CWE-\d+$/;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === 'string' && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function isStringArray(value: unknown): value is string[] {
|
||||
return Array.isArray(value) && value.every((entry) => isNonEmptyString(entry));
|
||||
}
|
||||
|
||||
function isEnum<T extends string>(value: unknown, allowed: readonly T[]): value is T {
|
||||
return typeof value === 'string' && (allowed as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
/** Validate the stage-to-stage finding record before it is reused or exported. */
|
||||
export function isCapellaFinding(value: unknown): value is CapellaFinding {
|
||||
if (!isRecord(value)) return false;
|
||||
// Mirrors the collector's finding-id sanitizer: the id names findings/<id>.json,
|
||||
// so a separator or traversal here would escape the findings directory.
|
||||
if (!isNonEmptyString(value.id) || value.id.includes('/') || value.id.includes('\\') || value.id.includes('..')) {
|
||||
return false;
|
||||
}
|
||||
if (!isNonEmptyString(value.title) || !isNonEmptyString(value.description)) return false;
|
||||
if (!isStringArray(value.code_paths)) return false;
|
||||
if (!isNonEmptyString(value.impact) || !isNonEmptyString(value.mitigation)) return false;
|
||||
if (!isEnum(value.severity, CAPELLA_SEVERITIES)) return false;
|
||||
if (!isEnum(value.privileges_required, CAPELLA_PRIVILEGES)) return false;
|
||||
if (!isEnum(value.attacker_position, CAPELLA_ATTACKER_POSITIONS)) return false;
|
||||
if (!isEnum(value.user_interaction, CAPELLA_USER_INTERACTIONS)) return false;
|
||||
if (!isNonEmptyString(value.cwe) || !CWE_PATTERN.test(value.cwe)) return false;
|
||||
if (!isEnum(value.status, CAPELLA_STATUSES)) return false;
|
||||
if (!Array.isArray(value.history) || !value.history.every(isRecord)) return false;
|
||||
if (typeof value.recordedAt !== 'number' || !Number.isFinite(value.recordedAt)) return false;
|
||||
if (value.production_viability !== undefined && !isEnum(value.production_viability, CAPELLA_VIABILITIES))
|
||||
return false;
|
||||
if (value.repro_status !== undefined && !isEnum(value.repro_status, CAPELLA_REPRO_STATUSES)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function isFindingSetValue(value: unknown): value is { findings: CapellaFinding[] } {
|
||||
return isRecord(value) && Array.isArray(value.findings) && value.findings.every(isCapellaFinding);
|
||||
}
|
||||
|
||||
export function isRawFindingSetValue(value: unknown): value is { findings: unknown[] } {
|
||||
return isRecord(value) && Array.isArray(value.findings);
|
||||
}
|
||||
|
||||
export function assertFindingSet(value: unknown, source: string): asserts value is { findings: CapellaFinding[] } {
|
||||
if (!isFindingSetValue(value)) {
|
||||
throw new SastContractError(`${source} is not a valid Capella finding set`, 'FINDING_SET_SCHEMA');
|
||||
}
|
||||
}
|
||||
|
||||
export interface VerdictSetDetails {
|
||||
readonly expectedCount: number;
|
||||
readonly receivedCount: number;
|
||||
readonly missingIds: readonly string[];
|
||||
readonly duplicateIds: readonly string[];
|
||||
readonly unexpectedIds: readonly string[];
|
||||
}
|
||||
|
||||
/** Calculate exact-set completeness without mutating findings or throwing. */
|
||||
export function calculateVerdictSetDetails(
|
||||
expectedIds: readonly string[],
|
||||
recordedIds: readonly string[],
|
||||
): VerdictSetDetails {
|
||||
const expected = [...expectedIds].sort();
|
||||
const recorded = [...recordedIds].sort();
|
||||
const expectedSet = new Set(expected);
|
||||
const recordedSet = new Set(recorded);
|
||||
return {
|
||||
expectedCount: expected.length,
|
||||
receivedCount: recorded.length,
|
||||
missingIds: [...new Set(expected.filter((id) => !recordedSet.has(id)))],
|
||||
duplicateIds: [...new Set(recorded.filter((id, index) => index > 0 && id === recorded[index - 1]))],
|
||||
unexpectedIds: [...new Set(recorded.filter((id) => !expectedSet.has(id)))],
|
||||
};
|
||||
}
|
||||
|
||||
export function isKbEntity(value: unknown): value is KbResult['entities'][number] {
|
||||
return isRecord(value) && isNonEmptyString(value.name) && isNonEmptyString(value.content);
|
||||
}
|
||||
|
||||
export interface SalvagedKbResult {
|
||||
readonly value: KbResult;
|
||||
readonly consideredEntityCount: number;
|
||||
readonly omittedEntityCount: number;
|
||||
readonly consideredDependencyCount: number;
|
||||
readonly omittedDependencyCount: number;
|
||||
}
|
||||
|
||||
/** Keep a valid KB core while dropping malformed entities and dependency edges. */
|
||||
export function salvageKbResult(value: unknown): SalvagedKbResult | undefined {
|
||||
if (!isRecord(value)) return undefined;
|
||||
if (!isNonEmptyString(value.architecture) || !isNonEmptyString(value.index)) return undefined;
|
||||
if (!Array.isArray(value.entities) || !Array.isArray(value.vulnerabilities) || !isRecord(value.dependencies)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const rawEntities = [...value.entities, ...value.vulnerabilities];
|
||||
const entities = value.entities.filter(isKbEntity);
|
||||
const vulnerabilities = value.vulnerabilities.filter(isKbEntity);
|
||||
const dependencyEntries = Object.entries(value.dependencies);
|
||||
const dependencies = Object.fromEntries(dependencyEntries.filter(([, targets]) => isStringArray(targets))) as Record<
|
||||
string,
|
||||
string[]
|
||||
>;
|
||||
return {
|
||||
value: {
|
||||
architecture: value.architecture,
|
||||
entities,
|
||||
vulnerabilities,
|
||||
index: value.index,
|
||||
dependencies,
|
||||
},
|
||||
consideredEntityCount: rawEntities.length,
|
||||
omittedEntityCount: rawEntities.length - entities.length - vulnerabilities.length,
|
||||
consideredDependencyCount: dependencyEntries.length,
|
||||
omittedDependencyCount: dependencyEntries.length - Object.keys(dependencies).length,
|
||||
};
|
||||
}
|
||||
|
||||
export function isKbResult(value: unknown): value is KbResult {
|
||||
if (!isRecord(value)) return false;
|
||||
if (!isNonEmptyString(value.architecture) || !isNonEmptyString(value.index)) return false;
|
||||
if (!Array.isArray(value.entities) || !value.entities.every(isKbEntity)) return false;
|
||||
if (!Array.isArray(value.vulnerabilities) || !value.vulnerabilities.every(isKbEntity)) return false;
|
||||
if (!isRecord(value.dependencies)) return false;
|
||||
return Object.values(value.dependencies).every(isStringArray);
|
||||
}
|
||||
|
||||
export function isArchitectureValue(value: unknown): value is ArchitectureValue {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
isKbResult(value.knowledgeBase) &&
|
||||
Number.isSafeInteger(value.componentCount) &&
|
||||
(value.reduction === undefined ||
|
||||
(isAgenticSastReduction(value.reduction) &&
|
||||
value.reduction.stage === 'architecture' &&
|
||||
value.reduction.reason === 'invalid_architecture_items'))
|
||||
);
|
||||
}
|
||||
|
||||
export function isThreatModelResult(value: unknown): value is ThreatModelResult {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
isNonEmptyString(value.threatModel) &&
|
||||
(value.intent === 'PRODUCTION' || value.intent === 'SAMPLE_OR_TEST_ONLY')
|
||||
);
|
||||
}
|
||||
|
||||
export function isThreatModelValue(value: unknown): value is ThreatModelValue {
|
||||
if (!isThreatModelResult(value)) return false;
|
||||
const record = value as ThreatModelResult & Record<string, unknown>;
|
||||
return isNonEmptyString(record.threatModelPath);
|
||||
}
|
||||
|
||||
export function isInvestigation(value: unknown): value is PlanResult['investigations'][number] {
|
||||
if (!isRecord(value)) return false;
|
||||
return (
|
||||
isNonEmptyString(value.title) &&
|
||||
isStringArray(value.target_files) &&
|
||||
value.target_files.every(isNormalizedRepositoryPath) &&
|
||||
Array.isArray(value.kb_references) &&
|
||||
value.kb_references.every((entry) => typeof entry === 'string') &&
|
||||
isNonEmptyString(value.question)
|
||||
);
|
||||
}
|
||||
|
||||
export interface SalvagedPlanResult {
|
||||
readonly value: PlanResult;
|
||||
readonly consideredCount: number;
|
||||
readonly omittedCount: number;
|
||||
}
|
||||
|
||||
/** Keep usable investigations while preserving root invalidity as an atomic failure. */
|
||||
export function salvagePlanResult(value: unknown): SalvagedPlanResult | undefined {
|
||||
if (!isRecord(value) || !Array.isArray(value.investigations)) return undefined;
|
||||
const investigations = value.investigations.filter(isInvestigation);
|
||||
return {
|
||||
value: { investigations },
|
||||
consideredCount: value.investigations.length,
|
||||
omittedCount: value.investigations.length - investigations.length,
|
||||
};
|
||||
}
|
||||
|
||||
export function isPlanResult(value: unknown): value is PlanResult {
|
||||
const salvaged = salvagePlanResult(value);
|
||||
return salvaged !== undefined && salvaged.omittedCount === 0;
|
||||
}
|
||||
|
||||
export function isPlanValue(value: unknown): value is PlanValue {
|
||||
if (!isPlanResult(value)) return false;
|
||||
const record = value as PlanResult & Record<string, unknown>;
|
||||
const reduction = record.reduction;
|
||||
return (
|
||||
value.investigations.length > 0 &&
|
||||
record.investigationCount === value.investigations.length &&
|
||||
(reduction === undefined ||
|
||||
(isAgenticSastReduction(reduction) &&
|
||||
reduction.stage === 'plan' &&
|
||||
reduction.reason === 'invalid_investigations' &&
|
||||
reduction.usableCount === value.investigations.length))
|
||||
);
|
||||
}
|
||||
|
||||
export function isTriageResult(value: unknown): value is TriageResult {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
Array.isArray(value.classifications) &&
|
||||
value.classifications.every(
|
||||
(classification) =>
|
||||
isRecord(classification) &&
|
||||
isNormalizedRepositoryPath(String(classification.file)) &&
|
||||
typeof classification.potentially_flawed === 'boolean' &&
|
||||
typeof classification.reason === 'string',
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function hasInteger(value: Record<string, unknown>, key: string): boolean {
|
||||
return Number.isSafeInteger(value[key]) && Number(value[key]) >= 0;
|
||||
}
|
||||
|
||||
function isResearchCoverage(value: unknown): value is ResearchValue['triageCoverage'] {
|
||||
if (!isRecord(value)) return false;
|
||||
if (
|
||||
!hasInteger(value, 'consideredCount') ||
|
||||
!hasInteger(value, 'classifiedCount') ||
|
||||
!hasInteger(value, 'omittedCount') ||
|
||||
!hasInteger(value, 'affectedBatchCount')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (Number(value.classifiedCount) + Number(value.omittedCount) !== Number(value.consideredCount)) return false;
|
||||
return (
|
||||
Array.isArray(value.missingFiles) &&
|
||||
value.missingFiles.every((file) => typeof file === 'string' && isNormalizedRepositoryPath(file)) &&
|
||||
value.missingFiles.length === Number(value.omittedCount)
|
||||
);
|
||||
}
|
||||
|
||||
function isResearchAuditCoverage(value: unknown): value is ResearchValue['auditCoverage'] {
|
||||
if (!isRecord(value)) return false;
|
||||
if (
|
||||
!hasInteger(value, 'consideredCount') ||
|
||||
!hasInteger(value, 'completedCount') ||
|
||||
!hasInteger(value, 'salvagedSessionCount')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return Number(value.completedCount) === Number(value.consideredCount);
|
||||
}
|
||||
|
||||
export function isResearchValue(value: unknown): value is ResearchValue {
|
||||
if (!isFindingSetValue(value)) return false;
|
||||
const record = value as { findings: CapellaFinding[] } & Record<string, unknown>;
|
||||
const reduction = record.reduction;
|
||||
const triageCoverage = record.triageCoverage;
|
||||
const auditCoverage = record.auditCoverage;
|
||||
const coverageIsValid = isResearchCoverage(triageCoverage) && isResearchAuditCoverage(auditCoverage);
|
||||
const reductionMatches =
|
||||
reduction === undefined ||
|
||||
(coverageIsValid &&
|
||||
isAgenticSastReduction(reduction) &&
|
||||
reduction.stage === 'research' &&
|
||||
reduction.reason === 'incomplete_research' &&
|
||||
reduction.triageConsideredCount === triageCoverage.consideredCount &&
|
||||
reduction.triageClassifiedCount === triageCoverage.classifiedCount &&
|
||||
reduction.triageOmittedCount === triageCoverage.omittedCount &&
|
||||
reduction.affectedTriageBatchCount === triageCoverage.affectedBatchCount &&
|
||||
reduction.auditUnitCount === auditCoverage.consideredCount &&
|
||||
reduction.salvagedAuditSessionCount === auditCoverage.salvagedSessionCount);
|
||||
return (
|
||||
Array.isArray(record.flaggedFiles) &&
|
||||
record.flaggedFiles.every((file) => typeof file === 'string' && isNormalizedRepositoryPath(file)) &&
|
||||
hasInteger(record, 'dispatchedCount') &&
|
||||
hasInteger(record, 'resumedCount') &&
|
||||
(record.coverage === 'complete' || record.coverage === 'reduced') &&
|
||||
coverageIsValid &&
|
||||
reductionMatches &&
|
||||
(record.coverage === 'reduced') === (reduction !== undefined)
|
||||
);
|
||||
}
|
||||
|
||||
function hasValidOptionalReduction(
|
||||
value: unknown,
|
||||
stage: AgenticSastReduction['stage'],
|
||||
reason: AgenticSastReduction['reason'],
|
||||
): boolean {
|
||||
const record = value as Record<string, unknown>;
|
||||
return (
|
||||
record.reduction === undefined ||
|
||||
(isAgenticSastReduction(record.reduction) && record.reduction.stage === stage && record.reduction.reason === reason)
|
||||
);
|
||||
}
|
||||
|
||||
function hasPrivateVerdictDiagnostics(value: Record<string, unknown>): boolean {
|
||||
return hasInteger(value, 'rejectedUnexpectedCount') && hasInteger(value, 'rejectedDuplicateCount');
|
||||
}
|
||||
|
||||
function hasMatchingVerdictReduction(
|
||||
value: Record<string, unknown>,
|
||||
stage: 'review' | 'critic' | 'confirm' | 'calibrate',
|
||||
reason: 'incomplete_review' | 'incomplete_critic' | 'incomplete_confirm' | 'incomplete_calibrate',
|
||||
): boolean {
|
||||
if (!hasValidOptionalReduction(value, stage, reason)) return false;
|
||||
if (value.reduction === undefined) return true;
|
||||
const reduction = value.reduction;
|
||||
const reductionRecord = reduction as Record<string, unknown>;
|
||||
return (
|
||||
isAgenticSastReduction(reduction) &&
|
||||
reductionRecord.rejectedUnexpectedCount === value.rejectedUnexpectedCount &&
|
||||
reductionRecord.rejectedDuplicateCount === value.rejectedDuplicateCount
|
||||
);
|
||||
}
|
||||
|
||||
export function isDedupeValue(value: unknown): value is DedupeValue {
|
||||
return (
|
||||
isFindingSetValue(value) &&
|
||||
hasInteger(value, 'duplicateCount') &&
|
||||
hasInteger(value, 'survivorCount') &&
|
||||
Number((value as Record<string, unknown>).survivorCount) === value.findings.length &&
|
||||
hasValidOptionalReduction(value, 'dedupe', 'incomplete_dedupe')
|
||||
);
|
||||
}
|
||||
|
||||
export function isReviewValue(value: unknown): value is ReviewValue {
|
||||
return (
|
||||
isFindingSetValue(value) &&
|
||||
hasInteger(value, 'validCount') &&
|
||||
hasInteger(value, 'provisionalCount') &&
|
||||
hasInteger(value, 'falsePositiveCount') &&
|
||||
hasPrivateVerdictDiagnostics(value) &&
|
||||
hasMatchingVerdictReduction(value, 'review', 'incomplete_review')
|
||||
);
|
||||
}
|
||||
|
||||
export function isCriticValue(value: unknown): value is CriticValue {
|
||||
return (
|
||||
isFindingSetValue(value) &&
|
||||
hasInteger(value, 'viableCount') &&
|
||||
hasPrivateVerdictDiagnostics(value) &&
|
||||
hasMatchingVerdictReduction(value, 'critic', 'incomplete_critic')
|
||||
);
|
||||
}
|
||||
|
||||
export function isConfirmValue(value: unknown): value is ConfirmValue {
|
||||
return (
|
||||
isFindingSetValue(value) &&
|
||||
hasInteger(value, 'confirmedCount') &&
|
||||
hasPrivateVerdictDiagnostics(value) &&
|
||||
hasMatchingVerdictReduction(value, 'confirm', 'incomplete_confirm')
|
||||
);
|
||||
}
|
||||
|
||||
export function isCalibrateValue(value: unknown): value is CalibrateValue {
|
||||
return (
|
||||
isFindingSetValue(value) &&
|
||||
hasInteger(value, 'calibratedCount') &&
|
||||
hasPrivateVerdictDiagnostics(value) &&
|
||||
hasMatchingVerdictReduction(value, 'calibrate', 'incomplete_calibrate')
|
||||
);
|
||||
}
|
||||
|
||||
function isBoundedCount(value: unknown): value is number {
|
||||
return Number.isSafeInteger(value) && Number(value) >= 0 && Number(value) <= 1_000_000;
|
||||
}
|
||||
|
||||
// A reduction's shape is closed, not merely a superset check: every isAgenticSastReduction branch
|
||||
// below calls this so a reduction cannot carry an extra field the schema does not name. Without it,
|
||||
// something upstream could smuggle unbounded text or a path through a field this validator never
|
||||
// inspects, since a subset check alone would not catch an addition.
|
||||
function hasExactKeys(value: Record<string, unknown>, required: readonly string[]): boolean {
|
||||
return Object.keys(value).length === required.length && required.every((key) => key in value);
|
||||
}
|
||||
|
||||
function hasBoundedCounts(value: Record<string, unknown>, fields: readonly string[]): boolean {
|
||||
return fields.every((field) => isBoundedCount(value[field]));
|
||||
}
|
||||
|
||||
// 'export' is deliberately excluded: a failed export has no later stage to fall back to, so that
|
||||
// failure is always the workflow's terminal outcome rather than something recoverable through
|
||||
// the last-good-findings fallback path.
|
||||
const FALLBACK_REDUCTION_STAGES = [
|
||||
'architecture',
|
||||
'threat-model',
|
||||
'plan',
|
||||
'research',
|
||||
'dedupe',
|
||||
'review',
|
||||
'critic',
|
||||
'confirm',
|
||||
'calibrate',
|
||||
] as const;
|
||||
|
||||
/** Validate one reduction member. New members carry counts only; export keeps bounded omission detail. */
|
||||
export function isAgenticSastReduction(value: unknown): value is AgenticSastReduction {
|
||||
if (!isRecord(value)) return false;
|
||||
if (value.reason === 'failed_stage_fallback') {
|
||||
return (
|
||||
(FALLBACK_REDUCTION_STAGES as readonly unknown[]).includes(value.stage) &&
|
||||
isBoundedCount(value.fallbackFindingCount) &&
|
||||
hasExactKeys(value, ['stage', 'reason', 'fallbackFindingCount'])
|
||||
);
|
||||
}
|
||||
if (value.stage === 'architecture') {
|
||||
return (
|
||||
value.reason === 'invalid_architecture_items' &&
|
||||
hasBoundedCounts(value, ['entityCount', 'omittedEntityCount', 'dependencyCount', 'omittedDependencyCount']) &&
|
||||
Number(value.omittedEntityCount) + Number(value.omittedDependencyCount) >= 1 &&
|
||||
Number(value.omittedEntityCount) <= Number(value.entityCount) &&
|
||||
Number(value.omittedDependencyCount) <= Number(value.dependencyCount) &&
|
||||
hasExactKeys(value, [
|
||||
'stage',
|
||||
'reason',
|
||||
'entityCount',
|
||||
'omittedEntityCount',
|
||||
'dependencyCount',
|
||||
'omittedDependencyCount',
|
||||
])
|
||||
);
|
||||
}
|
||||
if (value.stage === 'plan') {
|
||||
return (
|
||||
value.reason === 'invalid_investigations' &&
|
||||
hasBoundedCounts(value, ['consideredCount', 'usableCount', 'omittedCount']) &&
|
||||
Number(value.omittedCount) >= 1 &&
|
||||
Number(value.usableCount) + Number(value.omittedCount) === Number(value.consideredCount) &&
|
||||
hasExactKeys(value, ['stage', 'reason', 'consideredCount', 'usableCount', 'omittedCount'])
|
||||
);
|
||||
}
|
||||
if (value.stage === 'export') {
|
||||
return (
|
||||
value.reason === 'malformed_findings' &&
|
||||
isBoundedCount(value.omittedCount) &&
|
||||
Number(value.omittedCount) >= 1 &&
|
||||
isBoundedCount(value.consideredCount) &&
|
||||
Number(value.consideredCount) >= Number(value.omittedCount) &&
|
||||
Array.isArray(value.omissions) &&
|
||||
value.omissions.length === Number(value.omittedCount) &&
|
||||
value.omissions.every(isAgenticSastOmission) &&
|
||||
hasExactKeys(value, ['stage', 'reason', 'omittedCount', 'consideredCount', 'omissions'])
|
||||
);
|
||||
}
|
||||
if (value.stage === 'research') {
|
||||
return (
|
||||
value.reason === 'incomplete_research' &&
|
||||
hasBoundedCounts(value, [
|
||||
'triageConsideredCount',
|
||||
'triageClassifiedCount',
|
||||
'triageOmittedCount',
|
||||
'affectedTriageBatchCount',
|
||||
'auditUnitCount',
|
||||
'salvagedAuditSessionCount',
|
||||
]) &&
|
||||
Number(value.triageClassifiedCount) + Number(value.triageOmittedCount) === Number(value.triageConsideredCount) &&
|
||||
Number(value.triageOmittedCount) + Number(value.salvagedAuditSessionCount) >= 1 &&
|
||||
hasExactKeys(value, [
|
||||
'stage',
|
||||
'reason',
|
||||
'triageConsideredCount',
|
||||
'triageClassifiedCount',
|
||||
'triageOmittedCount',
|
||||
'affectedTriageBatchCount',
|
||||
'auditUnitCount',
|
||||
'salvagedAuditSessionCount',
|
||||
])
|
||||
);
|
||||
}
|
||||
if (value.stage === 'dedupe') {
|
||||
return (
|
||||
value.reason === 'incomplete_dedupe' &&
|
||||
hasBoundedCounts(value, ['consideredCount', 'survivorCount', 'unreadableCount', 'salvagedTurnLimitCount']) &&
|
||||
Number(value.unreadableCount) + Number(value.salvagedTurnLimitCount) >= 1 &&
|
||||
Number(value.salvagedTurnLimitCount) <= 1 &&
|
||||
hasExactKeys(value, [
|
||||
'stage',
|
||||
'reason',
|
||||
'consideredCount',
|
||||
'survivorCount',
|
||||
'unreadableCount',
|
||||
'salvagedTurnLimitCount',
|
||||
])
|
||||
);
|
||||
}
|
||||
if (['review', 'critic', 'confirm', 'calibrate'].includes(String(value.stage))) {
|
||||
const stage = value.stage as 'review' | 'critic' | 'confirm' | 'calibrate';
|
||||
const expectedReason = `incomplete_${stage}`;
|
||||
const countFields = [
|
||||
'consideredCount',
|
||||
'gradedCount',
|
||||
'missingCount',
|
||||
'unreadableCount',
|
||||
'rejectedUnexpectedCount',
|
||||
'rejectedDuplicateCount',
|
||||
'salvagedTurnLimitCount',
|
||||
];
|
||||
if (
|
||||
value.reason !== expectedReason ||
|
||||
!hasBoundedCounts(value, countFields) ||
|
||||
Number(value.missingCount) > Number(value.consideredCount) ||
|
||||
Number(value.salvagedTurnLimitCount) > 2 ||
|
||||
Number(value.missingCount) + Number(value.unreadableCount) + Number(value.salvagedTurnLimitCount) < 1
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (stage === 'review') {
|
||||
return (
|
||||
isBoundedCount(value.quarantinedCount) &&
|
||||
Number(value.quarantinedCount) <= Number(value.missingCount) &&
|
||||
hasExactKeys(value, ['stage', 'reason', ...countFields, 'quarantinedCount'])
|
||||
);
|
||||
}
|
||||
return hasExactKeys(value, ['stage', 'reason', ...countFields]);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isExportValue(value: unknown): value is ExportValue {
|
||||
if (!isRecord(value) || !isRecord(value.sarif)) return false;
|
||||
const reduction = value.reduction;
|
||||
const reductionIsValid =
|
||||
reduction === undefined || (isAgenticSastReduction(reduction) && reduction.stage === 'export');
|
||||
return (
|
||||
isNonEmptyString(value.sarif.path) &&
|
||||
typeof value.sarif.sha256 === 'string' &&
|
||||
/^[0-9a-f]{64}$/.test(value.sarif.sha256) &&
|
||||
hasInteger(value, 'findingCount') &&
|
||||
(value.coverage === 'complete' || value.coverage === 'reduced') &&
|
||||
Array.isArray(value.warnings) &&
|
||||
value.warnings.every((warning) => typeof warning === 'string') &&
|
||||
isNonEmptyString(value.reportPath) &&
|
||||
reductionIsValid
|
||||
);
|
||||
}
|
||||
|
||||
function isAgenticSastOmission(value: unknown): boolean {
|
||||
if (!isRecord(value)) return false;
|
||||
if (
|
||||
typeof value.reason !== 'string' ||
|
||||
!['invalid_finding_record', 'missing_code_path', 'invalid_code_path'].includes(value.reason)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const allowedKeys = ['reason'];
|
||||
if (value.findingId !== undefined) {
|
||||
if (typeof value.findingId !== 'string' || !/^[a-z0-9-]{1,256}$/.test(value.findingId)) return false;
|
||||
allowedKeys.push('findingId');
|
||||
}
|
||||
if (value.displayName !== undefined) {
|
||||
if (
|
||||
typeof value.displayName !== 'string' ||
|
||||
value.displayName.length === 0 ||
|
||||
value.displayName.length > 160 ||
|
||||
containsControlCharacter(value.displayName)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
allowedKeys.push('displayName');
|
||||
}
|
||||
return Object.keys(value).length === allowedKeys.length && allowedKeys.every((key) => key in value);
|
||||
}
|
||||
|
||||
function containsControlCharacter(value: string): boolean {
|
||||
return [...value].some((character) => {
|
||||
const code = character.charCodeAt(0);
|
||||
return code <= 31 || code === 127;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
// 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 { isNormalizedRepositoryPath } from './capella/paths.js';
|
||||
|
||||
/**
|
||||
* The exact SARIF 2.1.0 profile Capella publishes. The reconciliation SAST
|
||||
* intake (`ai/reconciliation/sast/sarif-parser.ts`) re-validates the same shape
|
||||
* on read, so a field added or relaxed here without a matching parser change is
|
||||
* rejected at intake rather than reconciled.
|
||||
*/
|
||||
|
||||
export const CAPELLA_SARIF_SCHEMA =
|
||||
'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json';
|
||||
export const CAPELLA_SARIF_DRIVER_NAME = 'Shannon Capella Agentic SAST';
|
||||
export const CAPELLA_SARIF_DRIVER_VERSION = '1.0.0';
|
||||
export const CAPELLA_SARIF_INFORMATION_URI = 'https://github.com/KeygraphHQ/shannon';
|
||||
|
||||
export type CapellaSarifSeverity = 'Critical' | 'High' | 'Medium' | 'Low' | 'Info';
|
||||
export type CapellaSarifLevel = 'error' | 'warning' | 'note';
|
||||
|
||||
export interface CapellaSarifPhysicalLocation {
|
||||
artifactLocation: { uri: string; uriBaseId?: '%SRCROOT%' };
|
||||
region: { startLine: number };
|
||||
}
|
||||
|
||||
export interface CapellaSarifThreadFlowLocation {
|
||||
location: {
|
||||
physicalLocation: CapellaSarifPhysicalLocation;
|
||||
message: { text: string };
|
||||
};
|
||||
importance: 'essential' | 'important' | string;
|
||||
}
|
||||
|
||||
export interface CapellaSarifResult {
|
||||
ruleId: `CWE-${number}`;
|
||||
level: CapellaSarifLevel;
|
||||
message: { text: string };
|
||||
locations: [{ physicalLocation: CapellaSarifPhysicalLocation }, ...unknown[]];
|
||||
codeFlows: Array<{ threadFlows: Array<{ locations: CapellaSarifThreadFlowLocation[] }> }>;
|
||||
properties: {
|
||||
severity: CapellaSarifSeverity;
|
||||
cwe: `CWE-${number}`;
|
||||
status: 'verified';
|
||||
description: string;
|
||||
findingSubType: 'AGENT_SAST';
|
||||
};
|
||||
}
|
||||
|
||||
export interface CapellaSarifRule {
|
||||
id: `CWE-${number}`;
|
||||
name: string;
|
||||
shortDescription: { text: string };
|
||||
fullDescription: { text: string };
|
||||
helpUri: string;
|
||||
properties: { cwe: `CWE-${number}`; tags: string[] };
|
||||
}
|
||||
|
||||
export interface CapellaSarif {
|
||||
$schema: string;
|
||||
version: '2.1.0';
|
||||
runs: [
|
||||
{
|
||||
tool: {
|
||||
driver: {
|
||||
name: string;
|
||||
version: string;
|
||||
informationUri: string;
|
||||
rules: CapellaSarifRule[];
|
||||
};
|
||||
};
|
||||
results: CapellaSarifResult[];
|
||||
properties: { repository: string; totalFindings: number };
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export interface SarifValidationResult {
|
||||
readonly valid: boolean;
|
||||
readonly errors: string[];
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isText(value: unknown): value is { text: string } {
|
||||
return isRecord(value) && typeof value.text === 'string' && value.text.length > 0;
|
||||
}
|
||||
|
||||
function validatePhysicalLocation(value: unknown, errors: string[], label: string, requireBase: boolean): void {
|
||||
if (!isRecord(value)) {
|
||||
errors.push(`${label} must be an object`);
|
||||
return;
|
||||
}
|
||||
const artifact = value.artifactLocation;
|
||||
const region = value.region;
|
||||
if (!isRecord(artifact) || typeof artifact.uri !== 'string' || !isNormalizedRepositoryPath(artifact.uri)) {
|
||||
errors.push(`${label}.artifactLocation.uri must be a normalized repository-relative path`);
|
||||
}
|
||||
if (requireBase && (!isRecord(artifact) || artifact.uriBaseId !== '%SRCROOT%')) {
|
||||
errors.push(`${label}.artifactLocation.uriBaseId must be %SRCROOT%`);
|
||||
}
|
||||
if (!isRecord(region) || !Number.isSafeInteger(region.startLine) || Number(region.startLine) <= 0) {
|
||||
errors.push(`${label}.region.startLine must be a positive integer`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateResult(value: unknown, ruleIds: Set<string>, errors: string[], index: number): void {
|
||||
const label = `runs[0].results[${index}]`;
|
||||
if (!isRecord(value)) {
|
||||
errors.push(`${label} must be an object`);
|
||||
return;
|
||||
}
|
||||
const ruleId = value.ruleId;
|
||||
if (typeof ruleId !== 'string' || !/^CWE-\d+$/.test(ruleId)) errors.push(`${label}.ruleId must be a bare CWE`);
|
||||
if (typeof ruleId === 'string' && !ruleIds.has(ruleId)) errors.push(`${label}.ruleId has no matching rule metadata`);
|
||||
if (!['error', 'warning', 'note'].includes(String(value.level))) errors.push(`${label}.level is invalid`);
|
||||
if (!isText(value.message)) errors.push(`${label}.message.text is required`);
|
||||
if (!Array.isArray(value.locations) || value.locations.length === 0 || !isRecord(value.locations[0])) {
|
||||
errors.push(`${label}.locations[0] is required`);
|
||||
} else {
|
||||
validatePhysicalLocation(
|
||||
value.locations[0].physicalLocation,
|
||||
errors,
|
||||
`${label}.locations[0].physicalLocation`,
|
||||
true,
|
||||
);
|
||||
}
|
||||
const properties = value.properties;
|
||||
if (!isRecord(properties)) {
|
||||
errors.push(`${label}.properties is required`);
|
||||
} else {
|
||||
if (!['Critical', 'High', 'Medium', 'Low', 'Info'].includes(String(properties.severity))) {
|
||||
errors.push(`${label}.properties.severity is invalid`);
|
||||
}
|
||||
if (properties.cwe !== ruleId) errors.push(`${label}.properties.cwe must equal ruleId`);
|
||||
if (properties.status !== 'verified') errors.push(`${label}.properties.status must be verified`);
|
||||
if (properties.findingSubType !== 'AGENT_SAST') errors.push(`${label}.properties.findingSubType is invalid`);
|
||||
if (typeof properties.description !== 'string') errors.push(`${label}.properties.description must be a string`);
|
||||
// The intake parser rejects results carrying these properties; refusing them
|
||||
// at publish time surfaces the violation to the producer instead of dropping
|
||||
// findings at intake.
|
||||
for (const forbidden of ['invariantDescription', 'owasp_category', 'proofOfConcept']) {
|
||||
if (forbidden in properties) errors.push(`${label}.properties.${forbidden} is forbidden`);
|
||||
}
|
||||
}
|
||||
if (!Array.isArray(value.codeFlows)) {
|
||||
errors.push(`${label}.codeFlows must be an array`);
|
||||
} else {
|
||||
value.codeFlows.forEach((flow, flowIndex) => {
|
||||
if (!isRecord(flow) || !Array.isArray(flow.threadFlows)) {
|
||||
errors.push(`${label}.codeFlows[${flowIndex}].threadFlows must be an array`);
|
||||
return;
|
||||
}
|
||||
flow.threadFlows.forEach((thread, threadIndex) => {
|
||||
if (!isRecord(thread) || !Array.isArray(thread.locations)) {
|
||||
errors.push(`${label}.codeFlows[${flowIndex}].threadFlows[${threadIndex}].locations must be an array`);
|
||||
return;
|
||||
}
|
||||
thread.locations.forEach((location, locationIndex) => {
|
||||
if (!isRecord(location) || !isRecord(location.location)) {
|
||||
errors.push(`${label}.codeFlows location must be an object`);
|
||||
return;
|
||||
}
|
||||
validatePhysicalLocation(
|
||||
location.location.physicalLocation,
|
||||
errors,
|
||||
`${label}.codeFlows[${flowIndex}].threadFlows[${threadIndex}].locations[${locationIndex}]`,
|
||||
false,
|
||||
);
|
||||
if (!isText(location.location.message)) errors.push(`${label}.codeFlows location message is required`);
|
||||
if (typeof location.importance !== 'string')
|
||||
errors.push(`${label}.codeFlows location importance is required`);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a document against the exact producer/consumer profile above. The
|
||||
* exporter refuses to publish a document this rejects, which is what lets the
|
||||
* reconciliation intake treat a violation as corruption instead of noise.
|
||||
*/
|
||||
export function validateCapellaSarif(value: unknown): SarifValidationResult {
|
||||
const errors: string[] = [];
|
||||
if (!isRecord(value)) return { valid: false, errors: ['SARIF document must be an object'] };
|
||||
if (value.$schema !== CAPELLA_SARIF_SCHEMA) errors.push('$schema is invalid');
|
||||
if (value.version !== '2.1.0') errors.push('version must be 2.1.0');
|
||||
if (!Array.isArray(value.runs) || value.runs.length !== 1 || !isRecord(value.runs[0])) {
|
||||
errors.push('runs must contain exactly one run');
|
||||
return { valid: false, errors };
|
||||
}
|
||||
const run = value.runs[0];
|
||||
const driver = isRecord(run.tool) && isRecord(run.tool.driver) ? run.tool.driver : undefined;
|
||||
if (!driver) {
|
||||
errors.push('runs[0].tool.driver is required');
|
||||
return { valid: false, errors };
|
||||
}
|
||||
if (driver.name !== CAPELLA_SARIF_DRIVER_NAME) errors.push('driver.name is invalid');
|
||||
if (driver.version !== CAPELLA_SARIF_DRIVER_VERSION) errors.push('driver.version is invalid');
|
||||
if (driver.informationUri !== CAPELLA_SARIF_INFORMATION_URI) errors.push('driver.informationUri is invalid');
|
||||
const ruleIds = new Set<string>();
|
||||
if (!Array.isArray(driver.rules)) {
|
||||
errors.push('driver.rules must be an array');
|
||||
} else {
|
||||
driver.rules.forEach((rule, index) => {
|
||||
if (!isRecord(rule) || typeof rule.id !== 'string' || !/^CWE-\d+$/.test(rule.id)) {
|
||||
errors.push(`driver.rules[${index}].id must be a bare CWE`);
|
||||
return;
|
||||
}
|
||||
if (ruleIds.has(rule.id)) errors.push(`driver.rules[${index}].id is duplicated`);
|
||||
ruleIds.add(rule.id);
|
||||
if (typeof rule.name !== 'string' || rule.name.length === 0)
|
||||
errors.push(`driver.rules[${index}].name is required`);
|
||||
if (!isText(rule.shortDescription) || !isText(rule.fullDescription))
|
||||
errors.push(`driver.rules[${index}] descriptions are required`);
|
||||
if (typeof rule.helpUri !== 'string' || rule.helpUri.length === 0) {
|
||||
errors.push(`driver.rules[${index}].helpUri is required`);
|
||||
}
|
||||
if (
|
||||
!isRecord(rule.properties) ||
|
||||
rule.properties.cwe !== rule.id ||
|
||||
!Array.isArray(rule.properties.tags) ||
|
||||
!rule.properties.tags.every((tag) => typeof tag === 'string')
|
||||
) {
|
||||
errors.push(`driver.rules[${index}] properties are invalid`);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (!Array.isArray(run.results)) {
|
||||
errors.push('runs[0].results must be an array');
|
||||
} else {
|
||||
run.results.forEach((result, index) => {
|
||||
validateResult(result, ruleIds, errors, index);
|
||||
});
|
||||
}
|
||||
if (!isRecord(run.properties)) {
|
||||
errors.push('runs[0].properties is required');
|
||||
} else {
|
||||
if (typeof run.properties.repository !== 'string') errors.push('runs[0].properties.repository must be a string');
|
||||
if (!Number.isSafeInteger(run.properties.totalFindings)) {
|
||||
errors.push('runs[0].properties.totalFindings must be an integer');
|
||||
} else if (Array.isArray(run.results) && run.properties.totalFindings !== run.results.length) {
|
||||
errors.push('runs[0].properties.totalFindings does not match results.length');
|
||||
}
|
||||
}
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
// 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.
|
||||
|
||||
/** Shared neutral contracts for Capella execution and SARIF handoff. */
|
||||
|
||||
export interface SarifRef {
|
||||
path: string;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* The one human-facing name per stage, shared by the scan log and the `shannon status`
|
||||
* progress tree so an operator reads the same word in both places. This module imports
|
||||
* nothing, so the parent workflow can use it inside the Temporal sandbox.
|
||||
*/
|
||||
export const CAPELLA_STAGE_LABELS: Readonly<Record<CapellaStage, string>> = {
|
||||
architecture: 'Architecture',
|
||||
'threat-model': 'Threat model',
|
||||
plan: 'Plan',
|
||||
research: 'Research',
|
||||
dedupe: 'Dedupe',
|
||||
review: 'Review',
|
||||
critic: 'Critique',
|
||||
confirm: 'Confirm',
|
||||
calibrate: 'Calibrate',
|
||||
export: 'Export',
|
||||
};
|
||||
|
||||
/**
|
||||
* Export writes artifacts but runs no model, so it is the one stage the progress tree
|
||||
* leaves out: a row that can only ever read 0s tells an operator nothing.
|
||||
*/
|
||||
export const CAPELLA_PROGRESS_STAGES: readonly CapellaStage[] = CAPELLA_STAGES.filter((stage) => stage !== 'export');
|
||||
|
||||
export type CapellaFailurePoint = CapellaStage | 'workflow';
|
||||
|
||||
export interface CapellaUsage {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheReadTokens: number;
|
||||
cacheWriteTokens: number;
|
||||
costUsd: number;
|
||||
turns: number;
|
||||
}
|
||||
|
||||
/** Architecture-stage reduction: malformed model-authored KB items were dropped. */
|
||||
export interface AgenticSastArchitectureReduction {
|
||||
readonly stage: 'architecture';
|
||||
readonly reason: 'invalid_architecture_items';
|
||||
readonly entityCount: number;
|
||||
readonly omittedEntityCount: number;
|
||||
readonly dependencyCount: number;
|
||||
readonly omittedDependencyCount: number;
|
||||
}
|
||||
|
||||
/** Plan-stage reduction: malformed investigations were dropped before research. */
|
||||
export interface AgenticSastPlanReduction {
|
||||
readonly stage: 'plan';
|
||||
readonly reason: 'invalid_investigations';
|
||||
readonly consideredCount: number;
|
||||
readonly usableCount: number;
|
||||
readonly omittedCount: number;
|
||||
}
|
||||
|
||||
/** Aggregate research reduction across triage and deep-audit units. */
|
||||
export interface AgenticSastResearchReduction {
|
||||
readonly stage: 'research';
|
||||
readonly reason: 'incomplete_research';
|
||||
readonly triageConsideredCount: number;
|
||||
readonly triageClassifiedCount: number;
|
||||
readonly triageOmittedCount: number;
|
||||
readonly affectedTriageBatchCount: number;
|
||||
readonly auditUnitCount: number;
|
||||
readonly salvagedAuditSessionCount: number;
|
||||
}
|
||||
|
||||
/** Dedupe-stage reduction: invalid files or a salvaged turn-limit reduced coverage. */
|
||||
export interface AgenticSastDedupeReduction {
|
||||
readonly stage: 'dedupe';
|
||||
readonly reason: 'incomplete_dedupe';
|
||||
readonly consideredCount: number;
|
||||
readonly survivorCount: number;
|
||||
readonly unreadableCount: number;
|
||||
readonly salvagedTurnLimitCount: number;
|
||||
}
|
||||
|
||||
interface AgenticSastVerdictReductionBase {
|
||||
readonly consideredCount: number;
|
||||
readonly gradedCount: number;
|
||||
readonly missingCount: number;
|
||||
readonly unreadableCount: number;
|
||||
readonly rejectedUnexpectedCount: number;
|
||||
readonly rejectedDuplicateCount: number;
|
||||
readonly salvagedTurnLimitCount: number;
|
||||
}
|
||||
|
||||
/** Review-stage reduction. Ungraded survivors are quarantined before publication. */
|
||||
export interface AgenticSastReviewReduction extends AgenticSastVerdictReductionBase {
|
||||
readonly stage: 'review';
|
||||
readonly reason: 'incomplete_review';
|
||||
readonly quarantinedCount: number;
|
||||
}
|
||||
|
||||
export interface AgenticSastCriticReduction extends AgenticSastVerdictReductionBase {
|
||||
readonly stage: 'critic';
|
||||
readonly reason: 'incomplete_critic';
|
||||
}
|
||||
|
||||
export interface AgenticSastConfirmReduction extends AgenticSastVerdictReductionBase {
|
||||
readonly stage: 'confirm';
|
||||
readonly reason: 'incomplete_confirm';
|
||||
}
|
||||
|
||||
export interface AgenticSastCalibrateReduction extends AgenticSastVerdictReductionBase {
|
||||
readonly stage: 'calibrate';
|
||||
readonly reason: 'incomplete_calibrate';
|
||||
}
|
||||
|
||||
export type CapellaFallbackStage = Exclude<CapellaStage, 'export'>;
|
||||
|
||||
/** A failed stage completed from the last verified finding artifact instead. */
|
||||
export interface AgenticSastFallbackReduction {
|
||||
readonly stage: CapellaFallbackStage;
|
||||
readonly reason: 'failed_stage_fallback';
|
||||
readonly fallbackFindingCount: number;
|
||||
}
|
||||
|
||||
/** Export-stage reduction: findings dropped because their records were malformed. */
|
||||
export interface AgenticSastExportReduction {
|
||||
readonly stage: 'export';
|
||||
readonly reason: 'malformed_findings';
|
||||
readonly omittedCount: number;
|
||||
readonly consideredCount: number;
|
||||
readonly omissions: readonly AgenticSastOmission[];
|
||||
}
|
||||
|
||||
/**
|
||||
* One reduced-coverage fact. A run carries at most one member per stage, in stage order. New
|
||||
* reductions project bounded counts only. The pre-existing export reduction is the intentional
|
||||
* exception: it retains bounded, sanitized omission identity and display-name details.
|
||||
*/
|
||||
export type AgenticSastReduction =
|
||||
| AgenticSastArchitectureReduction
|
||||
| AgenticSastPlanReduction
|
||||
| AgenticSastResearchReduction
|
||||
| AgenticSastDedupeReduction
|
||||
| AgenticSastReviewReduction
|
||||
| AgenticSastCriticReduction
|
||||
| AgenticSastConfirmReduction
|
||||
| AgenticSastCalibrateReduction
|
||||
| AgenticSastFallbackReduction
|
||||
| AgenticSastExportReduction;
|
||||
|
||||
export interface AgenticSastOmission {
|
||||
readonly findingId?: string;
|
||||
readonly displayName?: string;
|
||||
readonly reason: 'invalid_finding_record' | 'missing_code_path' | 'invalid_code_path';
|
||||
}
|
||||
|
||||
/** Original bounded failure retained when the child finishes from a last-good artifact. */
|
||||
export interface CapellaRecoveredFailure {
|
||||
readonly failedStage: CapellaFallbackStage;
|
||||
readonly error: string;
|
||||
readonly errorCode?: string;
|
||||
readonly completedStages: readonly CapellaStage[];
|
||||
}
|
||||
|
||||
export type CapellaRunResult =
|
||||
| {
|
||||
status: 'succeeded';
|
||||
sarif: SarifRef;
|
||||
findingCount: number;
|
||||
coverage: 'complete' | 'reduced';
|
||||
durationMs: number;
|
||||
usage: CapellaUsage;
|
||||
usageComplete: boolean;
|
||||
warnings: string[];
|
||||
reductions?: readonly AgenticSastReduction[];
|
||||
recoveredFailure?: CapellaRecoveredFailure;
|
||||
}
|
||||
| {
|
||||
status: 'failed';
|
||||
failedStage: CapellaFailurePoint;
|
||||
error: string;
|
||||
/** Bounded machine code from the failing activity's classified failure, when available. */
|
||||
errorCode?: string;
|
||||
durationMs: number;
|
||||
usage: CapellaUsage;
|
||||
usageComplete: boolean;
|
||||
completedStages: CapellaStage[];
|
||||
warnings: string[];
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
// 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.
|
||||
|
||||
/** One structured-generation request with exactly one output-schema tool. */
|
||||
export interface StructuredGenerationRequest {
|
||||
systemPrompt?: string;
|
||||
userContent: string;
|
||||
tool: {
|
||||
name: 'submit_result';
|
||||
description: string;
|
||||
parametersJsonSchema: Record<string, unknown>;
|
||||
};
|
||||
maxTokens: number;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/** Typed classification of a failed provider request, set whenever `errorMessage` is. */
|
||||
export interface StructuredGenerationProviderFailure {
|
||||
readonly type: 'AuthenticationError' | 'ConfigurationError' | 'AgentExecutionError';
|
||||
readonly retryable: boolean;
|
||||
}
|
||||
|
||||
/** Host-neutral outcome of one structured generation request. */
|
||||
export interface StructuredGenerationResult {
|
||||
stopReason: 'toolUse' | 'stop' | 'length' | 'error' | 'aborted';
|
||||
toolCalls: Array<{ name: string; arguments: unknown }>;
|
||||
usage: {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
costUsd: number;
|
||||
};
|
||||
errorMessage?: string;
|
||||
/** Consumers branch on this typed flag, never on `errorMessage` text. */
|
||||
providerFailure?: StructuredGenerationProviderFailure;
|
||||
}
|
||||
|
||||
/** Host-supplied transport that makes exactly one model request per call. */
|
||||
export interface StructuredGenerationPort<TModelContext> {
|
||||
generate(request: StructuredGenerationRequest, modelContext: TModelContext): Promise<StructuredGenerationResult>;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (C) 2025 Keygraph, Inc.
|
||||
// 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
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (C) 2025 Keygraph, Inc.
|
||||
// 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
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (C) 2025 Keygraph, Inc.
|
||||
// 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
|
||||
@@ -14,11 +14,30 @@
|
||||
import { PentestError } from '../services/error-handling.js';
|
||||
import { ErrorCode } from '../types/errors.js';
|
||||
import type { AgentEndResult } from '../types/index.js';
|
||||
import type { AgentMetrics } from '../types/metrics.js';
|
||||
import {
|
||||
type DurableScanState,
|
||||
type MiscellaneousOutcome,
|
||||
type PartialReason,
|
||||
type ReportProgress,
|
||||
type ReportSarifDisposition,
|
||||
RunStateError,
|
||||
type StoredPdfProvenance,
|
||||
} from '../types/run-state.js';
|
||||
import { SessionMutex } from '../utils/concurrency.js';
|
||||
import { formatTimestamp } from '../utils/formatting.js';
|
||||
import { AgentLogger } from './logger.js';
|
||||
import { MetricsTracker } from './metrics-tracker.js';
|
||||
import { initializeAuditStructure, type SessionMetadata } from './utils.js';
|
||||
import { fileExists } from '../utils/file-io.js';
|
||||
import {
|
||||
MetricsTracker,
|
||||
type TerminalWorkflowMetricsInput,
|
||||
type TerminalWorkflowMetricTotals,
|
||||
} from './metrics-tracker.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
|
||||
@@ -26,14 +45,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) {
|
||||
@@ -82,8 +104,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;
|
||||
}
|
||||
@@ -100,101 +123,42 @@ 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> {
|
||||
// 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;
|
||||
|
||||
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 }),
|
||||
};
|
||||
await this.workflowLogger.logAgent(agentName, 'end', agentLogDetails);
|
||||
async endAgent(agentName: LoggableAgentName, result: AgentEndResult): Promise<void> {
|
||||
await this.finishAgentLogs(agentName, result);
|
||||
|
||||
// 3. Acquire mutex before touching session.json
|
||||
const unlock = await sessionMutex.lock(this.sessionId);
|
||||
@@ -207,6 +171,159 @@ export class AuditSession {
|
||||
}
|
||||
}
|
||||
|
||||
/** Record a successful report-model attempt as a nonterminal durable draft. */
|
||||
async endReportDraft(result: AgentEndResult): Promise<ReportProgress> {
|
||||
await this.finishAgentLogs('report', result);
|
||||
|
||||
const unlock = await sessionMutex.lock(this.sessionId);
|
||||
try {
|
||||
await this.metricsTracker.reload();
|
||||
return await this.metricsTracker.recordReportDraft(result);
|
||||
} finally {
|
||||
unlock();
|
||||
}
|
||||
}
|
||||
|
||||
/** 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.errorCode !== undefined && { errorCode: result.errorCode }),
|
||||
};
|
||||
await this.workflowLogger.logAgent(agentName, 'end', agentLogDetails);
|
||||
await this.workflowLogger.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize fresh durable state or validate a resume record without reconstructing it.
|
||||
*
|
||||
* This is the first activity of every run, so it is also where a fresh workspace's
|
||||
* session.json is created. It therefore takes the workflow id explicitly: initializing
|
||||
* without one would persist a session with no `originalWorkflowId`, and later calls load
|
||||
* the existing file rather than rewriting identity, leaving the scan unresolvable.
|
||||
*/
|
||||
async initializeDurableScanState(
|
||||
workflowId: string,
|
||||
exploit: boolean,
|
||||
context: 'fresh' | 'resume',
|
||||
): Promise<DurableScanState> {
|
||||
if (context === 'resume' && !(await fileExists(generateSessionJsonPath(this.sessionMetadata)))) {
|
||||
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 {
|
||||
await this.metricsTracker.reload();
|
||||
return await this.metricsTracker.initializeDurableScanState(exploit, context);
|
||||
} finally {
|
||||
unlock();
|
||||
}
|
||||
}
|
||||
|
||||
/** Return a validated snapshot of durable execution state. */
|
||||
async getDurableScanState(): Promise<DurableScanState> {
|
||||
await this.ensureInitialized();
|
||||
const unlock = await sessionMutex.lock(this.sessionId);
|
||||
try {
|
||||
await this.metricsTracker.reload();
|
||||
return this.metricsTracker.getDurableScanState();
|
||||
} finally {
|
||||
unlock();
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist a `miscellaneous` branch outcome under the session lock. */
|
||||
async updateMiscellaneousOutcome(outcome: MiscellaneousOutcome): Promise<DurableScanState> {
|
||||
await this.ensureInitialized();
|
||||
const unlock = await sessionMutex.lock(this.sessionId);
|
||||
try {
|
||||
await this.metricsTracker.reload();
|
||||
return await this.metricsTracker.updateMiscellaneousOutcome(outcome);
|
||||
} finally {
|
||||
unlock();
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist the ordered renumber-failure set and durable partial reasons before assembly. */
|
||||
async initializeReportProgress(
|
||||
failedClasses: readonly import('../types/reconciliation.js').ReconciliationClass[],
|
||||
partialReasons: readonly PartialReason[],
|
||||
): Promise<ReportProgress> {
|
||||
await this.ensureInitialized();
|
||||
const unlock = await sessionMutex.lock(this.sessionId);
|
||||
try {
|
||||
await this.metricsTracker.reload();
|
||||
return await this.metricsTracker.initializeReportProgress(failedClasses, partialReasons);
|
||||
} finally {
|
||||
unlock();
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist the post-compaction canonical report checkpoint without terminal success. */
|
||||
async recordCanonicalReportCheckpoint(
|
||||
checkpoint: string,
|
||||
appendReasons: readonly PartialReason[] = [],
|
||||
): Promise<ReportProgress> {
|
||||
await this.ensureInitialized();
|
||||
const unlock = await sessionMutex.lock(this.sessionId);
|
||||
try {
|
||||
await this.metricsTracker.reload();
|
||||
return await this.metricsTracker.recordCanonicalReportCheckpoint(checkpoint, appendReasons);
|
||||
} finally {
|
||||
unlock();
|
||||
}
|
||||
}
|
||||
|
||||
/** Atomically mark report finalized and successful after external proof validation. */
|
||||
async finalizeReportProgress(
|
||||
finalCheckpoint: string,
|
||||
manifestSha256: string,
|
||||
terminal: {
|
||||
readonly sarifDisposition: ReportSarifDisposition;
|
||||
readonly pdfProvenance: StoredPdfProvenance | null;
|
||||
readonly partialReasons: readonly PartialReason[];
|
||||
},
|
||||
): Promise<ReportProgress> {
|
||||
await this.ensureInitialized();
|
||||
const unlock = await sessionMutex.lock(this.sessionId);
|
||||
try {
|
||||
await this.metricsTracker.reload();
|
||||
return await this.metricsTracker.finalizeReportProgress(finalCheckpoint, manifestSha256, terminal);
|
||||
} finally {
|
||||
unlock();
|
||||
}
|
||||
}
|
||||
|
||||
/** Return an invalid model draft to pending without erasing its billable attempt. */
|
||||
async rollbackReportDraft(): Promise<ReportProgress> {
|
||||
await this.ensureInitialized();
|
||||
const unlock = await sessionMutex.lock(this.sessionId);
|
||||
try {
|
||||
await this.metricsTracker.reload();
|
||||
return await this.metricsTracker.rollbackReportDraft();
|
||||
} finally {
|
||||
unlock();
|
||||
}
|
||||
}
|
||||
|
||||
/** Read persisted report metrics for model-skip resume. */
|
||||
async getReportMetrics(): Promise<AgentMetrics> {
|
||||
await this.ensureInitialized();
|
||||
const unlock = await sessionMutex.lock(this.sessionId);
|
||||
try {
|
||||
await this.metricsTracker.reload();
|
||||
return this.metricsTracker.getReportMetrics();
|
||||
} finally {
|
||||
unlock();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update session status
|
||||
*/
|
||||
@@ -222,6 +339,22 @@ export class AuditSession {
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist the terminal workflow projection under its retry-stable workflow id. */
|
||||
async recordTerminalWorkflowMetrics(
|
||||
workflowId: string,
|
||||
input: TerminalWorkflowMetricsInput,
|
||||
): Promise<TerminalWorkflowMetricTotals> {
|
||||
await this.ensureInitialized();
|
||||
|
||||
const unlock = await sessionMutex.lock(this.sessionId);
|
||||
try {
|
||||
await this.metricsTracker.reload();
|
||||
return await this.metricsTracker.recordTerminalWorkflowMetrics(workflowId, input);
|
||||
} finally {
|
||||
unlock();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current metrics (read-only)
|
||||
*/
|
||||
@@ -233,17 +366,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();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -251,7 +392,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();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -274,17 +419,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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (C) 2025 Keygraph, Inc.
|
||||
// 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
|
||||
|
||||
@@ -1,127 +1,215 @@
|
||||
// Copyright (C) 2025 Keygraph, Inc.
|
||||
// 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.
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (C) 2025 Keygraph, Inc.
|
||||
// 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
|
||||
@@ -15,8 +15,25 @@ import { PentestError } from '../services/error-handling.js';
|
||||
import { AGENT_PHASE_MAP, type PhaseName } from '../session-manager.js';
|
||||
import { ErrorCode } from '../types/errors.js';
|
||||
import type { AgentEndResult, AgentName } from '../types/index.js';
|
||||
import type { AgentMetrics } from '../types/metrics.js';
|
||||
import {
|
||||
appendPartialReasons,
|
||||
createInitialDurableScanState,
|
||||
type DurableScanState,
|
||||
isDurableScanState,
|
||||
isOrderedPartialReasonSet,
|
||||
type MiscellaneousOutcome,
|
||||
type PartialReason,
|
||||
type ReportProgress,
|
||||
type ReportSarifDisposition,
|
||||
RunStateError,
|
||||
recordMiscellaneousOutcome,
|
||||
type StoredPdfProvenance,
|
||||
} from '../types/run-state.js';
|
||||
import { atomicWrite, fileExists, readJson } from '../utils/file-io.js';
|
||||
import { calculatePercentage, formatTimestamp } from '../utils/formatting.js';
|
||||
import { mergeIntervalsDurationMs, type OperationalStageTiming } from './operational-summary.js';
|
||||
import { safeErrorFromCode } from './safe-fields.js';
|
||||
import { generateSessionJsonPath, type SessionMetadata } from './utils.js';
|
||||
|
||||
interface AttemptData {
|
||||
@@ -32,6 +49,7 @@ interface AttemptData {
|
||||
timestamp: string;
|
||||
model?: string | undefined;
|
||||
error?: string | undefined;
|
||||
error_code?: ErrorCode | undefined;
|
||||
}
|
||||
|
||||
interface AgentAuditMetrics {
|
||||
@@ -54,6 +72,83 @@ interface PhaseMetrics {
|
||||
agent_count: number;
|
||||
}
|
||||
|
||||
interface OperationalAuditMetrics {
|
||||
duration_ms: number;
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
cache_read_tokens: number;
|
||||
cache_write_tokens: number;
|
||||
cost_usd: number;
|
||||
turns: number;
|
||||
usage_complete: boolean;
|
||||
}
|
||||
|
||||
/** One operational stage's wall-clock span, as persisted per run. */
|
||||
interface StageSpan {
|
||||
started_at_ms: number;
|
||||
duration_ms: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The stage families `total_operational_duration_ms` and the `background` phase are defined over:
|
||||
* agentic SAST and finding reconciliation. Report steps and the miscellaneous lane are pipeline
|
||||
* work, not operational spend, so their spans are excluded and those two fields keep the meaning
|
||||
* they document.
|
||||
*/
|
||||
const OPERATIONAL_STAGE_FAMILIES: readonly string[] = ['agentic-sast', 'reconciliation'];
|
||||
|
||||
function isOperationalStageKey(stageKey: string): boolean {
|
||||
return OPERATIONAL_STAGE_FAMILIES.some((family) => stageKey === family || stageKey.startsWith(`${family}:`));
|
||||
}
|
||||
|
||||
interface TerminalRunMetrics {
|
||||
status: 'completed' | 'failed' | 'cancelled' | 'partial';
|
||||
started_at: string;
|
||||
ended_at: string;
|
||||
wall_duration_ms: number;
|
||||
usage_accounting_complete: boolean;
|
||||
/** Usage-accounting warnings for this run; always an array, empty when the ledger reconciled. */
|
||||
usage_accounting_warnings: string[];
|
||||
}
|
||||
|
||||
/** One workflow's usage for a single operational metric key (an agentic-SAST stage, a reconciliation class). */
|
||||
export interface WorkflowOperationalMetric {
|
||||
readonly durationMs: number;
|
||||
readonly inputTokens: number | null;
|
||||
readonly outputTokens: number | null;
|
||||
readonly cacheReadTokens: number | null;
|
||||
readonly cacheWriteTokens: number | null;
|
||||
readonly costUsd: number | null;
|
||||
readonly numTurns: number | null;
|
||||
readonly usageComplete?: boolean;
|
||||
}
|
||||
|
||||
/** The terminal projection recorded for one workflow execution, keyed by its retry-stable workflow id. */
|
||||
export interface TerminalWorkflowMetricsInput {
|
||||
readonly status: TerminalRunMetrics['status'];
|
||||
readonly startedAtMs: number;
|
||||
readonly endedAtMs: number;
|
||||
readonly usageAccountingComplete: boolean;
|
||||
readonly usageAccountingWarnings: readonly string[];
|
||||
readonly operationalMetrics: Readonly<Record<string, WorkflowOperationalMetric>>;
|
||||
/**
|
||||
* Real wall-clock spans for this run's operational stages. Priced metrics carry no faithful
|
||||
* duration — a reconciliation stage's `StageMetrics` records cost and tokens only — so this is
|
||||
* the sole source of operational timing.
|
||||
*/
|
||||
readonly operationalStages: Readonly<Record<string, OperationalStageTiming>>;
|
||||
}
|
||||
|
||||
/** Workspace-wide totals recomputed across every recorded run, returned after a terminal metrics write. */
|
||||
export interface TerminalWorkflowMetricTotals {
|
||||
readonly totalDurationMs: number;
|
||||
readonly totalCostUsd: number;
|
||||
readonly totalTurns: number;
|
||||
readonly runCount: number;
|
||||
readonly usageAccountingComplete: boolean;
|
||||
}
|
||||
|
||||
/** One recorded resume of a workspace, with the prior workflows it terminated and the checkpoint it restored. */
|
||||
export interface ResumeAttempt {
|
||||
workflowId: string;
|
||||
timestamp: string;
|
||||
@@ -74,10 +169,20 @@ interface SessionData {
|
||||
};
|
||||
metrics: {
|
||||
total_duration_ms: number;
|
||||
total_agent_duration_ms?: number;
|
||||
/** Wall time attributed to operational (non-agent) work — agentic SAST and reconciliation. */
|
||||
total_operational_duration_ms?: number;
|
||||
total_cost_usd: number;
|
||||
total_turns?: number;
|
||||
usage_accounting_complete?: boolean;
|
||||
phases: Record<string, PhaseMetrics>;
|
||||
agents: Record<string, AgentAuditMetrics>;
|
||||
operational?: Record<string, Record<string, OperationalAuditMetrics>>;
|
||||
/** Operational stage spans per run, keyed by workflow id then stage key. */
|
||||
stages?: Record<string, Record<string, StageSpan>>;
|
||||
runs?: Record<string, TerminalRunMetrics>;
|
||||
};
|
||||
durableScanState?: DurableScanState;
|
||||
}
|
||||
|
||||
interface ActiveTimer {
|
||||
@@ -134,9 +239,16 @@ export class MetricsTracker {
|
||||
},
|
||||
metrics: {
|
||||
total_duration_ms: 0,
|
||||
total_agent_duration_ms: 0,
|
||||
total_operational_duration_ms: 0,
|
||||
total_cost_usd: 0,
|
||||
total_turns: 0,
|
||||
usage_accounting_complete: true,
|
||||
phases: {}, // Phase-level aggregations
|
||||
agents: {}, // Agent-level metrics
|
||||
operational: {},
|
||||
stages: {},
|
||||
runs: {},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -176,51 +288,14 @@ export class MetricsTracker {
|
||||
);
|
||||
}
|
||||
|
||||
// 1. Initialize agent metrics if first time seeing this agent
|
||||
const existingAgent = this.data.metrics.agents[agentName];
|
||||
const agent = existingAgent ?? {
|
||||
status: 'in-progress' as const,
|
||||
attempts: [],
|
||||
final_duration_ms: 0,
|
||||
total_cost_usd: 0,
|
||||
total_input_tokens: 0,
|
||||
total_output_tokens: 0,
|
||||
total_cache_read_tokens: 0,
|
||||
total_cache_write_tokens: 0,
|
||||
};
|
||||
this.data.metrics.agents[agentName] = agent;
|
||||
|
||||
// 2. Build attempt record with optional model/error fields
|
||||
const attempt: AttemptData = {
|
||||
attempt_number: result.attemptNumber,
|
||||
duration_ms: result.duration_ms,
|
||||
cost_usd: result.cost_usd,
|
||||
success: result.success,
|
||||
timestamp: formatTimestamp(),
|
||||
...(result.input_tokens !== undefined && { input_tokens: result.input_tokens }),
|
||||
...(result.output_tokens !== undefined && { output_tokens: result.output_tokens }),
|
||||
...(result.cache_read_tokens !== undefined && { cache_read_tokens: result.cache_read_tokens }),
|
||||
...(result.cache_write_tokens !== undefined && { cache_write_tokens: result.cache_write_tokens }),
|
||||
...(result.turns !== undefined && { turns: result.turns }),
|
||||
};
|
||||
|
||||
if (result.model) {
|
||||
attempt.model = result.model;
|
||||
// The report agent never reaches success through this ordinary path. Its model attempt is
|
||||
// recorded as a nonterminal draft, and only verified finalization promotes it to success,
|
||||
// so a success here would let an unfinalized report look complete.
|
||||
if (agentName === 'report' && result.success) {
|
||||
throw new RunStateError('DurableStateConflictError', 'report-success-requires-terminal-promotion');
|
||||
}
|
||||
|
||||
if (result.error) {
|
||||
attempt.error = result.error;
|
||||
}
|
||||
|
||||
// 3. Append attempt to history
|
||||
agent.attempts.push(attempt);
|
||||
|
||||
// 4. Recalculate totals across all attempts (includes failures)
|
||||
agent.total_cost_usd = agent.attempts.reduce((sum, a) => sum + a.cost_usd, 0);
|
||||
agent.total_input_tokens = agent.attempts.reduce((sum, a) => sum + (a.input_tokens ?? 0), 0);
|
||||
agent.total_output_tokens = agent.attempts.reduce((sum, a) => sum + (a.output_tokens ?? 0), 0);
|
||||
agent.total_cache_read_tokens = agent.attempts.reduce((sum, a) => sum + (a.cache_read_tokens ?? 0), 0);
|
||||
agent.total_cache_write_tokens = agent.attempts.reduce((sum, a) => sum + (a.cache_write_tokens ?? 0), 0);
|
||||
const agent = this.appendAttempt(agentName, result);
|
||||
|
||||
// 5. Update agent status based on outcome
|
||||
if (result.success) {
|
||||
@@ -235,6 +310,11 @@ export class MetricsTracker {
|
||||
if (result.checkpoint) {
|
||||
agent.checkpoint = result.checkpoint;
|
||||
}
|
||||
|
||||
if (agentName === 'miscellaneous-exploit') {
|
||||
const durableState = this.requireDurableScanState();
|
||||
this.data.durableScanState = recordMiscellaneousOutcome(durableState, 'completed');
|
||||
}
|
||||
} else {
|
||||
// A non-final failed attempt stays in-progress (Temporal will retry); only the
|
||||
// terminal attempt (or an unqualified failure) marks the agent failed.
|
||||
@@ -251,6 +331,318 @@ export class MetricsTracker {
|
||||
await this.save();
|
||||
}
|
||||
|
||||
/** Initialize or validate the schema-1 state without reconstructing a missing resume record. */
|
||||
async initializeDurableScanState(exploit: boolean, context: 'fresh' | 'resume'): Promise<DurableScanState> {
|
||||
const data = this.requireData();
|
||||
const existing = data.durableScanState;
|
||||
if (existing !== undefined) {
|
||||
if (!isDurableScanState(existing)) {
|
||||
throw new RunStateError('CorruptedSessionError', 'durable-state-malformed');
|
||||
}
|
||||
if (existing.exploit !== exploit) {
|
||||
throw new RunStateError('IncompatibleWorkspaceError', 'exploit-mode-changed');
|
||||
}
|
||||
return structuredClone(existing);
|
||||
}
|
||||
|
||||
if (context === 'resume') {
|
||||
throw new RunStateError('IncompatibleWorkspaceError', 'durable-state-missing-on-resume');
|
||||
}
|
||||
const hasRecordedWork =
|
||||
Object.keys(data.metrics.agents).length > 0 || (data.session.resumeAttempts?.length ?? 0) > 0;
|
||||
if (hasRecordedWork) {
|
||||
throw new RunStateError('CorruptedSessionError', 'durable-state-missing-after-work');
|
||||
}
|
||||
|
||||
const initialized = createInitialDurableScanState(exploit);
|
||||
data.durableScanState = initialized;
|
||||
await this.save();
|
||||
return structuredClone(initialized);
|
||||
}
|
||||
|
||||
/** Return validated durable state. */
|
||||
getDurableScanState(): DurableScanState {
|
||||
return structuredClone(this.requireDurableScanState());
|
||||
}
|
||||
|
||||
/** Persist the internal `miscellaneous` result and append its agent only for actionable exploitation. */
|
||||
async updateMiscellaneousOutcome(outcome: MiscellaneousOutcome): Promise<DurableScanState> {
|
||||
const data = this.requireData();
|
||||
const next = recordMiscellaneousOutcome(this.requireDurableScanState(), outcome);
|
||||
if (!isDurableScanState(next)) {
|
||||
throw new RunStateError('DurableStateConflictError', 'miscellaneous-outcome-produced-invalid-state');
|
||||
}
|
||||
data.durableScanState = next;
|
||||
await this.save();
|
||||
return structuredClone(next);
|
||||
}
|
||||
|
||||
/** Persist the complete failed-class set and durable partial reasons before report assembly. */
|
||||
async initializeReportProgress(
|
||||
failedClasses: readonly import('../types/reconciliation.js').ReconciliationClass[],
|
||||
partialReasons: readonly PartialReason[],
|
||||
): Promise<ReportProgress> {
|
||||
const data = this.requireData();
|
||||
const durableState = this.requireDurableScanState();
|
||||
if (!isOrderedPartialReasonSet(partialReasons)) {
|
||||
throw new RunStateError('DurableStateConflictError', 'report-pending-reasons-invalid');
|
||||
}
|
||||
if (durableState.report !== undefined) {
|
||||
if (!this.arraysEqual(durableState.report.renumber_failed_classes, failedClasses)) {
|
||||
throw new RunStateError('DurableStateConflictError', 'report-failed-class-set-changed');
|
||||
}
|
||||
// A lost-acknowledgement re-drive adopts the same set; a resume may append newly
|
||||
// observed reasons, but never removes a durable one. Append preserves every existing
|
||||
// member, so an unchanged length means nothing new was observed.
|
||||
const merged = appendPartialReasons(durableState.report.partial_reasons, partialReasons);
|
||||
if (merged.length === durableState.report.partial_reasons.length) {
|
||||
return structuredClone(durableState.report);
|
||||
}
|
||||
const report: ReportProgress = { ...durableState.report, partial_reasons: merged };
|
||||
const next = { ...durableState, report };
|
||||
if (!isDurableScanState(next)) {
|
||||
throw new RunStateError('DurableStateConflictError', 'report-pending-reasons-conflict');
|
||||
}
|
||||
data.durableScanState = next;
|
||||
await this.save();
|
||||
return structuredClone(report);
|
||||
}
|
||||
|
||||
const report: ReportProgress = {
|
||||
stage: 'pending',
|
||||
renumber_failed_classes: [...failedClasses],
|
||||
partial_reasons: appendPartialReasons([], partialReasons),
|
||||
};
|
||||
const next = { ...durableState, report };
|
||||
if (!isDurableScanState(next)) {
|
||||
throw new RunStateError('DurableStateConflictError', 'report-pending-invalid');
|
||||
}
|
||||
data.durableScanState = next;
|
||||
await this.save();
|
||||
return structuredClone(report);
|
||||
}
|
||||
|
||||
/** Record billable report-model metrics and a real Git checkpoint without terminal success. */
|
||||
async recordReportDraft(result: AgentEndResult): Promise<ReportProgress> {
|
||||
const data = this.requireData();
|
||||
const checkpoint = result.checkpoint;
|
||||
if (!result.success || checkpoint === undefined) {
|
||||
throw new RunStateError('DurableStateConflictError', 'report-draft-requires-success-checkpoint');
|
||||
}
|
||||
const durableState = this.requireDurableScanState();
|
||||
const current = durableState.report;
|
||||
if (current === undefined || current.stage === 'finalized') {
|
||||
throw new RunStateError('DurableStateConflictError', 'report-draft-invalid-source-stage');
|
||||
}
|
||||
if (current.stage === 'draft') {
|
||||
if (current.model_checkpoint !== checkpoint) {
|
||||
throw new RunStateError('DurableStateConflictError', 'report-model-checkpoint-conflict');
|
||||
}
|
||||
return structuredClone(current);
|
||||
}
|
||||
|
||||
const agent = this.appendAttempt('report', result);
|
||||
agent.status = 'in-progress';
|
||||
agent.final_duration_ms = result.duration_ms;
|
||||
agent.checkpoint = checkpoint;
|
||||
if (result.model !== undefined) {
|
||||
agent.model = result.model;
|
||||
} else {
|
||||
delete agent.model;
|
||||
}
|
||||
|
||||
const report: ReportProgress = {
|
||||
stage: 'draft',
|
||||
renumber_failed_classes: [...current.renumber_failed_classes],
|
||||
partial_reasons: [...current.partial_reasons],
|
||||
model_checkpoint: checkpoint,
|
||||
};
|
||||
const next = { ...durableState, report };
|
||||
if (!isDurableScanState(next)) {
|
||||
throw new RunStateError('DurableStateConflictError', 'report-draft-invalid');
|
||||
}
|
||||
data.durableScanState = next;
|
||||
this.activeTimers.delete('report');
|
||||
this.recalculateAggregations();
|
||||
await this.save();
|
||||
return structuredClone(report);
|
||||
}
|
||||
|
||||
/** Record the post-compaction canonical checkpoint while keeping report nonterminal. */
|
||||
async recordCanonicalReportCheckpoint(
|
||||
checkpoint: string,
|
||||
appendReasons: readonly PartialReason[] = [],
|
||||
): Promise<ReportProgress> {
|
||||
const data = this.requireData();
|
||||
const durableState = this.requireDurableScanState();
|
||||
const current = durableState.report;
|
||||
if (current?.stage === 'finalized') {
|
||||
if (current.canonical_checkpoint !== checkpoint) {
|
||||
throw new RunStateError('DurableStateConflictError', 'report-canonical-checkpoint-conflict');
|
||||
}
|
||||
return structuredClone(current);
|
||||
}
|
||||
if (current?.stage !== 'draft') {
|
||||
throw new RunStateError('DurableStateConflictError', 'report-canonical-invalid-source-stage');
|
||||
}
|
||||
const mergedReasons = appendPartialReasons(current.partial_reasons, appendReasons);
|
||||
if (current.canonical_checkpoint !== undefined) {
|
||||
if (current.canonical_checkpoint !== checkpoint) {
|
||||
throw new RunStateError('DurableStateConflictError', 'report-canonical-checkpoint-conflict');
|
||||
}
|
||||
if (mergedReasons.length === current.partial_reasons.length) {
|
||||
return structuredClone(current);
|
||||
}
|
||||
}
|
||||
|
||||
const report: ReportProgress = {
|
||||
...current,
|
||||
partial_reasons: mergedReasons,
|
||||
canonical_checkpoint: checkpoint,
|
||||
};
|
||||
const next = { ...durableState, report };
|
||||
if (!isDurableScanState(next)) {
|
||||
throw new RunStateError('DurableStateConflictError', 'report-canonical-invalid');
|
||||
}
|
||||
data.durableScanState = next;
|
||||
await this.save();
|
||||
return structuredClone(report);
|
||||
}
|
||||
|
||||
/**
|
||||
* Promote a verified finalization commit to the only terminal report state.
|
||||
*
|
||||
* `final_checkpoint` and the manifest digest are strict match-or-conflict fields. The SARIF
|
||||
* disposition and its `report_sarif_failed` reason are derived from the committed manifest,
|
||||
* partial reasons stay append-only, and the PDF provenance is replaceable after finalization.
|
||||
*/
|
||||
async finalizeReportProgress(
|
||||
finalCheckpoint: string,
|
||||
manifestSha256: string,
|
||||
terminal: {
|
||||
readonly sarifDisposition: ReportSarifDisposition;
|
||||
readonly pdfProvenance: StoredPdfProvenance | null;
|
||||
readonly partialReasons: readonly PartialReason[];
|
||||
},
|
||||
): Promise<ReportProgress> {
|
||||
const data = this.requireData();
|
||||
const durableState = this.requireDurableScanState();
|
||||
const current = durableState.report;
|
||||
if (current?.stage === 'finalized') {
|
||||
if (current.final_checkpoint !== finalCheckpoint || current.finalization_manifest_sha256 !== manifestSha256) {
|
||||
throw new RunStateError('DurableStateConflictError', 'report-final-checkpoint-conflict');
|
||||
}
|
||||
if (current.sarif_disposition !== terminal.sarifDisposition) {
|
||||
throw new RunStateError('DurableStateConflictError', 'report-final-disposition-conflict');
|
||||
}
|
||||
const { pdf_provenance: _priorProvenance, ...currentWithoutProvenance } = current;
|
||||
let adopted: ReportProgress = {
|
||||
...currentWithoutProvenance,
|
||||
partial_reasons: appendPartialReasons(current.partial_reasons, terminal.partialReasons),
|
||||
};
|
||||
if (terminal.pdfProvenance !== null) {
|
||||
adopted = { ...adopted, pdf_provenance: terminal.pdfProvenance };
|
||||
}
|
||||
return await this.persistFinalizedReport(data, durableState, adopted);
|
||||
}
|
||||
if (current?.stage !== 'draft' || current.canonical_checkpoint === undefined) {
|
||||
throw new RunStateError('DurableStateConflictError', 'report-final-invalid-source-stage');
|
||||
}
|
||||
|
||||
const sarifReasons: readonly PartialReason[] =
|
||||
terminal.sarifDisposition === 'render_failed' ? [{ code: 'report_sarif_failed' }] : [];
|
||||
const report: ReportProgress = {
|
||||
stage: 'finalized',
|
||||
renumber_failed_classes: [...current.renumber_failed_classes],
|
||||
partial_reasons: appendPartialReasons(current.partial_reasons, [...terminal.partialReasons, ...sarifReasons]),
|
||||
model_checkpoint: current.model_checkpoint,
|
||||
canonical_checkpoint: current.canonical_checkpoint,
|
||||
final_checkpoint: finalCheckpoint,
|
||||
finalization_manifest_sha256: manifestSha256,
|
||||
sarif_disposition: terminal.sarifDisposition,
|
||||
...(terminal.pdfProvenance !== null && { pdf_provenance: terminal.pdfProvenance }),
|
||||
};
|
||||
|
||||
const agent = data.metrics.agents.report;
|
||||
if (agent === undefined || agent.attempts.length === 0) {
|
||||
throw new RunStateError('DurableStateConflictError', 'report-final-without-model-metrics');
|
||||
}
|
||||
const persisted = await this.persistFinalizedReport(data, durableState, report, () => {
|
||||
agent.status = 'success';
|
||||
agent.checkpoint = finalCheckpoint;
|
||||
const latestAttempt = agent.attempts.at(-1);
|
||||
agent.final_duration_ms = latestAttempt?.duration_ms ?? agent.final_duration_ms;
|
||||
this.recalculateAggregations();
|
||||
});
|
||||
return persisted;
|
||||
}
|
||||
|
||||
private async persistFinalizedReport(
|
||||
data: SessionData,
|
||||
durableState: DurableScanState,
|
||||
report: ReportProgress,
|
||||
beforeSave?: () => void,
|
||||
): Promise<ReportProgress> {
|
||||
const next = { ...durableState, report };
|
||||
if (!isDurableScanState(next)) {
|
||||
throw new RunStateError('DurableStateConflictError', 'report-final-invalid');
|
||||
}
|
||||
beforeSave?.();
|
||||
data.durableScanState = next;
|
||||
await this.save();
|
||||
return structuredClone(report);
|
||||
}
|
||||
|
||||
/** Roll back only report state after a coherent draft shape fails checkpoint validation. */
|
||||
async rollbackReportDraft(): Promise<ReportProgress> {
|
||||
const data = this.requireData();
|
||||
const durableState = this.requireDurableScanState();
|
||||
const current = durableState.report;
|
||||
if (current?.stage !== 'draft') {
|
||||
throw new RunStateError('DurableStateConflictError', 'report-draft-rollback-invalid-source-stage');
|
||||
}
|
||||
const report: ReportProgress = {
|
||||
stage: 'pending',
|
||||
renumber_failed_classes: [...current.renumber_failed_classes],
|
||||
partial_reasons: [...current.partial_reasons],
|
||||
};
|
||||
const agent = data.metrics.agents.report;
|
||||
if (agent !== undefined) {
|
||||
agent.status = 'in-progress';
|
||||
delete agent.checkpoint;
|
||||
delete agent.model;
|
||||
}
|
||||
data.durableScanState = { ...durableState, report };
|
||||
this.recalculateAggregations();
|
||||
await this.save();
|
||||
return structuredClone(report);
|
||||
}
|
||||
|
||||
/** Return persisted report metrics for a coherent draft/finalized model-skip path. */
|
||||
getReportMetrics(): AgentMetrics {
|
||||
const durableState = this.requireDurableScanState();
|
||||
if (durableState.report?.stage !== 'draft' && durableState.report?.stage !== 'finalized') {
|
||||
throw new RunStateError('DurableStateConflictError', 'report-metrics-before-draft');
|
||||
}
|
||||
const agent = this.requireData().metrics.agents.report;
|
||||
if (agent === undefined || agent.attempts.length === 0) {
|
||||
throw new RunStateError('CorruptedSessionError', 'report-draft-metrics-missing');
|
||||
}
|
||||
const latest = agent.attempts.at(-1);
|
||||
return {
|
||||
durationMs: agent.final_duration_ms,
|
||||
inputTokens: agent.total_input_tokens,
|
||||
outputTokens: agent.total_output_tokens,
|
||||
cacheReadTokens: agent.total_cache_read_tokens,
|
||||
cacheWriteTokens: agent.total_cache_write_tokens,
|
||||
costUsd: agent.total_cost_usd,
|
||||
numTurns: agent.attempts.reduce((sum, attempt) => sum + (attempt.turns ?? 0), 0),
|
||||
...(latest?.model !== undefined && { model: latest.model }),
|
||||
...(agent.checkpoint !== undefined && { checkpoint: agent.checkpoint }),
|
||||
skipped: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update session status
|
||||
*/
|
||||
@@ -266,6 +658,67 @@ export class MetricsTracker {
|
||||
await this.save();
|
||||
}
|
||||
|
||||
/** Upsert one workflow's terminal wall time and operational usage, then recompute workspace totals. */
|
||||
async recordTerminalWorkflowMetrics(
|
||||
workflowId: string,
|
||||
input: TerminalWorkflowMetricsInput,
|
||||
): Promise<TerminalWorkflowMetricTotals> {
|
||||
const data = this.requireData();
|
||||
if (
|
||||
!Number.isSafeInteger(input.startedAtMs) ||
|
||||
!Number.isSafeInteger(input.endedAtMs) ||
|
||||
input.startedAtMs < 0 ||
|
||||
input.endedAtMs < input.startedAtMs
|
||||
) {
|
||||
throw new RunStateError('DurableStateConflictError', 'terminal-metric-time-invalid');
|
||||
}
|
||||
|
||||
data.metrics.operational ??= {};
|
||||
const operational = data.metrics.operational;
|
||||
operational[workflowId] = Object.fromEntries(
|
||||
Object.entries(input.operationalMetrics).map(([key, metric]) => [
|
||||
key,
|
||||
{
|
||||
duration_ms: metric.durationMs,
|
||||
input_tokens: metric.inputTokens ?? 0,
|
||||
output_tokens: metric.outputTokens ?? 0,
|
||||
cache_read_tokens: metric.cacheReadTokens ?? 0,
|
||||
cache_write_tokens: metric.cacheWriteTokens ?? 0,
|
||||
cost_usd: metric.costUsd ?? 0,
|
||||
turns: metric.numTurns ?? 0,
|
||||
usage_complete: metric.usageComplete !== false,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
data.metrics.stages ??= {};
|
||||
data.metrics.stages[workflowId] = this.collectOperationalSpans(input.operationalStages);
|
||||
|
||||
data.metrics.runs ??= {};
|
||||
const runs = data.metrics.runs;
|
||||
runs[workflowId] = {
|
||||
status: input.status,
|
||||
started_at: new Date(input.startedAtMs).toISOString(),
|
||||
ended_at: new Date(input.endedAtMs).toISOString(),
|
||||
wall_duration_ms: input.endedAtMs - input.startedAtMs,
|
||||
usage_accounting_complete: input.usageAccountingComplete,
|
||||
usage_accounting_warnings: [...input.usageAccountingWarnings],
|
||||
};
|
||||
|
||||
data.session.status = input.status;
|
||||
data.session.completedAt = runs[workflowId].ended_at;
|
||||
this.recalculateAggregations();
|
||||
await this.save();
|
||||
|
||||
return {
|
||||
totalDurationMs: data.metrics.total_duration_ms,
|
||||
totalCostUsd: data.metrics.total_cost_usd,
|
||||
totalTurns: data.metrics.total_turns ?? 0,
|
||||
runCount: Object.keys(runs).length,
|
||||
usageAccountingComplete: data.metrics.usage_accounting_complete ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a resume attempt to the session
|
||||
*
|
||||
@@ -294,6 +747,12 @@ export class MetricsTracker {
|
||||
this.data.session.resumeAttempts = [];
|
||||
}
|
||||
|
||||
// A lost-acknowledgement re-drive of the same resume adopts the earlier record instead
|
||||
// of appending a duplicate row for the same workflow id.
|
||||
if (this.data.session.resumeAttempts.some((attempt) => attempt.workflowId === workflowId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Add new resume attempt
|
||||
const resumeAttempt: ResumeAttempt = {
|
||||
workflowId,
|
||||
@@ -324,22 +783,82 @@ export class MetricsTracker {
|
||||
// Only count successful agents
|
||||
const successfulAgents = Object.entries(agents).filter(([, data]) => data.status === 'success');
|
||||
|
||||
// Calculate total duration and cost
|
||||
const totalDuration = successfulAgents.reduce((sum, [, data]) => sum + data.final_duration_ms, 0);
|
||||
const totalAgentDuration = successfulAgents.reduce((sum, [, data]) => sum + data.final_duration_ms, 0);
|
||||
const operational = Object.values(this.data.metrics.operational ?? {}).flatMap((metrics) => Object.values(metrics));
|
||||
const runs = Object.values(this.data.metrics.runs ?? {});
|
||||
|
||||
const totalCost = successfulAgents.reduce((sum, [, data]) => sum + data.total_cost_usd, 0);
|
||||
|
||||
this.data.metrics.total_duration_ms = totalDuration;
|
||||
this.data.metrics.total_cost_usd = totalCost;
|
||||
this.data.metrics.total_agent_duration_ms = totalAgentDuration;
|
||||
this.data.metrics.total_operational_duration_ms = this.operationalWallClockMs(this.data);
|
||||
this.data.metrics.total_duration_ms = runs.reduce((sum, run) => sum + run.wall_duration_ms, 0);
|
||||
this.data.metrics.total_cost_usd =
|
||||
Object.values(agents).reduce((sum, agent) => sum + agent.total_cost_usd, 0) +
|
||||
operational.reduce((sum, metric) => sum + metric.cost_usd, 0);
|
||||
this.data.metrics.total_turns =
|
||||
Object.values(agents).reduce(
|
||||
(sum, agent) => sum + agent.attempts.reduce((attemptSum, attempt) => attemptSum + (attempt.turns ?? 0), 0),
|
||||
0,
|
||||
) + operational.reduce((sum, metric) => sum + metric.turns, 0);
|
||||
this.data.metrics.usage_accounting_complete =
|
||||
runs.every((run) => run.usage_accounting_complete) && operational.every((metric) => metric.usage_complete);
|
||||
|
||||
// Calculate phase-level metrics
|
||||
this.data.metrics.phases = this.calculatePhaseMetrics(successfulAgents);
|
||||
this.data.metrics.phases = this.calculatePhaseMetrics(successfulAgents, operational);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the operational stage spans this run can place on a timeline. A stage that never ran, or
|
||||
* that was still running, has no complete span and is dropped rather than guessed at; a present
|
||||
* but nonsensical value is corruption and fails closed.
|
||||
*/
|
||||
private collectOperationalSpans(
|
||||
operationalStages: Readonly<Record<string, OperationalStageTiming>>,
|
||||
): Record<string, StageSpan> {
|
||||
const spans: Record<string, StageSpan> = {};
|
||||
for (const [stageKey, timing] of Object.entries(operationalStages)) {
|
||||
if (!isOperationalStageKey(stageKey)) continue;
|
||||
if (timing.startedAt === undefined || timing.durationMs === undefined) continue;
|
||||
const spanIsWellFormed =
|
||||
Number.isSafeInteger(timing.startedAt) &&
|
||||
Number.isSafeInteger(timing.durationMs) &&
|
||||
timing.startedAt >= 0 &&
|
||||
timing.durationMs >= 0;
|
||||
if (!spanIsWellFormed) {
|
||||
throw new RunStateError('DurableStateConflictError', 'terminal-stage-span-invalid');
|
||||
}
|
||||
spans[stageKey] = { started_at_ms: timing.startedAt, duration_ms: timing.durationMs };
|
||||
}
|
||||
return spans;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wall time the workspace actually spent on operational work. Reconciliation stages carry no
|
||||
* duration in their priced metrics, so the timing comes from the stage spans, merged so classes
|
||||
* that overlapped count once instead of once each. A run recorded before spans were persisted has
|
||||
* none, so it falls back to summing its priced durations — the same fallback
|
||||
* `summarizeOperationalMetrics` applies to a metrics-only view. The two sets never intersect, so
|
||||
* no run is counted twice.
|
||||
*/
|
||||
private operationalWallClockMs(data: SessionData): number {
|
||||
const spansByRun = data.metrics.stages ?? {};
|
||||
const spans = Object.values(spansByRun).flatMap((stages) =>
|
||||
Object.values(stages).map((span) => ({ startedAt: span.started_at_ms, durationMs: span.duration_ms })),
|
||||
);
|
||||
const spanlessRunDuration = Object.entries(data.metrics.operational ?? {})
|
||||
.filter(([workflowId]) => spansByRun[workflowId] === undefined)
|
||||
.reduce(
|
||||
(sum, [, metrics]) => sum + Object.values(metrics).reduce((inner, metric) => inner + metric.duration_ms, 0),
|
||||
0,
|
||||
);
|
||||
return mergeIntervalsDurationMs(spans) + spanlessRunDuration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate phase-level metrics
|
||||
*/
|
||||
private calculatePhaseMetrics(successfulAgents: Array<[string, AgentAuditMetrics]>): Record<string, PhaseMetrics> {
|
||||
private calculatePhaseMetrics(
|
||||
successfulAgents: Array<[string, AgentAuditMetrics]>,
|
||||
operational: OperationalAuditMetrics[],
|
||||
): Record<string, PhaseMetrics> {
|
||||
const phases: Record<PhaseName, AgentAuditMetrics[]> = {
|
||||
'pre-recon': [],
|
||||
recon: [],
|
||||
@@ -358,8 +877,11 @@ export class MetricsTracker {
|
||||
|
||||
// Calculate metrics per phase
|
||||
const phaseMetrics: Record<string, PhaseMetrics> = {};
|
||||
// biome-ignore lint/style/noNonNullAssertion: called from recalculateAggregations which guards this.data
|
||||
const totalDuration = this.data!.metrics.total_duration_ms;
|
||||
// Percentages share one basis — agent plus operational duration — so the synthetic background
|
||||
// phase below is comparable to the agent phases rather than measured against a different total.
|
||||
// (`this.data` is guaranteed by the recalculateAggregations caller; optional chaining keeps it lint-clean.)
|
||||
const operationalDuration = this.data?.metrics.total_operational_duration_ms ?? 0;
|
||||
const totalDuration = (this.data?.metrics.total_agent_duration_ms ?? 0) + operationalDuration;
|
||||
|
||||
for (const [phaseName, agentList] of Object.entries(phases)) {
|
||||
if (agentList.length === 0) continue;
|
||||
@@ -375,6 +897,19 @@ export class MetricsTracker {
|
||||
};
|
||||
}
|
||||
|
||||
// Operational work (agentic SAST, reconciliation) runs concurrently with the agent phases and is
|
||||
// otherwise absent from this breakdown; surface it as one `background` phase. `agent_count` here
|
||||
// is the number of operational metric entries, not agents.
|
||||
if (operational.length > 0) {
|
||||
const backgroundCost = operational.reduce((sum, metric) => sum + metric.cost_usd, 0);
|
||||
phaseMetrics.background = {
|
||||
duration_ms: operationalDuration,
|
||||
duration_percentage: calculatePercentage(operationalDuration, totalDuration),
|
||||
cost_usd: backgroundCost,
|
||||
agent_count: operational.length,
|
||||
};
|
||||
}
|
||||
|
||||
return phaseMetrics;
|
||||
}
|
||||
|
||||
@@ -399,4 +934,71 @@ export class MetricsTracker {
|
||||
async reload(): Promise<void> {
|
||||
this.data = await readJson<SessionData>(this.sessionJsonPath);
|
||||
}
|
||||
|
||||
private requireData(): SessionData {
|
||||
if (this.data === null) {
|
||||
throw new RunStateError('CorruptedSessionError', 'metrics-tracker-not-initialized');
|
||||
}
|
||||
return this.data;
|
||||
}
|
||||
|
||||
private requireDurableScanState(): DurableScanState {
|
||||
const durableState = this.requireData().durableScanState;
|
||||
if (durableState === undefined) {
|
||||
throw new RunStateError('CorruptedSessionError', 'durable-state-missing');
|
||||
}
|
||||
if (!isDurableScanState(durableState)) {
|
||||
throw new RunStateError('CorruptedSessionError', 'durable-state-malformed');
|
||||
}
|
||||
return durableState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append one attempt and recompute the agent's cumulative totals from the full attempt list,
|
||||
* rather than incrementing them. A reload-then-write cycle can replay this on the same agent
|
||||
* more than once across a retry, and recomputing from the stored attempts keeps the totals
|
||||
* correct regardless of how many times that happens.
|
||||
*/
|
||||
private appendAttempt(agentName: string, result: AgentEndResult): AgentAuditMetrics {
|
||||
const data = this.requireData();
|
||||
const existingAgent = data.metrics.agents[agentName];
|
||||
const agent = existingAgent ?? {
|
||||
status: 'in-progress' as const,
|
||||
attempts: [],
|
||||
final_duration_ms: 0,
|
||||
total_cost_usd: 0,
|
||||
total_input_tokens: 0,
|
||||
total_output_tokens: 0,
|
||||
total_cache_read_tokens: 0,
|
||||
total_cache_write_tokens: 0,
|
||||
};
|
||||
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,
|
||||
cost_usd: result.cost_usd,
|
||||
success: result.success,
|
||||
timestamp: formatTimestamp(),
|
||||
...(result.input_tokens !== undefined && { input_tokens: result.input_tokens }),
|
||||
...(result.output_tokens !== undefined && { output_tokens: result.output_tokens }),
|
||||
...(result.cache_read_tokens !== undefined && { cache_read_tokens: result.cache_read_tokens }),
|
||||
...(result.cache_write_tokens !== undefined && { cache_write_tokens: result.cache_write_tokens }),
|
||||
...(result.turns !== undefined && { turns: result.turns }),
|
||||
...(result.model !== undefined && { model: result.model }),
|
||||
...(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);
|
||||
agent.total_input_tokens = agent.attempts.reduce((sum, entry) => sum + (entry.input_tokens ?? 0), 0);
|
||||
agent.total_output_tokens = agent.attempts.reduce((sum, entry) => sum + (entry.output_tokens ?? 0), 0);
|
||||
agent.total_cache_read_tokens = agent.attempts.reduce((sum, entry) => sum + (entry.cache_read_tokens ?? 0), 0);
|
||||
agent.total_cache_write_tokens = agent.attempts.reduce((sum, entry) => sum + (entry.cache_write_tokens ?? 0), 0);
|
||||
return agent;
|
||||
}
|
||||
|
||||
private arraysEqual<T>(left: readonly T[], right: readonly T[]): boolean {
|
||||
return left.length === right.length && left.every((value, index) => value === right[index]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
// 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.
|
||||
|
||||
/**
|
||||
* Rolls a scan's non-agent (operational) spend up into the small, ordered set of labelled groups
|
||||
* the completion summary and live status view show. Pure and self-contained (no I/O) so it can be
|
||||
* unit-tested directly. Cost comes from the priced metrics; duration comes from the stage spans'
|
||||
* real wall-clock, which is why concurrent reconciliation classes read as their union, not a sum.
|
||||
*/
|
||||
|
||||
/** The buckets operational (non-agent) spend is grouped into for the completion summary. */
|
||||
export type OperationalGroupKey = 'agentic-sast' | 'reconciliation' | 'other';
|
||||
|
||||
export interface OperationalGroupTotal {
|
||||
readonly key: OperationalGroupKey;
|
||||
readonly label: string;
|
||||
readonly durationMs: number;
|
||||
/** Null only when every metric in the group has an unknown cost, matching the agent breakdown's N/A. */
|
||||
readonly costUsd: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The wall-clock span of one operational stage. Sourced from `operationalStages` (not the priced
|
||||
* metrics), it is how the summary reports a group's real elapsed time — reconciliation classes run
|
||||
* concurrently, so their true duration is the union of these spans, not a sum.
|
||||
*/
|
||||
export interface OperationalStageTiming {
|
||||
readonly startedAt?: number;
|
||||
readonly durationMs?: number;
|
||||
}
|
||||
|
||||
const OPERATIONAL_GROUP_LABELS: Readonly<Record<OperationalGroupKey, string>> = {
|
||||
'agentic-sast': 'Agentic SAST',
|
||||
reconciliation: 'Finding reconciliation',
|
||||
other: 'Background task',
|
||||
};
|
||||
|
||||
// Stable render order; a group only appears when it has at least one metric.
|
||||
const OPERATIONAL_GROUP_ORDER: readonly OperationalGroupKey[] = ['agentic-sast', 'reconciliation', 'other'];
|
||||
|
||||
/** Classify an operational metric key by its generated prefix; anything unexpected folds into `other`. */
|
||||
function operationalGroupKey(metricKey: string): OperationalGroupKey {
|
||||
if (metricKey.startsWith('agentic-sast:')) return 'agentic-sast';
|
||||
if (metricKey.startsWith('reconciliation:')) return 'reconciliation';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify an operational *stage* key. Stage keys differ from metric keys: the agentic-SAST stage
|
||||
* is the bare `agentic-sast` (its metric is `agentic-sast:export`), and reconciliation stages are
|
||||
* `reconciliation:<class>`. So match the bare family name as well as its colon-prefixed children.
|
||||
*/
|
||||
function operationalStageGroupKey(stageKey: string): OperationalGroupKey {
|
||||
if (stageKey === 'agentic-sast' || stageKey.startsWith('agentic-sast:')) return 'agentic-sast';
|
||||
if (stageKey === 'reconciliation' || stageKey.startsWith('reconciliation:')) return 'reconciliation';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
/**
|
||||
* Total wall-clock covered by a set of `[startedAt, startedAt + durationMs)` spans, merging any
|
||||
* overlap. This is what keeps a group's duration faithful when its stages run concurrently: several
|
||||
* reconciliation classes overlap in time, so their real elapsed time is the union, never the sum.
|
||||
* Spans missing a start or a positive duration cannot be placed on the timeline and are ignored.
|
||||
*/
|
||||
export function mergeIntervalsDurationMs(spans: readonly OperationalStageTiming[]): number {
|
||||
const intervals = spans
|
||||
.filter(
|
||||
(span): span is { startedAt: number; durationMs: number } =>
|
||||
span.startedAt !== undefined && span.durationMs !== undefined && span.durationMs > 0,
|
||||
)
|
||||
.map((span) => ({ start: span.startedAt, end: span.startedAt + span.durationMs }))
|
||||
.sort((a, b) => a.start - b.start);
|
||||
|
||||
let total = 0;
|
||||
let cursor = Number.NEGATIVE_INFINITY;
|
||||
for (const interval of intervals) {
|
||||
const start = Math.max(interval.start, cursor);
|
||||
if (interval.end > start) total += interval.end - start;
|
||||
cursor = Math.max(cursor, interval.end);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Roll the per-key operational metrics up into a small, ordered set of labelled group totals.
|
||||
* Grouping by prefix (rather than itemizing raw keys) keeps the summary robust to key drift and
|
||||
* needs no per-key label table. A group's presence and cost come from the priced metrics — cost
|
||||
* stays null for a group only when no metric in it reported one, so a partially-known group still
|
||||
* shows its known spend. Duration comes from the group's stage spans (their real wall-clock union)
|
||||
* when `operationalStages` is supplied; without it, it falls back to summing the metric durations.
|
||||
*/
|
||||
export function summarizeOperationalMetrics(
|
||||
operationalMetrics: Readonly<Record<string, { readonly durationMs: number; readonly costUsd: number | null }>>,
|
||||
operationalStages?: Readonly<Record<string, OperationalStageTiming>>,
|
||||
): OperationalGroupTotal[] {
|
||||
const metricDurationByGroup = new Map<OperationalGroupKey, number>();
|
||||
const costByGroup = new Map<OperationalGroupKey, number | null>();
|
||||
|
||||
for (const [metricKey, metrics] of Object.entries(operationalMetrics)) {
|
||||
const group = operationalGroupKey(metricKey);
|
||||
metricDurationByGroup.set(group, (metricDurationByGroup.get(group) ?? 0) + Math.max(0, metrics.durationMs));
|
||||
if (metrics.costUsd !== null) {
|
||||
const priorCost = costByGroup.get(group);
|
||||
costByGroup.set(group, (priorCost ?? 0) + Math.max(0, metrics.costUsd));
|
||||
} else if (!costByGroup.has(group)) {
|
||||
costByGroup.set(group, null);
|
||||
}
|
||||
}
|
||||
|
||||
const spansByGroup = new Map<OperationalGroupKey, OperationalStageTiming[]>();
|
||||
for (const [stageKey, timing] of Object.entries(operationalStages ?? {})) {
|
||||
const group = operationalStageGroupKey(stageKey);
|
||||
const spans = spansByGroup.get(group) ?? [];
|
||||
spans.push(timing);
|
||||
spansByGroup.set(group, spans);
|
||||
}
|
||||
|
||||
const totals: OperationalGroupTotal[] = [];
|
||||
for (const key of OPERATIONAL_GROUP_ORDER) {
|
||||
if (!metricDurationByGroup.has(key)) continue;
|
||||
const spans = spansByGroup.get(key);
|
||||
// Real wall-clock from the stage spans; fall back to the summed metric durations when no spans
|
||||
// were supplied (e.g. the console path passes metrics only).
|
||||
const durationMs = spans !== undefined ? mergeIntervalsDurationMs(spans) : (metricDurationByGroup.get(key) ?? 0);
|
||||
totals.push({
|
||||
key,
|
||||
label: OPERATIONAL_GROUP_LABELS[key],
|
||||
durationMs,
|
||||
costUsd: costByGroup.get(key) ?? null,
|
||||
});
|
||||
}
|
||||
return totals;
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
// 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. A forward slash is treated as a word
|
||||
* separator (`XSS/Injection` → `xss injection`), not a path marker — real paths and URLs are
|
||||
* still rejected below by their `.`, `\`, `:`, or `@`.
|
||||
*/
|
||||
export function normalizeSemanticLabel(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string' || containsControlCharacter(value)) return undefined;
|
||||
const collapsed = value.replace(/[\s/]+/gu, ' ').trim();
|
||||
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;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (C) 2025 Keygraph, Inc.
|
||||
// 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
|
||||
@@ -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
@@ -1,4 +1,4 @@
|
||||
// Copyright (C) 2025 Keygraph, Inc.
|
||||
// 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
|
||||
@@ -9,8 +9,7 @@
|
||||
* per-run valid-ID set).
|
||||
*
|
||||
* Exposes a single TypeBox-validated tool `add_exploit`, called once per
|
||||
* processed vulnerability by the 5 exploit-* agents (injection, xss, auth,
|
||||
* ssrf, authz). After the agent terminates, the host harvests
|
||||
* processed vulnerability by the exploit-* agents. After the agent terminates, the host harvests
|
||||
* collector.getAll() and runs exploit-renderer to produce
|
||||
* {class}_exploitation_evidence.md. The collector state is the structured
|
||||
* output.
|
||||
@@ -36,16 +35,18 @@
|
||||
*/
|
||||
|
||||
import { defineTool, type ToolDefinition } from '@earendil-works/pi-coding-agent';
|
||||
import { type TSchema, Type } from 'typebox';
|
||||
import { type Static, type TSchema, Type } from 'typebox';
|
||||
import { Value } from 'typebox/value';
|
||||
import { isTaskReference, REF_PREFIX } from '../ai/reconciliation/refs.js';
|
||||
import { ALL_RECONCILIATION_CLASSES, type ReconciliationClass } from '../types/reconciliation.js';
|
||||
import { stringEnum } from './schema.js';
|
||||
|
||||
// ============================================================================
|
||||
// CLASS DISCRIMINATOR
|
||||
// ============================================================================
|
||||
|
||||
export const EXPLOIT_VULN_CLASSES = ['injection', 'xss', 'auth', 'ssrf', 'authz'] as const;
|
||||
export type VulnClass = (typeof EXPLOIT_VULN_CLASSES)[number];
|
||||
export const EXPLOIT_VULN_CLASSES = ALL_RECONCILIATION_CLASSES;
|
||||
export type ExploitClass = ReconciliationClass;
|
||||
|
||||
// ============================================================================
|
||||
// SCHEMA CONSTANTS
|
||||
@@ -54,6 +55,43 @@ export type VulnClass = (typeof EXPLOIT_VULN_CLASSES)[number];
|
||||
const SEVERITY_VALUES = ['critical', 'high', 'medium', 'low'] as const;
|
||||
const CONFIDENCE_VALUES = ['high', 'medium', 'low'] as const;
|
||||
|
||||
const exploitEvidenceLocationSchema = Type.Object(
|
||||
{
|
||||
workspace_relative_file_path: Type.String({
|
||||
minLength: 1,
|
||||
description:
|
||||
'Non-empty POSIX path to code inspected during exploitation, relative to the Shannon workspace. ' +
|
||||
'Do not use a leading slash, backslash, empty segment, or "." or ".." segment.',
|
||||
}),
|
||||
line_number: Type.Union([Type.Integer({ minimum: 1 }), Type.Null()], {
|
||||
description: 'Exact one-based line inspected, or null when the file is known but the line is not.',
|
||||
}),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
const codeLocationsField = Type.Optional(
|
||||
Type.Array(exploitEvidenceLocationSchema, {
|
||||
minItems: 1,
|
||||
description:
|
||||
'Code locations inspected while investigating this task. Omit when none were inspected; never send an empty array.',
|
||||
}),
|
||||
);
|
||||
|
||||
export type ExploitEvidenceLocation = Static<typeof exploitEvidenceLocationSchema>;
|
||||
|
||||
/** Whether a path is a normalized POSIX path relative to the scan workspace. */
|
||||
export function isValidExploitEvidencePath(value: string): boolean {
|
||||
if (value.length === 0 || value.includes('\\') || value.includes('\0') || value.startsWith('/')) {
|
||||
return false;
|
||||
}
|
||||
if (/^[A-Za-z]:\//.test(value)) {
|
||||
return false;
|
||||
}
|
||||
const segments = value.split('/');
|
||||
return segments.every((segment) => segment.length > 0 && segment !== '.' && segment !== '..');
|
||||
}
|
||||
|
||||
const VALID_IDS_PREVIEW_LIMIT = 8;
|
||||
|
||||
function formatValidIdsPreview(validIds: ReadonlySet<string>): string {
|
||||
@@ -74,9 +112,11 @@ export type ExploitedExploit = {
|
||||
overview: string;
|
||||
prerequisites?: string | null;
|
||||
severity: (typeof SEVERITY_VALUES)[number];
|
||||
severity_rationale: string;
|
||||
impact: string;
|
||||
exploitation_steps: string[];
|
||||
proof_of_impact: string;
|
||||
code_locations?: ExploitEvidenceLocation[];
|
||||
notes?: string | null;
|
||||
};
|
||||
|
||||
@@ -94,6 +134,7 @@ export type BlockedExploit = {
|
||||
what_we_tried: string;
|
||||
how_this_would_be_exploited: string[];
|
||||
expected_impact: string;
|
||||
code_locations?: ExploitEvidenceLocation[];
|
||||
notes?: string | null;
|
||||
};
|
||||
|
||||
@@ -107,7 +148,7 @@ export function buildSchemas(validIds: ReadonlySet<string>) {
|
||||
const vulnerabilityIdField = Type.String({
|
||||
minLength: 1,
|
||||
description:
|
||||
'Vulnerability identifier (e.g. "INJ-VULN-03"). Must match an ID from this run\'s ' +
|
||||
'Stable exploitation-task identifier (e.g. "INJ-03" or "MISC-01"). Must match an ID from this run\'s ' +
|
||||
'{class}_exploitation_queue.json exactly — the collector rejects IDs not in the queue. ' +
|
||||
`Valid IDs for this run: ${formatValidIdsPreview(validIds)}.`,
|
||||
});
|
||||
@@ -172,6 +213,16 @@ export function buildSchemas(validIds: ReadonlySet<string>) {
|
||||
}),
|
||||
);
|
||||
|
||||
const severityRationaleField = Type.Optional(
|
||||
Type.Union([Type.String(), Type.Null()], {
|
||||
description:
|
||||
'REQUIRED when status="exploited". The four-question severity reasoning from ' +
|
||||
'<severity_reasoning>: (1) what the attacker ends up holding, (2) what it took, ' +
|
||||
'(3) how far it reaches, and (4) what it is worth in this application. Justifies the ' +
|
||||
'chosen severity against demonstrated impact, not theoretical potential.',
|
||||
}),
|
||||
);
|
||||
|
||||
const impactField = Type.Optional(
|
||||
Type.Union([Type.String({ minLength: 1 }), Type.Null()], {
|
||||
description:
|
||||
@@ -286,9 +337,11 @@ export function buildSchemas(validIds: ReadonlySet<string>) {
|
||||
prerequisites: prerequisitesField,
|
||||
notes: notesField,
|
||||
severity: severityField,
|
||||
severity_rationale: severityRationaleField,
|
||||
impact: impactField,
|
||||
exploitation_steps: exploitationStepsField,
|
||||
proof_of_impact: proofOfImpactField,
|
||||
code_locations: codeLocationsField,
|
||||
confidence: confidenceField,
|
||||
current_blocker: currentBlockerField,
|
||||
potential_impact: potentialImpactField,
|
||||
@@ -308,9 +361,11 @@ export function buildSchemas(validIds: ReadonlySet<string>) {
|
||||
overview: overviewField,
|
||||
prerequisites: prerequisitesField,
|
||||
severity: stringEnum(SEVERITY_VALUES),
|
||||
severity_rationale: Type.String({ minLength: 1 }),
|
||||
impact: Type.String({ minLength: 1 }),
|
||||
exploitation_steps: Type.Array(Type.String({ minLength: 1 }), { minItems: 1 }),
|
||||
proof_of_impact: Type.String({ minLength: 1 }),
|
||||
code_locations: codeLocationsField,
|
||||
notes: notesField,
|
||||
});
|
||||
|
||||
@@ -328,6 +383,7 @@ export function buildSchemas(validIds: ReadonlySet<string>) {
|
||||
what_we_tried: Type.String({ minLength: 1 }),
|
||||
how_this_would_be_exploited: Type.Array(Type.String({ minLength: 1 }), { minItems: 1 }),
|
||||
expected_impact: Type.String({ minLength: 1 }),
|
||||
code_locations: codeLocationsField,
|
||||
notes: notesField,
|
||||
});
|
||||
|
||||
@@ -377,7 +433,10 @@ export interface ExploitCollector {
|
||||
}
|
||||
|
||||
export interface CreateExploitCollectorOptions {
|
||||
vulnClass: VulnClass;
|
||||
vulnClass: ExploitClass;
|
||||
// Stable task references (e.g. "INJ-01") assigned by reconciliation for this run, not the
|
||||
// producer IDs (e.g. "INJ-VULN-01") those tasks were built from. The exploit agent only ever
|
||||
// sees this task namespace.
|
||||
validIds: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
@@ -413,6 +472,31 @@ export function createExploitCollector(options: CreateExploitCollectorOptions):
|
||||
}
|
||||
const typed = Value.Clean(StrictSchema, structuredClone(input)) as AddExploitInput;
|
||||
|
||||
const invalidLocationPaths = (typed.code_locations ?? [])
|
||||
.map((location) => location.workspace_relative_file_path)
|
||||
.filter((locationPath) => !isValidExploitEvidencePath(locationPath));
|
||||
if (invalidLocationPaths.length > 0) {
|
||||
return errorResult(
|
||||
`Invalid workspace-relative POSIX code location path(s): ${invalidLocationPaths.join(', ')}. ` +
|
||||
'Use non-empty relative paths with no backslashes, empty segments, ".", or ".." segments.',
|
||||
'ValidationError',
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
// Enforces the reconciliation boundary: an exploit agent only ever reasons about stable
|
||||
// task references, never the producer IDs (VULN-/SAST-tagged) that reconciliation grouped
|
||||
// to build them. Accepting a producer-shaped ID here would let that internal identity leak
|
||||
// into exploitation evidence and, from there, the published report.
|
||||
if (!isTaskReference(typed.vulnerability_id, vulnClass)) {
|
||||
return errorResult(
|
||||
`Vulnerability ID "${typed.vulnerability_id}" is outside the ${REF_PREFIX[vulnClass]}-NN task namespace ` +
|
||||
`(for example ${REF_PREFIX[vulnClass]}-01).`,
|
||||
'ValidationError',
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
// Reject IDs not in this run's queue (typo'd or hallucinated).
|
||||
if (!validIds.has(typed.vulnerability_id)) {
|
||||
return errorResult(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (C) 2025 Keygraph, Inc.
|
||||
// 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
|
||||
@@ -24,6 +24,8 @@ import { cleanInput, stringEnum } from './schema.js';
|
||||
// SCHEMA
|
||||
// ============================================================================
|
||||
|
||||
const CATEGORY_VALUES = ['Injection', 'XSS', 'Authentication', 'SSRF', 'Authorization', 'Miscellaneous'] as const;
|
||||
|
||||
const OWASP_CATEGORY_VALUES = [
|
||||
'A01:2025 — Broken Access Control',
|
||||
'A02:2025 — Security Misconfiguration',
|
||||
@@ -117,6 +119,13 @@ const AdditionalSectionSchema = Type.Object({
|
||||
}),
|
||||
});
|
||||
|
||||
const SASTSourceLocationSchema = Type.Object({
|
||||
file: Type.String({ minLength: 1, description: 'Source file path relative to the repository root.' }),
|
||||
line: Type.Integer({ minimum: 1, description: 'One-based source line.' }),
|
||||
column: Type.Integer({ minimum: 0, description: 'Zero-based source column.' }),
|
||||
rule_id: Type.String({ minLength: 1, description: 'Validated SAST rule or CWE identifier.' }),
|
||||
});
|
||||
|
||||
/**
|
||||
* `severity` is recorded in both modes, but it does not mean the same thing in each: an exploit
|
||||
* run measures it from what the exploit demonstrated, an analysis run assesses it from the class
|
||||
@@ -131,18 +140,17 @@ function identityFields(exploit: boolean) {
|
||||
severity: stringEnum(SEVERITY_VALUES, { description: severityDescription }),
|
||||
finding_id: Type.String({
|
||||
minLength: 1,
|
||||
description: 'Finding identifier (e.g., "AUTH-VULN-07", "INJ-VULN-03"). Must be unique per report.',
|
||||
description: 'Stable finding identifier (e.g., "AUTH-07", "INJ-03", "MISC-01"). Must be unique per report.',
|
||||
}),
|
||||
title: Type.String({
|
||||
minLength: 1,
|
||||
description:
|
||||
'Descriptive name (e.g., "SQL Injection — User Search", "IDOR — Unauthorized Access to User Orders").',
|
||||
}),
|
||||
category: stringEnum(['Injection', 'XSS', 'Authentication', 'Authorization', 'SSRF'], {
|
||||
category: stringEnum(CATEGORY_VALUES, {
|
||||
description:
|
||||
'From the finding_id prefix: INJ-VULN-xxx Injection, ' +
|
||||
'XSS-VULN-xxx XSS, AUTH-VULN-xxx Authentication, AUTHZ-VULN-xxx Authorization, ' +
|
||||
'SSRF-VULN-xxx SSRF.',
|
||||
'From the finding_id prefix: INJ-* Injection, XSS-* XSS, AUTH-* Authentication, ' +
|
||||
'AUTHZ-* Authorization, SSRF-* SSRF, MISC-* Miscellaneous.',
|
||||
}),
|
||||
owasp_category: stringEnum(OWASP_CATEGORY_VALUES, {
|
||||
description: 'OWASP Top Ten 2025 category.',
|
||||
@@ -256,6 +264,9 @@ export function buildAddFindingSchema(exploit: boolean) {
|
||||
const AddFindingSupersetSchema = Type.Object({
|
||||
...identityFields(true),
|
||||
code_locations: Type.Optional(Type.Array(CodeLocationSchema)),
|
||||
// Join-only, like code_locations: set by attachQueueCodeLocations from Capella's committed
|
||||
// output. Absent from buildAddFindingSchema so the report agent cannot author (fabricate) it.
|
||||
sast_source_location: Type.Optional(Type.Union([SASTSourceLocationSchema, Type.Null()])),
|
||||
auth_state: Type.Optional(Type.String()),
|
||||
prerequisites: Type.Optional(Type.String()),
|
||||
exploitation_steps: Type.Optional(Type.Array(StructuredStepSchema)),
|
||||
@@ -275,6 +286,7 @@ export type HttpLocation = Static<typeof HttpLocationSchema>;
|
||||
export type StepItem = Static<typeof StepItemSchema>;
|
||||
export type StructuredStep = Static<typeof StructuredStepSchema>;
|
||||
export type AdditionalSection = Static<typeof AdditionalSectionSchema>;
|
||||
export type SASTSourceLocation = Static<typeof SASTSourceLocationSchema>;
|
||||
|
||||
// ============================================================================
|
||||
// RESPONSE HELPERS
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (C) 2025 Keygraph, Inc.
|
||||
// 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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (C) 2025 Keygraph, Inc.
|
||||
// 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
|
||||
@@ -717,6 +717,9 @@ export function createReconCollector(): ReconCollector {
|
||||
'The renderer sorts by (path, method) before rendering, so emission order does not affect output.',
|
||||
parameters: AddEndpointsInputSchema,
|
||||
async execute(_toolCallId, input) {
|
||||
// Unlike the one-shot set_* tools, repeated calls here are expected (the agent splits a
|
||||
// large inventory across several), so a repeated (method, path) pair is silently skipped
|
||||
// as a no-op rather than rejected as a DuplicateError.
|
||||
addEndpointsCalls += 1;
|
||||
const added: string[] = [];
|
||||
const skipped: string[] = [];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (C) 2025 Keygraph, Inc.
|
||||
// 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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (C) 2025 Keygraph, Inc.
|
||||
// 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
|
||||
@@ -404,6 +404,8 @@ export function createVulnCollector(vulnClass: VulnClass): VulnCollector {
|
||||
parameters: intelSchema,
|
||||
async execute(_toolCallId, input) {
|
||||
if (state.strategic_intelligence) return alreadyCalled('set_strategic_intelligence');
|
||||
// Safe: intelSchema was selected from STRATEGIC_INTEL_SCHEMAS by this collector's own
|
||||
// vulnClass, so cleanInput's output shape always matches one arm of the union below.
|
||||
state.strategic_intelligence = cleanInput(intelSchema, input) as unknown as StrategicIntelligenceInput;
|
||||
return successResult({ set: 'set_strategic_intelligence' });
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (C) 2025 Keygraph, Inc.
|
||||
// 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
|
||||
@@ -10,15 +10,21 @@ import type { FormatsPlugin } from 'ajv-formats';
|
||||
import yaml from 'js-yaml';
|
||||
import { fs } from 'zx';
|
||||
import { PentestError } from './services/error-handling.js';
|
||||
import {
|
||||
ALL_VULN_CLASSES,
|
||||
type Authentication,
|
||||
type Config,
|
||||
type DistributedConfig,
|
||||
type Rule,
|
||||
} from './types/config.js';
|
||||
import type { Authentication, Config, DistributedConfig, Rule } from './types/config.js';
|
||||
import { ErrorCode } from './types/errors.js';
|
||||
|
||||
/**
|
||||
* Parses and validates scan configuration YAML against config-schema.json, then
|
||||
* distributes it into the plain values consumed by prompts and services.
|
||||
*
|
||||
* The schema is closed: every object in config-schema.json sets `additionalProperties:
|
||||
* false`, so an unrecognized field anywhere in the config is a hard validation failure
|
||||
* rather than a silently ignored typo. There is no public way to select which analysis
|
||||
* classes run; the schema only exposes steering knobs (rules, authentication,
|
||||
* agentic_sast.enabled, exploit, report, rules_of_engagement) on top of the fixed
|
||||
* five-class pipeline.
|
||||
*/
|
||||
|
||||
// Handle ESM/CJS interop for ajv-formats using require
|
||||
const require = createRequire(import.meta.url);
|
||||
const addFormats: FormatsPlugin = require('ajv-formats');
|
||||
@@ -42,6 +48,10 @@ try {
|
||||
});
|
||||
}
|
||||
|
||||
// Free-text config fields (description, rules_of_engagement, rule values, login fields,
|
||||
// report.guidance) get interpolated verbatim into agent prompts via prompt-manager.ts.
|
||||
// These patterns block the more obvious ways a scan config could smuggle markup, script
|
||||
// URLs, or path traversal into that prompt text or into a rendered value.
|
||||
const DANGEROUS_PATTERNS: RegExp[] = [
|
||||
/\.\.\//, // Path traversal
|
||||
/[<>]/, // HTML/XML injection
|
||||
@@ -312,6 +322,9 @@ export const parseConfigYAML = (yamlContent: string): Config => {
|
||||
return config as Config;
|
||||
};
|
||||
|
||||
// Runs before schema validation so a renamed field fails with a specific "renamed to X"
|
||||
// message instead of the generic "additionalProperties" rejection the closed schema
|
||||
// would otherwise produce for the old field name.
|
||||
function checkDeprecatedFields(config: Config): void {
|
||||
const messages: string[] = [];
|
||||
|
||||
@@ -387,7 +400,7 @@ const validateConfig = (config: Config): void => {
|
||||
!!config.rules ||
|
||||
!!config.authentication ||
|
||||
!!config.description ||
|
||||
!!config.vuln_classes ||
|
||||
!!config.agentic_sast ||
|
||||
config.exploit !== undefined ||
|
||||
!!config.report ||
|
||||
!!config.rules_of_engagement;
|
||||
@@ -673,9 +686,10 @@ export const distributeConfig = (config: Config | null): DistributedConfig => {
|
||||
const authentication = config?.authentication || null;
|
||||
const description = config?.description?.trim() || '';
|
||||
|
||||
const vuln_classes =
|
||||
config?.vuln_classes && config.vuln_classes.length > 0 ? [...config.vuln_classes] : [...ALL_VULN_CLASSES];
|
||||
|
||||
// The schema types boolean-shaped fields (exploit, report.sarif, agentic_sast.enabled)
|
||||
// as a string enum ("true"/"false") rather than JSON boolean, since YAML's FAILSAFE_SCHEMA
|
||||
// parses bareword true/false as strings. The string comparison here is intentional, not
|
||||
// a leftover from a looser type.
|
||||
const exploit = config?.exploit !== undefined ? config.exploit === 'true' : true;
|
||||
|
||||
const report = {
|
||||
@@ -693,7 +707,7 @@ export const distributeConfig = (config: Config | null): DistributedConfig => {
|
||||
focus: focus.map(sanitizeRule),
|
||||
authentication: authentication ? sanitizeAuthentication(authentication) : null,
|
||||
description,
|
||||
vuln_classes,
|
||||
...(config?.agentic_sast?.enabled === 'true' && { agenticSast: true as const }),
|
||||
exploit,
|
||||
report,
|
||||
rules_of_engagement,
|
||||
|
||||
@@ -46,6 +46,9 @@ export const REPORT_JSON_FILENAME = 'report.json';
|
||||
/** SARIF 2.1.0 log, written for exploit=true runs unless report.sarif is set to false. */
|
||||
export const SARIF_FILENAME = 'report.sarif';
|
||||
|
||||
/** Deterministic receipt for the canonical report finalization commit. */
|
||||
export const REPORT_FINALIZATION_MANIFEST_FILENAME = 'report_finalization_manifest.json';
|
||||
|
||||
/**
|
||||
* Resolve the session.json path for a run directory, preferring the current
|
||||
* `.shannon/` location and falling back to the legacy run-root location so
|
||||
@@ -90,4 +93,6 @@ function findRepoRoot(): string {
|
||||
}
|
||||
|
||||
const REPO_ROOT = findRepoRoot();
|
||||
|
||||
/** Default root for named scan workspaces; each session's audit directory nests under here. */
|
||||
export const WORKSPACES_DIR = path.join(REPO_ROOT, 'workspaces');
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (C) 2025 Keygraph, Inc.
|
||||
// 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
|
||||
@@ -36,7 +36,8 @@ export class ProgressIndicator {
|
||||
this.interval = null;
|
||||
}
|
||||
|
||||
// Clear the spinner line
|
||||
// Clear the spinner line: overwrite with spaces at least as wide as the last frame
|
||||
// written (message plus the spinner glyph and separator), then return the cursor home.
|
||||
process.stdout.write(`\r${' '.repeat(this.message.length + 5)}\r`);
|
||||
this.isRunning = false;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// Copyright (C) 2025 Keygraph, Inc.
|
||||
// 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
|
||||
@@ -12,6 +12,11 @@
|
||||
* Generates a TOTP code for the target's MFA.
|
||||
* Based on RFC 6238 (TOTP) and RFC 4226 (HOTP).
|
||||
*
|
||||
* The login flow prompt has the agent run this via the `bash` tool with the TOTP secret
|
||||
* substituted in, rather than asking the model to work out HOTP/TOTP arithmetic itself.
|
||||
* The secret is only ever held in memory here; nothing is written to disk, and the
|
||||
* result is emitted as JSON on stdout for the caller to parse.
|
||||
*
|
||||
* Usage:
|
||||
* generate-totp --secret JBSWY3DPEHPK3PXP
|
||||
*/
|
||||
@@ -64,7 +69,10 @@ function generateHOTP(secret: string, counter: number, digits: number = 6): stri
|
||||
hmac.update(counterBuffer);
|
||||
const hash = hmac.digest();
|
||||
|
||||
// Dynamic truncation (SHA-1 always produces 20 bytes)
|
||||
// Dynamic truncation (SHA-1 always produces 20 bytes). The low nibble of the last byte
|
||||
// picks a 4-byte window anywhere in the hash; masking the top bit of that window's first
|
||||
// byte (0x7f) keeps the result a positive 31-bit int per RFC 4226, regardless of JS's
|
||||
// signed 32-bit bitwise operators.
|
||||
const lastByte = hash[hash.length - 1] ?? 0;
|
||||
const offset = lastByte & 0x0f;
|
||||
const code =
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user