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:
ajmallesh
2026-08-27 14:30:16 -07:00
246 changed files with 32765 additions and 2724 deletions
+46 -60
View File
@@ -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();
}
+54
View File
@@ -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();
+32 -5
View File
@@ -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,
};
}
+28 -19
View File
@@ -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 -1
View File
@@ -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
+49 -20
View File
@@ -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);
+5 -5
View File
@@ -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;
+4 -16
View File
@@ -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: [
{
+295
View File
@@ -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();
+136 -73
View File
@@ -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;
}
+78
View File
@@ -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;
}
}
+18 -27
View File
@@ -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
+10 -2
View File
@@ -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();
+178 -26
View File
@@ -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;
}
+415
View File
@@ -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);
};
}
+441
View File
@@ -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);
});
}
+50
View File
@@ -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;
+69
View File
@@ -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;
}
+63
View File
@@ -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);
}
+94
View File
@@ -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 }),
};
}
+159
View File
@@ -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)]);
}
+283
View File
@@ -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;
});
}
+253
View File
@@ -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 };
}
+216
View File
@@ -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>;
}
+7 -1
View File
@@ -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 -1
View File
@@ -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
+98
View File
@@ -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`);
}
+256 -100
View File
@@ -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 -1
View File
@@ -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
+184 -96
View File
@@ -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;
}
}
-122
View File
@@ -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);
}
}
+656 -54
View File
@@ -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;
}
+177
View File
@@ -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(' ');
}
+124
View File
@@ -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;
}
+4 -30
View File
@@ -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 -1
View File
@@ -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
+3 -1
View File
@@ -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' });
},
+27 -13
View File
@@ -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,
+5
View File
@@ -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');
+3 -2
View File
@@ -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;
}
+10 -2
View File
@@ -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