/** * Environment variable loading and credential validation. * * Local mode: loads ./.env via dotenv. * NPX mode: fills gaps from ~/.shannon/config.toml (no .env). */ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import dotenv from 'dotenv'; import { resolveConfig } from './config/resolver.js'; import { getMode } from './mode.js'; import { CURATED_PROVIDERS, type CuratedProviderId, GENERIC_API_KEY_ENV, isCuratedProvider, PROVIDER_API_KEY_ENV, PROVIDER_CREDENTIAL_HINT, PROVIDER_EXTRA_ENV, resolveModelSpec, } from './model-spec.js'; /** * Variables forwarded to every worker container regardless of provider. Each is * forwarded only when set, so an unused one never appears in the container. * SHANNON_AI_API_KEY rides along because it is provider-neutral. */ const COMMON_FORWARD_VARS = [ 'SHANNON_AI_MODEL', 'SHANNON_AI_BASE_URL', 'SHANNON_AI_OPENAI_FORMAT', GENERIC_API_KEY_ENV, ] as const; /** * Credential variables for one provider. Only the selected provider's entries are * forwarded, so a key for an unused provider never enters the scan container. An * uncurated provider has none — it relies on the common SHANNON_AI_API_KEY. */ function providerForwardVars(providerId: string): readonly string[] { if (!isCuratedProvider(providerId)) return []; return [...PROVIDER_API_KEY_ENV[providerId], ...PROVIDER_EXTRA_ENV[providerId]]; } /** Parse a user-facing boolean env var: `1`/`true` (any case) true, `0`/`false`/empty false, else the default. */ export function envBool(name: string, defaultValue: boolean): boolean { const raw = process.env[name]?.trim().toLowerCase(); if (raw === undefined || raw === '') return defaultValue; if (raw === '1' || raw === 'true') return true; if (raw === '0' || raw === 'false') return false; return defaultValue; } const USE_PI_AUTH_ENV = 'SHANNON_USE_PI_AUTH'; /** Where the host's auth.json is mounted: pi's standard location (worker HOME is /tmp), read natively. */ export const PI_AUTH_CONTAINER_PATH = '/tmp/.pi/agent/auth.json'; /** Host path to pi's credential file. */ export function resolveHostPiAuthPath(): string { return path.join(os.homedir(), '.pi', 'agent', 'auth.json'); } export function piAuthFlagEnabled(): boolean { return envBool(USE_PI_AUTH_ENV, false); } /** Opted into pi auth via the flag, and the auth file exists to mount. */ export function shouldUsePiAuth(): boolean { return piAuthFlagEnabled() && fs.existsSync(resolveHostPiAuthPath()); } /** * Load credentials into process.env. * Local mode: loads ./.env via dotenv. * NPX mode: fills gaps from ~/.shannon/config.toml. * Exported env vars always take precedence in both modes. */ export function loadEnv(): void { if (getMode() === 'local') { dotenv.config({ path: '.env', quiet: true }); } else { resolveConfig(); } } /** * Build `-e` flags for docker run. Forwards the common vars plus only the * selected provider's credentials, passed by name (`-e KEY`) so secret values * stay out of the `docker run` argv; docker inherits them from this process's env. */ export function buildEnvFlags(): string[] { const flags: string[] = ['-e', 'TEMPORAL_ADDRESS=shannon-temporal:7233']; const spec = resolveModelSpec(); const providerVars = typeof spec === 'string' ? [] : providerForwardVars(spec.providerId); for (const key of [...COMMON_FORWARD_VARS, ...providerVars]) { if (process.env[key]) { flags.push('-e', key); } } return flags; } interface CredentialValidation { valid: boolean; error?: string; } /** Whether a curated provider has its own named credential set (API key plus any extra var). */ function hasNamedCredential(providerId: CuratedProviderId): boolean { const apiKeys = PROVIDER_API_KEY_ENV[providerId]; if (!apiKeys.some((name) => Boolean(process.env[name]))) return false; return PROVIDER_EXTRA_ENV[providerId].every((name) => Boolean(process.env[name])); } /** Whether the selected provider has a credential. Bedrock needs its AWS_ vars; the generic key never stands in for it. */ function hasCredential(providerId: string): boolean { if (providerId === 'amazon-bedrock') return hasNamedCredential('amazon-bedrock'); if (isCuratedProvider(providerId) && hasNamedCredential(providerId)) return true; return Boolean(process.env[GENERIC_API_KEY_ENV]); } /** Curated providers with a named credential. The generic key is neutral, so it never counts toward ambiguity. */ function configuredProviders(): CuratedProviderId[] { return CURATED_PROVIDERS.filter((providerId) => hasNamedCredential(providerId)); } /** * Validate that the model selection parses and its provider has a credential. * Runs before any Docker work so mistakes fail immediately. */ export function validateCredentials(): CredentialValidation { // 1. Model selection must parse into a provider and model id const spec = resolveModelSpec(); if (typeof spec === 'string') { return { valid: false, error: spec }; } // Pi-auth: skip the API-key checks, but the host auth file must exist to mount. if (piAuthFlagEnabled()) { const authPath = resolveHostPiAuthPath(); if (!fs.existsSync(authPath)) { return { valid: false, error: `${USE_PI_AUTH_ENV} is set but no pi credentials were found at ${authPath}. Authenticate with pi first.`, }; } return { valid: true }; } // 2. The selected provider must have a credential if (!hasCredential(spec.providerId)) { const requirement = isCuratedProvider(spec.providerId) ? PROVIDER_CREDENTIAL_HINT[spec.providerId] : GENERIC_API_KEY_ENV; const hint = getMode() === 'local' ? `Set ${requirement} in .env or export it.` : `Export the variables or run 'npx @keygraph/shannon setup'.`; return { valid: false, error: `No credentials found for provider "${spec.providerId}". ${hint}`, }; } // 3. Exactly one provider may be configured. Several complete credentials make // the scan's provider depend on SHANNON_AI_MODEL alone, which is too easy to // misread as "both are in play" and too easy to redirect by editing one line. const configured = configuredProviders(); if (configured.length > 1) { const setKeys = (id: CuratedProviderId): string[] => PROVIDER_API_KEY_ENV[id].filter((name) => Boolean(process.env[name])); const list = configured.map((id) => `${id} (${setKeys(id).join(', ')})`).join(' and '); const others = configured.filter((id) => id !== spec.providerId); const extraVars = others.flatMap(setKeys); const dropHint = getMode() === 'local' ? 'remove them from .env or unset them in your shell:' : "unset them in your shell, or reconfigure with 'npx @keygraph/shannon setup':"; const lines = [`Credentials for more than one provider are set: ${list}.`]; if (extraVars.length > 0) { lines.push( `Shannon runs one provider per scan, selected by SHANNON_AI_MODEL ("${spec.providerId}:...").`, `Keep ${spec.providerId} and drop the rest — ${dropHint}`, ` unset ${extraVars.join(' ')}`, ); } return { valid: false, error: lines.join('\n') }; } return { valid: true }; }