mirror of
https://github.com/KeygraphHQ/shannon.git
synced 2026-10-07 16:56:51 +02:00
* feat(preflight): gate scans on an exploit-workload readiness probe
* feat: add --validate-auth to run authentication validation only
* feat: refuse reusing an auth-validation workspace for a scan
* chore: refresh suggested model IDs (Grok 4.7, OpenAI gpt-6-sol, Claude 5)
* fix(preflight): make the exploit-readiness probe trip the cyber safeguard reliably
* chore(preflight): update the exploit-readiness probe prompt
* feat: add --validate-model to run the preflight model checks only
* feat(cli): name cyber-access and app-login steps in the start loader
* feat(cli): refine start loader — skip app-login step when following, annotate preflight label
* feat(status): show Preflight and Cyber access verification rows for gated providers
* chore(preflight): suggest a fallback model in cyber-access remediation hints
* chore(preflight): drop env-var syntax from cyber-access fallback hints
* fix(preflight): separate finding heading from Target line in readiness probe
* refactor(preflight): rename exploit-readiness probe to cyber access verification
* fix(preflight): re-join finding heading with Target line in readiness probe
Reverts the heading/Target split from 22d84f2, gluing each finding's
heading back onto its Target line in the cyber-access probe's user
content.
* feat(validation): show a Checks summary in the validation log
* fix(cli): say a validation run failed, not that it could not start
* docs(ai-providers): replace broken Pi subscription link with /login steps
* feat(preflight): gate the openai-codex subscription on cyber access
365 lines
12 KiB
TypeScript
365 lines
12 KiB
TypeScript
/**
|
|
* `npx @keygraph/shannon setup` — interactive TUI wizard for one-time credential configuration.
|
|
*
|
|
* Walks the user through selecting a provider, entering credentials, and naming
|
|
* the model that runs the whole scan, then persists everything to
|
|
* ~/.shannon/config.toml with 0o600 permissions.
|
|
*/
|
|
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import * as p from '@clack/prompts';
|
|
import { type ShannonConfig, saveConfig } from '../config/writer.js';
|
|
import { CURATED_PROVIDERS, type CuratedProviderId, isCuratedProvider } from '../model-spec.js';
|
|
import { displaySplash } from '../splash.js';
|
|
import { requireInteractive } from '../tty.js';
|
|
import { getVersion } from '../version.js';
|
|
|
|
const SHANNON_HOME = path.join(os.homedir(), '.shannon');
|
|
|
|
const CUSTOM_MODEL = '__custom__';
|
|
const CUSTOM_BASE_URL = '__custom_base_url__';
|
|
const OTHER_PROVIDER = '__other_provider__';
|
|
|
|
/**
|
|
* API dialects reachable through the gateway route. The dialect picks the provider
|
|
* that supplies the credential and names the wire protocol the endpoint must speak.
|
|
*/
|
|
const GATEWAY_DIALECTS: readonly {
|
|
value: string;
|
|
label: string;
|
|
provider: 'anthropic' | 'openai';
|
|
}[] = [
|
|
{ value: 'anthropic', label: 'Anthropic Messages', provider: 'anthropic' },
|
|
{ value: 'openai', label: 'OpenAI Responses', provider: 'openai' },
|
|
];
|
|
|
|
/** Suggested models per curated provider, best-first. Free-text entry accepts any model in the provider's catalogue. */
|
|
const MODEL_SUGGESTIONS: Readonly<Record<CuratedProviderId, readonly string[]>> = {
|
|
anthropic: [
|
|
'claude-sonnet-5',
|
|
'claude-opus-5',
|
|
'claude-sonnet-4-6',
|
|
'claude-opus-4-8',
|
|
'claude-opus-4-7',
|
|
'claude-haiku-4-5-20251001',
|
|
],
|
|
openai: ['gpt-6-sol', 'gpt-5.6-sol', 'gpt-5.5', 'gpt-5.4'],
|
|
xai: ['grok-4.7'],
|
|
'amazon-bedrock': ['us.anthropic.claude-sonnet-4-6', 'us.anthropic.claude-opus-4-8', 'us.anthropic.claude-opus-4-7'],
|
|
};
|
|
|
|
/** Placeholder shown in the free-text model ID prompt, per curated provider. */
|
|
const MODEL_ID_PLACEHOLDER: Readonly<Record<CuratedProviderId, string>> = {
|
|
anthropic: 'claude-sonnet-4-6',
|
|
openai: 'gpt-6-sol',
|
|
xai: 'grok-4.7',
|
|
'amazon-bedrock': 'us.anthropic.claude-opus-4-8',
|
|
};
|
|
|
|
/** Model ID placeholder for a provider, absent when the provider is not curated. */
|
|
function modelIdPlaceholder(provider: string): string | undefined {
|
|
return isCuratedProvider(provider) ? MODEL_ID_PLACEHOLDER[provider] : undefined;
|
|
}
|
|
|
|
export async function setup(): Promise<void> {
|
|
requireInteractive('setup', 'For non-interactive use, export credentials as env vars (e.g. ANTHROPIC_API_KEY).');
|
|
displaySplash(getVersion());
|
|
p.intro('Setup');
|
|
|
|
// 1. Select provider. "Custom Base URL" is a route, not a provider — it asks
|
|
// which API dialect the gateway speaks and configures that provider. "Other
|
|
// provider" reaches any pi-supported provider Shannon does not curate.
|
|
const selected = await p.select({
|
|
message: 'Select your AI provider',
|
|
options: [
|
|
{ value: 'anthropic' as const, label: 'Anthropic', hint: 'Claude models - recommended' },
|
|
{ value: 'openai' as const, label: 'OpenAI', hint: 'GPT models' },
|
|
{ value: 'xai' as const, label: 'xAI', hint: 'Grok models' },
|
|
{ value: 'amazon-bedrock' as const, label: 'AWS Bedrock', hint: 'Claude models via AWS' },
|
|
{
|
|
value: CUSTOM_BASE_URL as typeof CUSTOM_BASE_URL,
|
|
label: 'Custom Base URL',
|
|
hint: 'route through a proxy or LLM gateway',
|
|
},
|
|
{
|
|
value: OTHER_PROVIDER as typeof OTHER_PROVIDER,
|
|
label: 'Other provider',
|
|
hint: 'any other Pi-supported provider',
|
|
},
|
|
],
|
|
});
|
|
if (p.isCancel(selected)) return cancelAndExit();
|
|
|
|
// 2. Credentials, and any endpoint override. A base URL overrides the endpoint
|
|
// for whichever provider is chosen — the curated gateway route names it via
|
|
// the dialect, the "Other provider" route asks for it directly.
|
|
const { provider, config, baseUrl } = await setupSelection(selected);
|
|
|
|
// 3. The model that runs every phase.
|
|
const modelId = await promptModel(provider);
|
|
config.core = { ...config.core, model: `${provider}:${modelId}` };
|
|
if (baseUrl) config.core = { ...config.core, base_url: baseUrl };
|
|
|
|
saveConfig(config);
|
|
|
|
const configPath = path.join(SHANNON_HOME, 'config.toml');
|
|
const summary = [`Provider ${provider}`, `Model ${modelId}`];
|
|
if (baseUrl) summary.push(`Endpoint ${baseUrl}`);
|
|
|
|
p.log.success(`Configuration saved to ${configPath}`);
|
|
p.log.info(summary.join('\n'));
|
|
p.outro('Run `npx @keygraph/shannon start` to begin a scan.');
|
|
}
|
|
|
|
interface Selection {
|
|
provider: string;
|
|
config: ShannonConfig;
|
|
baseUrl?: string;
|
|
}
|
|
|
|
/** Resolve the provider selection into a provider id and its credential config. */
|
|
async function setupSelection(
|
|
selected: CuratedProviderId | typeof CUSTOM_BASE_URL | typeof OTHER_PROVIDER,
|
|
): Promise<Selection> {
|
|
if (selected === CUSTOM_BASE_URL) {
|
|
const gateway = await setupGateway();
|
|
return { provider: gateway.provider, config: gateway.config, baseUrl: gateway.baseUrl };
|
|
}
|
|
if (selected === OTHER_PROVIDER) {
|
|
return setupOtherProvider();
|
|
}
|
|
return { provider: selected, config: await setupProvider(selected) };
|
|
}
|
|
|
|
async function setupProvider(provider: CuratedProviderId): Promise<ShannonConfig> {
|
|
switch (provider) {
|
|
case 'amazon-bedrock':
|
|
return setupBedrock();
|
|
case 'anthropic':
|
|
return setupAnthropic();
|
|
case 'openai':
|
|
return { openai: { api_key: await promptSecret('Enter your OpenAI API key') } };
|
|
case 'xai':
|
|
return { xai: { api_key: await promptSecret('Enter your xAI API key') } };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Any pi provider Shannon does not curate. The id is free text — the worker's
|
|
* preflight validates it — and the key is stored generically as SHANNON_AI_API_KEY.
|
|
* An optional base URL points that provider at a proxy or LLM gateway; left blank, the
|
|
* provider's own endpoint is used.
|
|
*/
|
|
async function setupOtherProvider(): Promise<Selection> {
|
|
p.log.info('Browse supported providers and models at https://pi.dev/models');
|
|
const provider = await p.text({
|
|
message: 'Provider ID',
|
|
validate: (value) => {
|
|
const id = value?.trim();
|
|
if (!id) return 'Provider ID is required';
|
|
if (isCuratedProvider(id)) return `${id} has its own option.`;
|
|
return undefined;
|
|
},
|
|
});
|
|
if (p.isCancel(provider)) return cancelAndExit();
|
|
|
|
const apiKey = await promptSecret('Enter the API key');
|
|
const baseUrl = await promptOptionalBaseUrl();
|
|
|
|
return {
|
|
provider: provider.trim(),
|
|
config: { provider: { api_key: apiKey } },
|
|
...(baseUrl && { baseUrl }),
|
|
};
|
|
}
|
|
|
|
// === Provider Setup Flows ===
|
|
|
|
async function setupAnthropic(): Promise<ShannonConfig> {
|
|
const authMethod = await p.select({
|
|
message: 'Authentication method',
|
|
options: [
|
|
{ value: 'api_key' as const, label: 'API Key' },
|
|
{ value: 'oauth' as const, label: 'OAuth Token' },
|
|
],
|
|
});
|
|
if (p.isCancel(authMethod)) return cancelAndExit();
|
|
|
|
if (authMethod === 'oauth') {
|
|
const token = await promptSecret('Enter your OAuth token');
|
|
return { anthropic: { oauth_token: token } };
|
|
}
|
|
|
|
const apiKey = await promptSecret('Enter your Anthropic API key');
|
|
return { anthropic: { api_key: apiKey } };
|
|
}
|
|
|
|
async function setupBedrock(): Promise<ShannonConfig> {
|
|
const region = await p.text({
|
|
message: 'AWS Region',
|
|
placeholder: 'us-east-1',
|
|
validate: required('AWS Region is required'),
|
|
});
|
|
if (p.isCancel(region)) return cancelAndExit();
|
|
|
|
const token = await promptSecret('Enter your AWS Bearer Token');
|
|
|
|
return { bedrock: { region, token } };
|
|
}
|
|
|
|
interface GatewaySetup {
|
|
provider: CuratedProviderId;
|
|
config: ShannonConfig;
|
|
baseUrl: string;
|
|
}
|
|
|
|
/**
|
|
* Gateway route: the endpoint decides where requests go, but the dialect still
|
|
* picks a real provider, because that is what supplies the credential and the
|
|
* wire protocol.
|
|
*/
|
|
async function setupGateway(): Promise<GatewaySetup> {
|
|
const choice = await p.select({
|
|
message: 'API format',
|
|
options: GATEWAY_DIALECTS.map(({ value, label }) => ({ value, label })),
|
|
});
|
|
if (p.isCancel(choice)) return cancelAndExit();
|
|
|
|
const dialect = GATEWAY_DIALECTS.find((entry) => entry.value === choice);
|
|
if (!dialect) return cancelAndExit();
|
|
const provider = dialect.provider;
|
|
|
|
const baseUrl = await p.text({
|
|
message: 'Endpoint URL',
|
|
placeholder: 'https://llm-gateway.example.com',
|
|
validate: (value) => {
|
|
if (!value) return 'Endpoint URL is required';
|
|
try {
|
|
new URL(value);
|
|
} catch {
|
|
return 'Must be a valid URL';
|
|
}
|
|
return undefined;
|
|
},
|
|
});
|
|
if (p.isCancel(baseUrl)) return cancelAndExit();
|
|
|
|
const authToken = await promptSecret('Enter the auth token for the endpoint');
|
|
const config: ShannonConfig =
|
|
provider === 'anthropic' ? { anthropic: { api_key: authToken } } : { openai: { api_key: authToken } };
|
|
|
|
return { provider, config, baseUrl };
|
|
}
|
|
|
|
// === Model Selection ===
|
|
|
|
/**
|
|
* Ask for the one model that runs every phase. Providers with suggestions offer a
|
|
* pick list with a free-text escape hatch; the rest go straight to free text.
|
|
*/
|
|
async function promptModel(provider: string): Promise<string> {
|
|
const suggestions = isCuratedProvider(provider) ? MODEL_SUGGESTIONS[provider] : [];
|
|
|
|
if (suggestions.length === 0) {
|
|
return promptModelId(provider, modelIdPlaceholder(provider));
|
|
}
|
|
|
|
const choice = await p.select({
|
|
message: 'Model',
|
|
options: [
|
|
...suggestions.map((model) => ({ value: model, label: model })),
|
|
{ value: CUSTOM_MODEL, label: 'Enter a model ID…' },
|
|
],
|
|
});
|
|
if (p.isCancel(choice)) return cancelAndExit();
|
|
|
|
if (choice === CUSTOM_MODEL) {
|
|
return promptModelId(provider, modelIdPlaceholder(provider));
|
|
}
|
|
return choice as string;
|
|
}
|
|
|
|
/**
|
|
* A leading `<provider>:` naming a supported provider other than the selected
|
|
* one. Bedrock model IDs carry their own colons (`…-v1:0`), so only a genuine
|
|
* provider id counts as a prefix.
|
|
*/
|
|
function conflictingProviderPrefix(provider: string, value: string): string | undefined {
|
|
const separator = value.indexOf(':');
|
|
if (separator === -1) return undefined;
|
|
|
|
const head = value.slice(0, separator);
|
|
if (head === provider) return undefined;
|
|
return (CURATED_PROVIDERS as readonly string[]).includes(head) ? head : undefined;
|
|
}
|
|
|
|
/**
|
|
* Ask for a model ID. The provider is already chosen, so this takes the bare ID
|
|
* and the caller pairs it with the provider — pasting a full `<provider>:<model>`
|
|
* spec just has its redundant prefix dropped.
|
|
*/
|
|
async function promptModelId(provider: string, placeholder?: string): Promise<string> {
|
|
const modelId = await p.text({
|
|
message: 'Model ID',
|
|
...(placeholder && { placeholder }),
|
|
validate: (value) => {
|
|
if (!value) return 'Model ID is required';
|
|
const conflicting = conflictingProviderPrefix(provider, value);
|
|
if (conflicting) return `That model ID is for ${conflicting}, but you selected ${provider}.`;
|
|
return undefined;
|
|
},
|
|
});
|
|
if (p.isCancel(modelId)) return cancelAndExit();
|
|
|
|
return modelId.startsWith(`${provider}:`) ? modelId.slice(provider.length + 1) : modelId;
|
|
}
|
|
|
|
// === Helpers ===
|
|
|
|
/**
|
|
* Optional endpoint override. Empty input means the provider's default endpoint;
|
|
* any value must be a valid URL.
|
|
*/
|
|
async function promptOptionalBaseUrl(): Promise<string | undefined> {
|
|
const baseUrl = await p.text({
|
|
message: 'Custom base URL (optional, leave blank for the provider default)',
|
|
placeholder: 'https://llm-gateway.example.com',
|
|
validate: (value) => {
|
|
const trimmed = value?.trim();
|
|
if (!trimmed) return undefined;
|
|
try {
|
|
new URL(trimmed);
|
|
} catch {
|
|
return 'Must be a valid URL';
|
|
}
|
|
return undefined;
|
|
},
|
|
});
|
|
if (p.isCancel(baseUrl)) return cancelAndExit();
|
|
|
|
const trimmed = baseUrl?.trim();
|
|
return trimmed ? trimmed : undefined;
|
|
}
|
|
|
|
async function promptSecret(message: string): Promise<string> {
|
|
const value = await p.password({
|
|
message,
|
|
validate: required(`${message.replace(/^Enter /, '')} is required`),
|
|
});
|
|
if (p.isCancel(value)) return cancelAndExit();
|
|
return value;
|
|
}
|
|
|
|
function required(errorMessage: string): (value: string | undefined) => string | undefined {
|
|
return (value) => {
|
|
if (!value) return errorMessage;
|
|
return undefined;
|
|
};
|
|
}
|
|
|
|
function cancelAndExit(): never {
|
|
p.cancel('Setup cancelled.');
|
|
process.exit(0);
|
|
}
|