// 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> { 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> { 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 { 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> { 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, modelRuntime: ModelRuntime, authType: string, ): Promise> { 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> = { 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)[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> { // 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 { 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> { 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> { // 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); }