Files
shannon/apps/worker/src/services/preflight.ts
T
ezl-keygraphandGitHub a1675f8390 feat(cli): support any Pi provider via generic SHANNON_AI_API_KEY (#415)
* feat(cli): support any Pi provider via generic SHANNON_AI_API_KEY

* docs(cli): point users to pi.dev/models for provider and model ids

* docs: document generic provider path and pi.dev catalogue
2026-08-07 00:28:23 +05:30

575 lines
18 KiB
TypeScript

// 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.
/**
* Preflight Validation Service
*
* Runs cheap, fast checks before any agent execution begins.
* Catches configuration and credential problems early, saving
* time and API costs compared to failing mid-pipeline.
*
* Checks run sequentially, cheapest first:
* 1. Repository path exists and is a directory
* 2. Config file parses and validates (if provided)
* 3. code_path rules match real entries in the repo (filesystem only)
* 4. Credentials validate via a minimal pi session against the run's own model
* 5. Target URL resolves, is not link-local (cloud metadata), and is reachable (DNS + HTTP)
*/
import type { LookupAddress } from 'node:dns';
import { lookup } from 'node:dns/promises';
import fs from 'node:fs/promises';
import http from 'node:http';
import https from 'node:https';
import net, { type LookupFunction } from 'node:net';
import os from 'node:os';
import type { Api, AssistantMessage, Model } from '@earendil-works/pi-ai';
import {
type AgentSession,
createAgentSession,
type ModelRuntime,
SessionManager,
SettingsManager,
} from '@earendil-works/pi-coding-agent';
import { glob } from 'zx';
import {
type CuratedProviderId,
createModelRuntime,
GENERIC_API_KEY_ENV,
type ModelSpec,
type OpenAiFormat,
PI_CATALOG_URL,
resolveGatewayFormat,
resolveModel,
resolveModelSpec,
resolveProviderCredentials,
} from '../ai/models.js';
import { PI_RETRY_SETTINGS } from '../ai/pi/retry-settings.js';
import { providerTurnError } from '../ai/pi/turn-error.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, isErr, ok, type Result } from '../types/result.js';
import { isRetryableFailure, PentestError } from './error-handling.js';
const TARGET_URL_TIMEOUT_MS = 10_000;
function isLoopbackAddress(address: string): boolean {
return address === '127.0.0.1' || address === '::1' || address === '0.0.0.0';
}
// 169.254.0.0/16 hosts the cloud metadata service. RFC1918 and loopback are
// intentionally allowed — scanning local targets is a supported Shannon use case.
const metadataBlockList = new net.BlockList();
metadataBlockList.addSubnet('169.254.0.0', 16, 'ipv4');
function isBlockedAddress(address: string): boolean {
switch (net.isIP(address)) {
case 4:
return metadataBlockList.check(address, 'ipv4');
case 6:
return metadataBlockList.check(address, 'ipv6');
default:
return false;
}
}
/** DNS lookup pinned to already-validated `addresses`, so the socket cannot be re-pointed after validation (DNS rebinding). */
function pinnedLookup(addresses: LookupAddress[]): LookupFunction {
return (hostname, options, callback) => {
const matching = options.family ? addresses.filter((a) => a.family === options.family) : addresses;
const pool = matching.length > 0 ? matching : addresses;
if (options.all) {
callback(null, pool);
return;
}
const first = pool[0];
if (!first) {
callback(new Error(`no resolved address for ${hostname}`), '', 0);
return;
}
callback(null, first.address, first.family);
};
}
// === Repository Validation ===
async function validateRepo(repoPath: string, logger: ActivityLogger): Promise<Result<void, PentestError>> {
logger.info('Checking repository path...', { repoPath });
// Check repo directory exists. The repo is not required to be a git repository:
// multi-repo targets (a parent directory containing several repos) have no top-level
// .git, and git-based checkpoint/rollback in git-manager already no-ops on non-git dirs.
try {
const stats = await fs.stat(repoPath);
if (!stats.isDirectory()) {
return err(
new PentestError(
`Repository path is not a directory: ${repoPath}`,
'config',
false,
{ repoPath },
ErrorCode.REPO_NOT_FOUND,
),
);
}
} catch {
return err(
new PentestError(
`Repository path does not exist: ${repoPath}`,
'config',
false,
{ repoPath },
ErrorCode.REPO_NOT_FOUND,
),
);
}
logger.info('Repository path OK');
return ok(undefined);
}
// === Config Validation ===
async function validateConfig(configPath: string, logger: ActivityLogger): Promise<Result<Config, PentestError>> {
logger.info('Validating configuration file...', { configPath });
try {
const config = await parseConfig(configPath);
logger.info('Configuration file OK');
return ok(config);
} catch (error) {
if (error instanceof PentestError) {
return err(error);
}
const message = error instanceof Error ? error.message : String(error);
return err(
new PentestError(
`Configuration validation failed: ${message}`,
'config',
false,
{ configPath },
ErrorCode.CONFIG_VALIDATION_FAILED,
),
);
}
}
// === code_path Existence Validation ===
const CODE_PATH_IGNORE = ['.git/**', '.shannon/**'];
async function patternMatchesAny(repoPath: string, pattern: string): Promise<boolean> {
const stream = glob.globbyStream(pattern, {
cwd: repoPath,
dot: true,
onlyFiles: false,
followSymbolicLinks: false,
ignore: CODE_PATH_IGNORE,
});
for await (const _ of stream) {
return true;
}
return false;
}
type RuleKind = 'avoid' | 'focus';
interface MissingCodePath {
kind: RuleKind;
value: string;
description?: string;
}
async function validateCodePathsExist(
config: Config,
repoPath: string,
logger: ActivityLogger,
): Promise<Result<void, PentestError>> {
const tagged: Array<{ kind: RuleKind; rule: Rule }> = [
...(config.rules?.avoid ?? []).map((rule) => ({ kind: 'avoid' as const, rule })),
...(config.rules?.focus ?? []).map((rule) => ({ kind: 'focus' as const, rule })),
].filter(({ rule }) => rule.type === 'code_path');
if (tagged.length === 0) {
return ok(undefined);
}
logger.info(`Validating ${tagged.length} code_path rule(s) against repo...`);
// ≥1 match is the only property enforced — malformed globs simply match nothing.
const missing: MissingCodePath[] = [];
for (const { kind, rule } of tagged) {
if (!(await patternMatchesAny(repoPath, rule.value))) {
const entry: MissingCodePath = { kind, value: rule.value };
if (rule.description) {
entry.description = rule.description;
}
missing.push(entry);
}
}
if (missing.length > 0) {
const lines = missing.map((m) => `[${m.kind}] '${m.value}'${m.description ? ` - ${m.description}` : ''}`);
return err(
new PentestError(
`code_path rules don't match any file or directory in the repo:\n - ${lines.join('\n - ')}\n` +
`Fix the patterns or remove the rules.`,
'config',
false,
{ missing },
ErrorCode.CONFIG_VALIDATION_FAILED,
),
);
}
logger.info('All code_path rules matched');
return ok(undefined);
}
// === Credential Validation ===
/**
* Minimal pi session probe against the model the scan will use, so credentials the
* account cannot use fail here rather than partway through the run. The descriptor
* already carries the run's endpoint and wire format, so the probe exercises the
* same path the scan will.
*/
async function probeCredentialsWithPi(
model: Model<Api>,
modelRuntime: ModelRuntime,
authType: string,
): Promise<Result<void, PentestError>> {
let failedTurn: AssistantMessage | undefined;
let session: AgentSession | undefined;
try {
({ session } = await createAgentSession({
cwd: os.tmpdir(),
model,
noTools: 'all',
modelRuntime,
sessionManager: SessionManager.inMemory(),
settingsManager: SettingsManager.inMemory({ retry: PI_RETRY_SETTINGS, compaction: { enabled: false } }),
}));
session.subscribe((e) => {
if (e.type === 'turn_end' && e.message.role === 'assistant' && e.message.stopReason === 'error') {
failedTurn = e.message;
}
});
await session.prompt('hi');
} catch (error) {
const thrown = error instanceof Error ? error : new Error(String(error));
return err(
new PentestError(
`${authType} validation failed: ${thrown.message.slice(0, 300)}`,
'unknown',
isRetryableFailure(thrown),
{ authType },
ErrorCode.AGENT_EXECUTION_FAILED,
),
);
} finally {
session?.dispose();
}
if (failedTurn) return err(providerTurnError(failedTurn, `${authType} validation failed`));
return ok(undefined);
}
/** Credential env var a curated provider reads, for "credential missing" messages. */
const PROVIDER_CREDENTIAL_HINT: Readonly<Record<CuratedProviderId, string>> = {
anthropic: 'ANTHROPIC_API_KEY (or CLAUDE_CODE_OAUTH_TOKEN)',
openai: 'OPENAI_API_KEY',
xai: 'XAI_API_KEY',
'amazon-bedrock': 'AWS_BEARER_TOKEN_BEDROCK and AWS_REGION',
};
/** Which variable to set when a provider's credential is missing. */
function credentialHint(providerId: string): string {
const curated = (PROVIDER_CREDENTIAL_HINT as Record<string, string | undefined>)[providerId];
return curated ?? GENERIC_API_KEY_ENV;
}
/** Human-readable label for which credential path a run is using. */
function describeAuth(providerId: string, baseUrl: string | undefined): string {
if (baseUrl) return `custom endpoint (${baseUrl})`;
if (providerId === 'amazon-bedrock') return 'Bedrock bearer token';
return `${providerId} API key`;
}
/** Validate the model selection and its credentials via a minimal pi session. */
async function validateCredentials(logger: ActivityLogger): Promise<Result<void, PentestError>> {
// 1. Resolve the run's model. A malformed spec or unknown provider fails here,
// before any scan work begins.
let spec: ModelSpec;
try {
spec = resolveModelSpec();
} catch (error) {
return err(
new PentestError(
error instanceof Error ? error.message : String(error),
'config',
false,
{},
ErrorCode.AUTH_FAILED,
),
);
}
logger.info(`Model: ${spec.providerId}:${spec.modelId}`);
// 2. Credential presence. Bedrock needs both AWS_ vars; every other provider
// needs one API key.
const credentials = resolveProviderCredentials(spec.providerId);
// 3. Wire format for an OpenAI gateway. Rejects a format named where it cannot
// take effect, rather than letting the run proceed on the wrong API.
let format: OpenAiFormat;
try {
format = resolveGatewayFormat(spec.providerId, credentials.baseUrl);
} catch (error) {
return err(
new PentestError(
error instanceof Error ? error.message : String(error),
'config',
false,
{ providerId: spec.providerId },
ErrorCode.AUTH_FAILED,
),
);
}
const isBedrock = spec.providerId === 'amazon-bedrock';
const missing = isBedrock ? ['AWS_REGION', 'AWS_BEARER_TOKEN_BEDROCK'].filter((n) => !process.env[n]) : [];
if (missing.length > 0 || (!isBedrock && !credentials.apiKey)) {
return err(
new PentestError(
`No credentials found for provider "${spec.providerId}". Set ${credentialHint(spec.providerId)} in .env.`,
'config',
false,
{ providerId: spec.providerId, ...(missing.length > 0 && { missing }) },
ErrorCode.AUTH_FAILED,
),
);
}
// 4. Model must exist in the registry, for every provider — Bedrock IDs are the
// easiest to get wrong, since region prefixes and version suffixes differ per
// model (`us.anthropic.claude-opus-5` exists, bare `anthropic.` does not).
// A custom endpoint is exempt: it may serve models under its own names.
const modelRuntime = await createModelRuntime(spec.providerId, credentials.apiKey);
const baseModel = resolveModel(modelRuntime, spec.providerId, spec.modelId, credentials.baseUrl, format);
if (!baseModel) {
return err(
new PentestError(
`Model not found in pi registry: provider="${spec.providerId}" model="${spec.modelId}". Check SHANNON_AI_MODEL — browse valid providers and models at ${PI_CATALOG_URL}.`,
'config',
false,
{ providerId: spec.providerId, modelId: spec.modelId },
ErrorCode.AUTH_FAILED,
),
);
}
if (!modelRuntime.getModel(spec.providerId, spec.modelId)) {
logger.warn(
`Model "${spec.modelId}" is not in the ${spec.providerId} catalogue; passing it to the custom endpoint as given. Cost figures will be approximate.`,
);
}
if (credentials.baseUrl && spec.providerId === 'openai') {
logger.info(`Gateway API: ${format} (${baseModel.api})`);
}
// 5. One real request, so a credential the account cannot use fails here
// rather than partway through the run. Bedrock included: pi resolves the
// bearer token from the primed credential and the region from AWS_REGION,
// so the probe exercises the same auth path the scan will.
const authType = describeAuth(spec.providerId, credentials.baseUrl);
logger.info(`Validating ${authType} via pi...`);
const probe = await probeCredentialsWithPi(baseModel, modelRuntime, authType);
if (isErr(probe)) return probe;
logger.info(`${authType} OK`);
return ok(undefined);
}
// === Target URL Validation ===
/** HTTP HEAD with TLS verification disabled — we check reachability, not certificate validity. */
function httpHead(url: string, timeoutMs: number, addresses: LookupAddress[]): Promise<number> {
return new Promise((resolve, reject) => {
const parsed = new URL(url);
const isHttps = parsed.protocol === 'https:';
const transport = isHttps ? https : http;
const req = transport.request(
url,
{
method: 'HEAD',
timeout: timeoutMs,
lookup: pinnedLookup(addresses),
...(isHttps && { rejectUnauthorized: false }),
},
(res) => {
res.resume();
resolve(res.statusCode ?? 0);
},
);
req.on('timeout', () => {
req.destroy();
reject(new Error(`Connection timed out after ${timeoutMs}ms`));
});
req.on('error', reject);
req.end();
});
}
/** Check that the target URL is reachable from inside the container. */
async function validateTargetUrl(targetUrl: string, logger: ActivityLogger): Promise<Result<void, PentestError>> {
logger.info('Checking target URL reachability...');
// 1. Parse URL
let parsed: URL;
try {
parsed = new URL(targetUrl);
} catch {
return err(
new PentestError(
`Invalid target URL: ${targetUrl}`,
'config',
false,
{ targetUrl },
ErrorCode.TARGET_UNREACHABLE,
),
);
}
// 2. Resolve all records once — reused (pinned) for the connection below.
const hostname = parsed.hostname;
let addresses: LookupAddress[];
try {
addresses = await lookup(hostname, { all: true });
} catch {
return err(
new PentestError(
`Target URL ${targetUrl} is not reachable. Verify the URL is correct and the site is up.`,
'network',
false,
{ targetUrl, hostname },
ErrorCode.TARGET_UNREACHABLE,
),
);
}
// 3. Reject the link-local metadata range (169.254.0.0/16).
const blocked = addresses.find((entry) => isBlockedAddress(entry.address));
if (blocked) {
return err(
new PentestError(
`Target URL ${targetUrl} resolves to ${blocked.address}, a link-local address ` +
`(169.254.0.0/16). This range hosts the cloud instance metadata service and cannot be scanned.`,
'config',
false,
{ targetUrl, hostname, address: blocked.address },
ErrorCode.TARGET_UNREACHABLE,
),
);
}
// 4. HTTP reachability check (socket pinned to the resolved addresses).
try {
await httpHead(targetUrl, TARGET_URL_TIMEOUT_MS, addresses);
logger.info('Target URL OK');
return ok(undefined);
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
const isLoopback = addresses.some((entry) => isLoopbackAddress(entry.address));
if (isLoopback) {
const suggestion = targetUrl.replace(hostname, 'host.docker.internal');
return err(
new PentestError(
`Target URL ${targetUrl} resolves to a loopback address and is not reachable. ` +
`For local services, use host.docker.internal instead of ${hostname} (e.g., ${suggestion})`,
'network',
false,
{ targetUrl, hostname },
ErrorCode.TARGET_UNREACHABLE,
),
);
}
return err(
new PentestError(
`Target URL ${targetUrl} is not reachable: ${detail}`,
'network',
false,
{ targetUrl },
ErrorCode.TARGET_UNREACHABLE,
),
);
}
}
// === Preflight Orchestrator ===
/**
* Run all preflight checks sequentially (cheapest first).
*
* 1. Repository path exists and is a directory
* 2. Config file parses and validates (if configPath provided)
* 3. code_path rules match at least one entry in the repo (skipped without config)
* 4. Credentials validate (API key, OAuth, or Bedrock)
* 5. Target URL is reachable from the container
*
* Returns on first failure.
*/
export async function runPreflightChecks(
targetUrl: string,
repoPath: string,
configPath: string | undefined,
logger: ActivityLogger,
): Promise<Result<void, PentestError>> {
// 1. Repository check (free — filesystem only)
const repoResult = await validateRepo(repoPath, logger);
if (!repoResult.ok) {
return repoResult;
}
// 2. Config check (free — filesystem + CPU)
let parsedConfig: Config | null = null;
if (configPath) {
const configResult = await validateConfig(configPath, logger);
if (!configResult.ok) {
return configResult;
}
parsedConfig = configResult.value;
}
// 3. code_path rules must match real entries in the repo (filesystem only).
// Runs after both repo and config are valid, before any network round-trip.
if (parsedConfig) {
const codePathResult = await validateCodePathsExist(parsedConfig, repoPath, logger);
if (!codePathResult.ok) {
return codePathResult;
}
}
// 4. Credential check (cheap — 1 pi round-trip)
const credResult = await validateCredentials(logger);
if (!credResult.ok) {
return credResult;
}
// 5. Target URL reachability check (cheap — 1 HTTP round-trip)
const urlResult = await validateTargetUrl(targetUrl, logger);
if (!urlResult.ok) {
return urlResult;
}
logger.info('All preflight checks passed');
return ok(undefined);
}