mirror of
https://github.com/KeygraphHQ/shannon.git
synced 2026-07-15 17:17:25 +02:00
feat(worker): migrate agent runtime from Claude Agent SDK to pi harness
This commit is contained in:
@@ -23,7 +23,7 @@
|
||||
|
||||
import { fs, path } from 'zx';
|
||||
import { type ClaudePromptResult, runClaudePrompt, validateAgentOutput } from '../ai/claude-executor.js';
|
||||
import { getOutputFormat, getQueueFilename } from '../ai/queue-schemas.js';
|
||||
import { createQueueSubmitTool, getQueueFilename } from '../ai/queue-schemas.js';
|
||||
import type { AuditSession } from '../audit/index.js';
|
||||
import { authStateFile } from '../audit/utils.js';
|
||||
import { AGENTS } from '../session-manager.js';
|
||||
@@ -54,7 +54,7 @@ export interface AgentExecutionInput {
|
||||
apiKey?: string | undefined;
|
||||
promptDir?: string | undefined;
|
||||
providerConfig?: import('../types/config.js').ProviderConfig | undefined;
|
||||
mcpServers?: Record<string, import('@anthropic-ai/claude-agent-sdk').McpServerConfig>;
|
||||
customTools?: import('@earendil-works/pi-coding-agent').ToolDefinition[];
|
||||
}
|
||||
|
||||
interface FailAgentOpts {
|
||||
@@ -109,7 +109,7 @@ export class AgentExecutionService {
|
||||
apiKey,
|
||||
promptDir,
|
||||
providerConfig,
|
||||
mcpServers,
|
||||
customTools,
|
||||
} = input;
|
||||
|
||||
// 1. Load config (pre-parsed configData → raw YAML → file path)
|
||||
@@ -163,8 +163,10 @@ export class AgentExecutionService {
|
||||
// 4. Start audit logging
|
||||
await auditSession.startAgent(agentName, prompt, attemptNumber);
|
||||
|
||||
// 5. Execute agent
|
||||
const outputFormat = getOutputFormat(agentName, distributedConfig?.exploit ?? true);
|
||||
// 5. Execute agent. Vuln agents get a submit tool that captures the structured
|
||||
// exploitation queue (pi has no JSON-schema output format).
|
||||
const submitTool = createQueueSubmitTool(agentName, distributedConfig?.exploit ?? true);
|
||||
const callerTools = [...(customTools ?? []), ...(submitTool ? [submitTool.tool] : [])];
|
||||
const result: ClaudePromptResult = await runClaudePrompt(
|
||||
prompt,
|
||||
repoPath,
|
||||
@@ -174,11 +176,10 @@ export class AgentExecutionService {
|
||||
auditSession,
|
||||
logger,
|
||||
AGENTS[agentName].modelTier,
|
||||
outputFormat,
|
||||
callerTools,
|
||||
apiKey,
|
||||
path.relative(repoPath, deliverablesPath),
|
||||
providerConfig,
|
||||
mcpServers,
|
||||
);
|
||||
|
||||
// 6. Spending cap check - defense-in-depth
|
||||
@@ -212,13 +213,17 @@ export class AgentExecutionService {
|
||||
});
|
||||
}
|
||||
|
||||
// 8. Write structured output to disk (vuln agents only)
|
||||
// 8. Write structured output to disk (vuln agents only) from the submit-tool capture
|
||||
const queueFilename = getQueueFilename(agentName);
|
||||
if (result.structuredOutput !== undefined && queueFilename) {
|
||||
await fs.ensureDir(deliverablesPath);
|
||||
const queuePath = path.join(deliverablesPath, queueFilename);
|
||||
await fs.writeFile(queuePath, JSON.stringify(result.structuredOutput, null, 2), 'utf8');
|
||||
logger.info(`Wrote structured output queue to ${queueFilename}`);
|
||||
if (submitTool && queueFilename) {
|
||||
const captured = submitTool.getCaptured();
|
||||
if (captured !== undefined) {
|
||||
result.structuredOutput = captured; // carry for the validation gate below
|
||||
await fs.ensureDir(deliverablesPath);
|
||||
const queuePath = path.join(deliverablesPath, queueFilename);
|
||||
await fs.writeFile(queuePath, JSON.stringify(captured, null, 2), 'utf8');
|
||||
logger.info(`Wrote structured output queue to ${queueFilename}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 9. Validate output
|
||||
|
||||
@@ -25,16 +25,23 @@ import fs from 'node:fs/promises';
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
import net, { type LookupFunction } from 'node:net';
|
||||
import type { SDKAssistantMessageError } from '@anthropic-ai/claude-agent-sdk';
|
||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
import os from 'node:os';
|
||||
import {
|
||||
AuthStorage,
|
||||
createAgentSession,
|
||||
ModelRegistry,
|
||||
SessionManager,
|
||||
SettingsManager,
|
||||
} from '@earendil-works/pi-coding-agent';
|
||||
import { glob } from 'zx';
|
||||
import { resolveModel } from '../ai/models.js';
|
||||
import { resolveModelId } from '../ai/models.js';
|
||||
import { parseConfig } from '../config-parser.js';
|
||||
import type { ActivityLogger } from '../types/activity-logger.js';
|
||||
import type { Config, Rule } from '../types/config.js';
|
||||
import { ErrorCode } from '../types/errors.js';
|
||||
import { err, ok, type Result } from '../types/result.js';
|
||||
import { isRetryableError, PentestError } from './error-handling.js';
|
||||
import { err, isErr, ok, type Result } from '../types/result.js';
|
||||
import { matchesBillingTextPattern } from '../utils/billing-detection.js';
|
||||
import { PentestError } from './error-handling.js';
|
||||
|
||||
const TARGET_URL_TIMEOUT_MS = 10_000;
|
||||
|
||||
@@ -241,82 +248,106 @@ async function validateCodePathsExist(
|
||||
// === Credential Validation ===
|
||||
|
||||
/** Map SDK error type to a human-readable preflight PentestError. */
|
||||
function classifySdkError(sdkError: SDKAssistantMessageError, authType: string): Result<void, PentestError> {
|
||||
switch (sdkError) {
|
||||
case 'authentication_failed':
|
||||
return err(
|
||||
new PentestError(
|
||||
`Invalid ${authType}. Check your credentials in .env and try again.`,
|
||||
'config',
|
||||
false,
|
||||
{ authType, sdkError },
|
||||
ErrorCode.AUTH_FAILED,
|
||||
),
|
||||
);
|
||||
case 'billing_error':
|
||||
return err(
|
||||
new PentestError(
|
||||
`Anthropic account has a billing issue. Add credits or check your billing dashboard.`,
|
||||
'billing',
|
||||
true,
|
||||
{ authType, sdkError },
|
||||
ErrorCode.BILLING_ERROR,
|
||||
),
|
||||
);
|
||||
case 'rate_limit':
|
||||
return err(
|
||||
new PentestError(
|
||||
`Anthropic rate limit or spending cap reached. Wait a few minutes and try again.`,
|
||||
'billing',
|
||||
true,
|
||||
{ authType, sdkError },
|
||||
ErrorCode.BILLING_ERROR,
|
||||
),
|
||||
);
|
||||
case 'server_error':
|
||||
return err(
|
||||
new PentestError(`Anthropic API is temporarily unavailable. Try again shortly.`, 'network', true, {
|
||||
authType,
|
||||
sdkError,
|
||||
}),
|
||||
);
|
||||
case 'overloaded':
|
||||
return err(
|
||||
new PentestError(`Anthropic API is overloaded. Wait a few moments and try again.`, 'network', true, {
|
||||
authType,
|
||||
sdkError,
|
||||
}),
|
||||
);
|
||||
case 'model_not_found':
|
||||
return err(
|
||||
new PentestError(
|
||||
`Configured model is not available for this account. Check ANTHROPIC_*_MODEL in .env.`,
|
||||
'config',
|
||||
false,
|
||||
{ authType, sdkError },
|
||||
),
|
||||
);
|
||||
case 'oauth_org_not_allowed':
|
||||
return err(
|
||||
new PentestError(
|
||||
`This credential's organization is not allowed. Check your ${authType} in .env.`,
|
||||
'config',
|
||||
false,
|
||||
{ authType, sdkError },
|
||||
ErrorCode.AUTH_FAILED,
|
||||
),
|
||||
);
|
||||
default:
|
||||
return err(
|
||||
new PentestError(
|
||||
`${authType} validation failed unexpectedly. Check your credentials in .env.`,
|
||||
'config',
|
||||
false,
|
||||
{ authType, sdkError },
|
||||
ErrorCode.AUTH_FAILED,
|
||||
),
|
||||
);
|
||||
/** Classify a provider error message (thrown or from a failed turn) into a PentestError. */
|
||||
function classifyCredentialError(text: string, authType: string): Result<void, PentestError> {
|
||||
const lower = text.toLowerCase();
|
||||
if (matchesBillingTextPattern(text)) {
|
||||
return err(
|
||||
new PentestError(
|
||||
`Anthropic account has a billing or rate-limit issue during ${authType} validation. Add credits or wait and retry.`,
|
||||
'billing',
|
||||
true,
|
||||
{ authType },
|
||||
ErrorCode.BILLING_ERROR,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (/401|403|invalid[ _-]?api[ _-]?key|unauthorized|authentication|forbidden|not allowed|x-api-key/.test(lower)) {
|
||||
return err(
|
||||
new PentestError(
|
||||
`Invalid ${authType}. Check your credentials in .env and try again.`,
|
||||
'config',
|
||||
false,
|
||||
{ authType },
|
||||
ErrorCode.AUTH_FAILED,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (/model/.test(lower) && /not found|not available|unknown/.test(lower)) {
|
||||
return err(
|
||||
new PentestError(
|
||||
`Configured model is not available for this account. Check ANTHROPIC_*_MODEL in .env.`,
|
||||
'config',
|
||||
false,
|
||||
{ authType },
|
||||
),
|
||||
);
|
||||
}
|
||||
if (
|
||||
/network|timeout|enotfound|econnrefused|fetch failed|getaddrinfo|socket|overloaded|unavailable|50\d/.test(lower)
|
||||
) {
|
||||
return err(
|
||||
new PentestError(`Anthropic API unreachable or temporarily unavailable. Try again shortly.`, 'network', true, {
|
||||
authType,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return err(
|
||||
new PentestError(
|
||||
`${authType} validation failed: ${text.slice(0, 150)}`,
|
||||
'config',
|
||||
false,
|
||||
{ authType },
|
||||
ErrorCode.AUTH_FAILED,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Minimal pi session probe to validate credentials. An optional baseUrl overrides the endpoint. */
|
||||
async function probeCredentialsWithPi(authType: string, baseUrl?: string): Promise<Result<void, PentestError>> {
|
||||
const authStorage = AuthStorage.inMemory();
|
||||
const token =
|
||||
process.env.ANTHROPIC_AUTH_TOKEN ?? process.env.ANTHROPIC_API_KEY ?? process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||
if (token) authStorage.setRuntimeApiKey('anthropic', token);
|
||||
|
||||
const baseModel = ModelRegistry.create(authStorage).find('anthropic', resolveModelId('small'));
|
||||
if (!baseModel) {
|
||||
return err(
|
||||
new PentestError(
|
||||
`Model not found in pi registry: ${resolveModelId('small')}`,
|
||||
'config',
|
||||
false,
|
||||
{},
|
||||
ErrorCode.AUTH_FAILED,
|
||||
),
|
||||
);
|
||||
}
|
||||
const model = baseUrl ? { ...baseModel, baseUrl } : baseModel;
|
||||
|
||||
let errText: string | undefined;
|
||||
try {
|
||||
const { session } = await createAgentSession({
|
||||
cwd: os.tmpdir(),
|
||||
model,
|
||||
thinkingLevel: 'off',
|
||||
noTools: 'all',
|
||||
authStorage,
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
settingsManager: SettingsManager.inMemory({ retry: { enabled: false }, compaction: { enabled: false } }),
|
||||
});
|
||||
session.subscribe((e) => {
|
||||
if (e.type === 'turn_end' && e.message.role === 'assistant' && e.message.stopReason === 'error') {
|
||||
errText = e.message.errorMessage ?? 'unknown provider error';
|
||||
}
|
||||
});
|
||||
await session.prompt('hi');
|
||||
session.dispose();
|
||||
} catch (error) {
|
||||
errText = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
if (errText) return classifyCredentialError(errText, authType);
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
/** Validate credentials via a minimal Claude Agent SDK query. */
|
||||
@@ -339,35 +370,14 @@ async function validateCredentials(
|
||||
if (apiKey) {
|
||||
process.env.ANTHROPIC_API_KEY = apiKey;
|
||||
}
|
||||
// 1. Custom base URL — validate endpoint is reachable via SDK query
|
||||
// 1. Custom base URL — validate the endpoint via a minimal pi session
|
||||
if (process.env.ANTHROPIC_BASE_URL && process.env.ANTHROPIC_AUTH_TOKEN) {
|
||||
const baseUrl = process.env.ANTHROPIC_BASE_URL;
|
||||
logger.info('Validating custom base URL');
|
||||
|
||||
try {
|
||||
for await (const message of query({ prompt: 'hi', options: { model: resolveModel('small'), maxTurns: 1 } })) {
|
||||
if (message.type === 'assistant' && message.error) {
|
||||
return classifySdkError(message.error, `custom endpoint (${baseUrl})`);
|
||||
}
|
||||
if (message.type === 'result') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Custom base URL OK');
|
||||
return ok(undefined);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return err(
|
||||
new PentestError(
|
||||
`Custom base URL unreachable: ${baseUrl} — ${message}`,
|
||||
'network',
|
||||
false,
|
||||
{ baseUrl },
|
||||
ErrorCode.AUTH_FAILED,
|
||||
),
|
||||
);
|
||||
}
|
||||
const probe = await probeCredentialsWithPi(`custom endpoint (${baseUrl})`, baseUrl);
|
||||
if (isErr(probe)) return probe;
|
||||
logger.info('Custom base URL OK');
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
// 2. Bedrock mode — validate required AWS credentials are present
|
||||
@@ -459,38 +469,13 @@ async function validateCredentials(
|
||||
);
|
||||
}
|
||||
|
||||
// 5. Validate via SDK query
|
||||
// 5. Validate via a minimal pi session
|
||||
const authType = process.env.CLAUDE_CODE_OAUTH_TOKEN ? 'OAuth token' : 'API key';
|
||||
logger.info(`Validating ${authType} via SDK...`);
|
||||
|
||||
try {
|
||||
for await (const message of query({ prompt: 'hi', options: { model: resolveModel('small'), maxTurns: 1 } })) {
|
||||
if (message.type === 'assistant' && message.error) {
|
||||
return classifySdkError(message.error, authType);
|
||||
}
|
||||
if (message.type === 'result') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`${authType} OK`);
|
||||
return ok(undefined);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const retryable = isRetryableError(error instanceof Error ? error : new Error(message));
|
||||
|
||||
return err(
|
||||
new PentestError(
|
||||
retryable
|
||||
? `Failed to reach Anthropic API. Check your network connection.`
|
||||
: `${authType} validation failed: ${message}`,
|
||||
retryable ? 'network' : 'config',
|
||||
retryable,
|
||||
{ authType },
|
||||
retryable ? undefined : ErrorCode.AUTH_FAILED,
|
||||
),
|
||||
);
|
||||
}
|
||||
logger.info(`Validating ${authType} via pi...`);
|
||||
const probe = await probeCredentialsWithPi(authType);
|
||||
if (isErr(probe)) return probe;
|
||||
logger.info(`${authType} OK`);
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
// === Target URL Validation ===
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
*/
|
||||
|
||||
import { readFile, rm } from 'node:fs/promises';
|
||||
import type { JsonSchemaOutputFormat } from '@anthropic-ai/claude-agent-sdk';
|
||||
import { z } from 'zod';
|
||||
import { defineTool, type ToolDefinition } from '@earendil-works/pi-coding-agent';
|
||||
import { Type } from 'typebox';
|
||||
import { runClaudePrompt } from '../ai/claude-executor.js';
|
||||
import type { AuditSession } from '../audit/index.js';
|
||||
import { authStateFile } from '../audit/utils.js';
|
||||
@@ -33,26 +33,38 @@ function isAuthFailurePoint(v: unknown): v is AuthFailurePoint {
|
||||
return typeof v === 'string' && (FAILURE_POINTS as readonly string[]).includes(v);
|
||||
}
|
||||
|
||||
// NOTE: SDK's AJV validator expects draft-07; Zod defaults to draft-2020-12,
|
||||
// which causes the SDK to silently skip structured output.
|
||||
const AuthValidationSchema = z.object({
|
||||
login_success: z.boolean(),
|
||||
failure_point: z.enum(FAILURE_POINTS).optional(),
|
||||
failure_detail: z
|
||||
.string()
|
||||
.max(250)
|
||||
.optional()
|
||||
.describe(
|
||||
'Free-form 1-2 sentence diagnostic of what the page showed (error messages, page state) when login failed. Required when login_success is false. Mask any sensitive values.',
|
||||
),
|
||||
});
|
||||
interface AuthValidationVerdict {
|
||||
login_success: boolean;
|
||||
failure_point?: AuthFailurePoint;
|
||||
failure_detail?: string;
|
||||
}
|
||||
|
||||
type AuthValidationVerdict = z.infer<typeof AuthValidationSchema>;
|
||||
|
||||
const VALIDATION_SCHEMA: JsonSchemaOutputFormat = {
|
||||
type: 'json_schema',
|
||||
schema: z.toJSONSchema(AuthValidationSchema, { target: 'draft-07' }) as Record<string, unknown>,
|
||||
};
|
||||
/** Submit tool capturing the login verdict (pi has no JSON-schema output format). */
|
||||
function createAuthSubmitTool(): { tool: ToolDefinition; getCaptured: () => AuthValidationVerdict | undefined } {
|
||||
let captured: AuthValidationVerdict | undefined;
|
||||
const tool = defineTool({
|
||||
name: 'submit_auth_result',
|
||||
label: 'Submit Auth Result',
|
||||
description: 'Report the login outcome. Call exactly once when the login attempt has concluded.',
|
||||
parameters: Type.Object({
|
||||
login_success: Type.Boolean(),
|
||||
failure_point: Type.Optional(
|
||||
Type.Union([Type.Literal('username_or_password'), Type.Literal('totp_secret'), Type.Literal('out_of_band')]),
|
||||
),
|
||||
failure_detail: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
'Free-form 1-2 sentence diagnostic of what the page showed (error messages, page state) when login failed. Required when login_success is false. Mask any sensitive values.',
|
||||
}),
|
||||
),
|
||||
}),
|
||||
execute: async (_toolCallId, params) => {
|
||||
captured = params as AuthValidationVerdict;
|
||||
return { content: [{ type: 'text' as const, text: 'Auth result recorded.' }], details: {} };
|
||||
},
|
||||
});
|
||||
return { tool, getCaptured: () => captured };
|
||||
}
|
||||
|
||||
const AGENT_NAME = 'validate-authentication';
|
||||
|
||||
@@ -110,6 +122,7 @@ export async function validateAuthentication(input: ValidateAuthInput): Promise<
|
||||
await auditSession.startAgent(AGENT_NAME, prompt, attemptNumber);
|
||||
const startTime = Date.now();
|
||||
|
||||
const submit = createAuthSubmitTool();
|
||||
const result = await runClaudePrompt(
|
||||
prompt,
|
||||
repoPath,
|
||||
@@ -119,11 +132,13 @@ export async function validateAuthentication(input: ValidateAuthInput): Promise<
|
||||
auditSession,
|
||||
logger,
|
||||
'medium',
|
||||
VALIDATION_SCHEMA,
|
||||
[submit.tool],
|
||||
apiKey,
|
||||
deliverablesSubdir,
|
||||
providerConfig,
|
||||
);
|
||||
const verdict = submit.getCaptured();
|
||||
if (verdict !== undefined) result.structuredOutput = verdict;
|
||||
|
||||
let classification = classifyResult(result, authentication);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user