Compare commits

...
9 Commits
Author SHA1 Message Date
ezl-keygraphandGitHub d4cc2ab974 feat: support pentests with Codex subscription auth (#419) 2026-08-10 15:27:19 +05:30
ezl-keygraphandGitHub 760a140228 docs: sync llms files and point prerequisites at the any-other-provider section (#416) 2026-08-07 00:46:25 +05:30
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
ezl-keygraphandGitHub 86effd5240 feat(worker): record severity in analysis mode and fix prompt substitutions (#413)
* feat(worker): record severity in analysis mode alongside confidence

* fix(worker): align add_finding severity with the exploit collector's four levels

* refactor(worker): drop the dead REPORT_VULN_HEADING substitution

No prompt in the tree uses the placeholder, so the replacement was a no-op
on every render.

* fix(worker): strip all whitespace from TOTP secrets, not just the ends

* fix(worker): render rule type and value in the agent prompt

* refactor(worker): drop the dead vuln-summary subsection substitution
2026-08-04 21:02:00 +05:30
george-keygraphandGitHub d26f3b668e Merge pull request #407 from KeygraphHQ/george-keygraph-patch-4
Update README.md
2026-07-30 17:46:55 -07:00
george-keygraphandGitHub af8cd12b5f Update README.md 2026-07-30 17:45:33 -07:00
george-keygraphandGitHub b2668afc2a Merge pull request #404 from KeygraphHQ/george-keygraph-patch-2
Update README.md
2026-07-30 14:34:26 -07:00
george-keygraphandGitHub 40660febfa Update README.md 2026-07-30 14:33:33 -07:00
ezl-keygraphandGitHub 5ca456e4e2 docs: sync provider options in bug report and README (#403) 2026-07-30 19:58:27 +05:30
25 changed files with 514 additions and 246 deletions
+22 -10
View File
@@ -3,16 +3,16 @@
# Defaults to anthropic:claude-sonnet-4-6.
# --- Anthropic ---------------------------------------------------------------
ANTHROPIC_API_KEY=your-api-key-here
SHANNON_AI_API_KEY=your-api-key-here
SHANNON_AI_MODEL=anthropic:claude-sonnet-4-6
# CLAUDE_CODE_OAUTH_TOKEN=your-oauth-token-here
# --- OpenAI ------------------------------------------------------------------
# OPENAI_API_KEY=your-api-key-here
# SHANNON_AI_MODEL=openai:gpt-5.6-sol
# SHANNON_AI_API_KEY=your-api-key-here
# SHANNON_AI_MODEL=openai:gpt-5.5
# --- xAI ---------------------------------------------------------------------
# XAI_API_KEY=your-api-key-here
# SHANNON_AI_API_KEY=your-api-key-here
# SHANNON_AI_MODEL=xai:grok-4.5
# --- AWS Bedrock -------------------------------------------------------------
@@ -24,20 +24,32 @@ SHANNON_AI_MODEL=anthropic:claude-sonnet-4-6
# --- Custom Base URL ---------------------------------------------------------
# Route through a proxy or gateway (LiteLLM, an internal endpoint).
# Pick the block matching the API dialect your gateway speaks, and uncomment all
# three lines. The provider prefix picks the dialect and which key is sent; the
# model id is whatever name your gateway serves it under.
# three lines. The provider prefix picks the dialect; the model id is whatever
# name your gateway serves it under.
# Anthropic compatible - Anthropic Messages:
# ANTHROPIC_API_KEY=your-gateway-key-here
# SHANNON_AI_API_KEY=your-gateway-key-here
# SHANNON_AI_BASE_URL=https://llm-gateway.example.com
# SHANNON_AI_MODEL=anthropic:claude-sonnet-4-6
# OpenAI compatible - Chat Completions (default) or Responses:
# OPENAI_API_KEY=your-gateway-key-here
# SHANNON_AI_API_KEY=your-gateway-key-here
# SHANNON_AI_BASE_URL=https://llm-gateway.example.com/v1
# SHANNON_AI_MODEL=openai:gpt-5.6-sol
# SHANNON_AI_MODEL=openai:gpt-5.5
# SHANNON_AI_OPENAI_FORMAT=responses
# --- Other -------------------------------------------------------------------
# --- Other provider ----------------------------------------------------------
# Any other provider the Pi harness supports. Name it in SHANNON_AI_MODEL and
# supply the key via the generic SHANNON_AI_API_KEY. Pi validates the provider
# and model at preflight.
# SHANNON_AI_MODEL=openrouter:moonshotai/kimi-k3
# SHANNON_AI_API_KEY=your-api-key-here
# --- Misc --------------------------------------------------------------------
# Forward /etc/hosts entries into the worker container.
# SHANNON_FORWARD_HOSTS=false
# See the guide below to use an OpenAI subscription
# https://github.com/KeygraphHQ/shannon/blob/main/docs/ai-providers.md#openai-codex-chatgpt-pluspro-subscription
# SHANNON_USE_PI_AUTH=1
# SHANNON_AI_MODEL=openai-codex:gpt-5.5
+5 -1
View File
@@ -117,8 +117,12 @@ body:
options:
- "Anthropic (API key)"
- "Anthropic (OAuth token)"
- "Custom base URL (proxy/gateway)"
- "OpenAI"
- "xAI"
- "AWS Bedrock"
- "Custom base URL - Anthropic Messages"
- "Custom base URL - OpenAI Chat Completions"
- "Custom base URL - OpenAI Responses"
validations:
required: true
+2 -1
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -107,12 +107,12 @@ RUN ln -s /app/apps/worker/dist/scripts/save-deliverable.js /usr/local/bin/save-
# Create directories for session data and ensure proper permissions
RUN mkdir -p /app/sessions /app/repos /app/workspaces && \
mkdir -p /tmp/.cache /tmp/.config /tmp/.npm && \
mkdir -p /tmp/.cache /tmp/.config /tmp/.npm /tmp/.pi/agent && \
chmod 777 /app && \
chmod 777 /tmp/.cache && \
chmod 777 /tmp/.config && \
chmod 777 /tmp/.npm && \
chown -R pentest:pentest /app /tmp/.claude
chown -R pentest:pentest /app /tmp/.claude /tmp/.pi
COPY entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh
+13 -7
View File
@@ -1,3 +1,6 @@
> [!NOTE]
> **[Shannon 2.0 is officially here](https://github.com/KeygraphHQ/shannon/discussions/405)**
<div align="center">
<img src="./assets/github-banner.png" alt="Shannon - AI Pentester by Keygraph" width="100%">
@@ -6,7 +9,7 @@
<a href="https://trendshift.io/repositories/15604" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15604" alt="KeygraphHQ%2Fshannon | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
Shannon is an autonomous, white-box AI pentester for web applications and APIs. <br />
Shannon is an autonomous, AI pentester for web applications and APIs. <br />
It analyzes your source code, identifies attack paths, and executes real exploits to prove vulnerabilities before they reach production.
**This repository is Shannon Open Source: the full agent, run locally from your command line.**
@@ -32,13 +35,13 @@ It analyzes your source code, identifies attack paths, and executes real exploit
- [Architecture](#architecture)
- [Documentation](#documentation)
- [Safety, Scope, and Limitations](#safety-scope-and-limitations)
- [License and Enterprise Licensing](#license-and-enterprise-licensing)
- [License](#license)
- [About Keygraph](#about-keygraph)
- [Community and Support](#community-and-support)
## What is Shannon?
Shannon is an autonomous AI pentester developed by [Keygraph](https://keygraph.io). It performs white-box security testing of web applications and their underlying APIs by combining source-code analysis with live exploitation.
Shannon is an autonomous AI pentester developed by [Keygraph](https://keygraph.io). It performs security testing of web applications and their underlying APIs by combining source-code analysis with live exploitation.
Shannon analyzes your web application's source code to identify potential attack vectors, then uses browser automation and command-line tools to execute real exploits against the running application and its APIs. Only vulnerabilities with a working proof-of-concept are included in the final report.
@@ -70,7 +73,7 @@ Sample penetration test reports from intentionally vulnerable applications, prod
- **Docker**: required for the worker container.
- **Node.js 18+**: required for the recommended `npx` workflow.
- **AI provider credentials**: Anthropic, OpenAI, xAI, or AWS Bedrock. Claude models are recommended. Gateway and proxy setups are documented separately.
- **AI provider credentials**: Anthropic, OpenAI, xAI, or AWS Bedrock - or [any other provider](docs/ai-providers.md#any-other-provider). Claude models are recommended. For suggested model IDs per provider, plus gateways and custom base URLs, see [AI providers](docs/ai-providers.md#suggested-models).
- **Cyber safeguards cleared with your provider**: Anthropic and OpenAI apply real-time safeguards to cyber-security workloads, which can interrupt a scan mid-run. Complete their guidance for legitimate security testers before your first run - see [AI providers](docs/ai-providers.md#cyber-safeguards-do-this-before-your-first-scan).
### Run Shannon
@@ -91,7 +94,10 @@ Shannon pulls the worker image from Docker Hub, starts the required local infras
For source builds, authenticated scans, provider-specific setup, and platform notes, see [Documentation](#documentation).
> [!TIP]
> **Prefer to run on your Claude Code subscription instead of API credits?** The [`shannon-v1`](https://github.com/KeygraphHQ/shannon/tree/shannon-v1) branch is the last release built on the Claude Agent SDK, so it accepts a Claude Code OAuth token. Generate one with `claude setup-token`, then run `npx @keygraph/shannon@1.9.0 setup` and pick **OAuth Token**. Pentests then cost nothing beyond your existing subscription.
> **Prefer to use a subscription instead of API credits?**
>
> - **OpenAI Codex:** The latest version of Shannon supports ChatGPT Plus and Pro subscriptions. Follow the [OpenAI Codex subscription setup guide](docs/ai-providers.md#openai-codex-chatgpt-pluspro-subscription) to get started.
> - **Claude Code:** The latest version of Shannon does not support Claude Code subscriptions. Follow the [Claude Code subscription setup guide](docs/ai-providers.md#claude-code-subscription) to use version `1.9.0`, which is the final release built on the Claude Agent SDK.
## Key Capabilities
@@ -187,7 +193,7 @@ Use these guides for operational detail:
| --- | --- |
| [Source build and CLI commands](docs/development.md) | Cloning, building, common commands, output paths, and local development. |
| [Configuration](docs/configuration.md) | Authenticated testing, login flows, rules of engagement, and report filters. |
| [AI providers](docs/ai-providers.md) | Selecting the model, the supported providers (Anthropic, OpenAI, xAI, AWS Bedrock), and custom gateways. |
| [AI providers](docs/ai-providers.md) | Selecting the model, the supported providers (Anthropic, OpenAI, xAI, AWS Bedrock, and any other Pi-supported provider), and custom gateways. |
| [Platforms and networking](docs/platforms.md) | Windows/WSL2, Linux, macOS, Docker networking, local apps, and custom hostnames. |
| [Workspaces and resuming](docs/workspaces.md) | Naming workspaces, resuming interrupted scans, and workspace storage. |
| [Safety and limitations](docs/safety.md) | Authorized-use requirements, non-production guidance, mutative effects, cost, and model caveats. |
@@ -210,7 +216,7 @@ Important limitations:
Read the full [Safety and limitations](docs/safety.md) guide before running Shannon in a new environment.
## License and Enterprise Licensing
## License
Shannon Open Source is licensed under the [GNU Affero General Public License v3.0](LICENSE).
+70 -19
View File
@@ -10,13 +10,14 @@ 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 { type OpenAiFormat, type ProviderId, SUPPORTED_PROVIDERS } from '../model-spec.js';
import { CURATED_PROVIDERS, type CuratedProviderId, isCuratedProvider, type OpenAiFormat } from '../model-spec.js';
import { requireInteractive } from '../tty.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__';
/**
* Wire formats reachable through the gateway route. The format picks the provider
@@ -39,28 +40,34 @@ const GATEWAY_DIALECTS: readonly {
{ value: 'openai-responses', label: 'OpenAI Responses', provider: 'openai', format: 'responses' },
];
/** Suggested models per provider, best-first. Free-text entry accepts any model in the provider's catalogue. */
const MODEL_SUGGESTIONS: Readonly<Record<ProviderId, readonly string[]>> = {
/** 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-4-6', 'claude-opus-4-8', 'claude-opus-4-7', 'claude-haiku-4-5-20251001'],
openai: ['gpt-5.6-sol', 'gpt-5.5', 'gpt-5.4'],
xai: ['grok-4.5'],
'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. */
const MODEL_ID_PLACEHOLDER: Readonly<Record<ProviderId, string>> = {
/** 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-5.6-sol',
xai: 'grok-4.5',
'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).');
p.intro('Shannon 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.
// 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: [
@@ -69,14 +76,17 @@ export async function setup(): Promise<void> {
{ 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: 'your own proxy or 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, on the gateway route, the endpoint and its dialect.
const gateway = selected === CUSTOM_BASE_URL ? await setupGateway() : undefined;
const provider = gateway?.provider ?? (selected as ProviderId);
const config = gateway?.config ?? (await setupProvider(provider));
const { provider, config, gateway } = await setupSelection(selected);
// 3. The model that runs every phase.
const modelId = await promptModel(provider);
@@ -95,7 +105,27 @@ export async function setup(): Promise<void> {
p.outro('Run `npx @keygraph/shannon start` to begin a scan.');
}
async function setupProvider(provider: ProviderId): Promise<ShannonConfig> {
interface Selection {
provider: string;
config: ShannonConfig;
gateway?: GatewaySetup;
}
/** 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, gateway };
}
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();
@@ -108,6 +138,27 @@ async function setupProvider(provider: ProviderId): Promise<ShannonConfig> {
}
}
/**
* 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.
*/
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');
return { provider: provider.trim(), config: { provider: { api_key: apiKey } } };
}
// === Provider Setup Flows ===
async function setupAnthropic(): Promise<ShannonConfig> {
@@ -143,7 +194,7 @@ async function setupBedrock(): Promise<ShannonConfig> {
}
interface GatewaySetup {
provider: ProviderId;
provider: CuratedProviderId;
config: ShannonConfig;
baseUrl: string;
format?: OpenAiFormat;
@@ -195,11 +246,11 @@ async function setupGateway(): Promise<GatewaySetup> {
* 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: ProviderId): Promise<string> {
const suggestions = MODEL_SUGGESTIONS[provider];
async function promptModel(provider: string): Promise<string> {
const suggestions = isCuratedProvider(provider) ? MODEL_SUGGESTIONS[provider] : [];
if (suggestions.length === 0) {
return promptModelId(provider, MODEL_ID_PLACEHOLDER[provider]);
return promptModelId(provider, modelIdPlaceholder(provider));
}
const choice = await p.select({
@@ -212,7 +263,7 @@ async function promptModel(provider: ProviderId): Promise<string> {
if (p.isCancel(choice)) return cancelAndExit();
if (choice === CUSTOM_MODEL) {
return promptModelId(provider, MODEL_ID_PLACEHOLDER[provider]);
return promptModelId(provider, modelIdPlaceholder(provider));
}
return choice as string;
}
@@ -222,13 +273,13 @@ async function promptModel(provider: ProviderId): Promise<string> {
* one. Bedrock model IDs carry their own colons (`…-v1:0`), so only a genuine
* provider id counts as a prefix.
*/
function conflictingProviderPrefix(provider: ProviderId, value: string): string | undefined {
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 (SUPPORTED_PROVIDERS as readonly string[]).includes(head) ? head : undefined;
return (CURATED_PROVIDERS as readonly string[]).includes(head) ? head : undefined;
}
/**
@@ -236,10 +287,10 @@ function conflictingProviderPrefix(provider: ProviderId, value: string): string
* and the caller pairs it with the provider — pasting a full `<provider>:<model>`
* spec just has its redundant prefix dropped.
*/
async function promptModelId(provider: ProviderId, placeholder: string): Promise<string> {
async function promptModelId(provider: string, placeholder?: string): Promise<string> {
const modelId = await p.text({
message: 'Model ID',
placeholder,
...(placeholder && { placeholder }),
validate: (value) => {
if (!value) return 'Model ID is required';
const conflicting = conflictingProviderPrefix(provider, value);
+2 -1
View File
@@ -9,7 +9,7 @@ import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { ensureImage, ensureInfra, randomSuffix, spawnWorker } from '../docker.js';
import { buildEnvFlags, loadEnv, validateCredentials } from '../env.js';
import { buildEnvFlags, loadEnv, resolveHostPiAuthPath, shouldUsePiAuth, validateCredentials } from '../env.js';
import { getWorkspacesDir, initHome } from '../home.js';
import { isLocal } from '../mode.js';
import { resolveModelSpec } from '../model-spec.js';
@@ -135,6 +135,7 @@ export async function start(args: StartArgs): Promise<void> {
workspace,
...(args.pipelineTesting && { pipelineTesting: true }),
...(args.debug && { debug: true }),
...(shouldUsePiAuth() && { piAuthHostPath: resolveHostPiAuthPath() }),
});
// 14. Bail if `docker run -d` itself fails (mount error, image missing, etc.)
+26 -5
View File
@@ -9,7 +9,13 @@ import fs from 'node:fs';
import { parse as parseTOML } from 'smol-toml';
import { getConfigFile } from '../home.js';
import { getMode } from '../mode.js';
import { DEFAULT_MODEL_SPEC, type ProviderId, parseModelSpec } from '../model-spec.js';
import {
type CuratedProviderId,
DEFAULT_MODEL_SPEC,
GENERIC_API_KEY_ENV,
isCuratedProvider,
parseModelSpec,
} from '../model-spec.js';
// === TOML ↔ Env Mapping ===
@@ -42,16 +48,22 @@ const CONFIG_MAP: readonly ConfigMapping[] = [
// Bedrock
{ env: 'AWS_REGION', toml: 'bedrock.region', type: 'string' },
{ env: 'AWS_BEARER_TOKEN_BEDROCK', toml: 'bedrock.token', type: 'string' },
// Generic — credential for any provider Shannon does not curate
{ env: GENERIC_API_KEY_ENV, toml: 'provider.api_key', type: 'string' },
] as const;
/** TOML section holding each provider's credentials, keyed by provider id. */
const PROVIDER_SECTIONS: Readonly<Record<ProviderId, string>> = {
/** TOML section holding each curated provider's credentials, keyed by provider id. */
const PROVIDER_SECTIONS: Readonly<Record<CuratedProviderId, string>> = {
anthropic: 'anthropic',
openai: 'openai',
xai: 'xai',
'amazon-bedrock': 'bedrock',
};
/** TOML section holding the generic credential for uncurated providers. */
const GENERIC_PROVIDER_SECTION = 'provider';
// === TOML Parsing ===
type TOMLValue = string | number | boolean;
@@ -128,9 +140,18 @@ function buildSchema(): Map<string, Map<string, TOMLType>> {
/**
* Check that the section backing the selected provider carries a usable
* credential. `core.model` names the provider, so only that section is required;
* other providers' sections are ignored and never forwarded.
* other providers' sections are ignored and never forwarded. An uncurated
* provider draws its credential from the generic [provider] section.
*/
function validateProviderFields(config: TOMLConfig, providerId: ProviderId, errors: string[]): void {
function validateProviderFields(config: TOMLConfig, providerId: string, errors: string[]): void {
if (!isCuratedProvider(providerId)) {
const section = config[GENERIC_PROVIDER_SECTION] as Record<string, unknown> | undefined;
if (!section || !Object.keys(section).includes('api_key')) {
errors.push(`[${GENERIC_PROVIDER_SECTION}] requires api_key for provider "${providerId}"`);
}
return;
}
const sectionName = PROVIDER_SECTIONS[providerId];
const section = config[sectionName] as Record<string, unknown> | undefined;
const keys = section ? Object.keys(section) : [];
+2
View File
@@ -13,6 +13,8 @@ export interface ShannonConfig {
openai?: { api_key?: string; format?: string };
xai?: { api_key?: string };
bedrock?: { region?: string; token?: string };
/** Generic credential for any provider Shannon does not curate. Maps to SHANNON_AI_API_KEY. */
provider?: { api_key?: string };
}
// === File Operations ===
+8 -1
View File
@@ -12,6 +12,7 @@ import os from 'node:os';
import path from 'node:path';
import { setTimeout as sleep } from 'node:timers/promises';
import { fileURLToPath } from 'node:url';
import { envBool, PI_AUTH_CONTAINER_PATH } from './env.js';
import { getMode, isDevMode } from './mode.js';
import { INTERNAL_DIR } from './paths.js';
@@ -203,7 +204,7 @@ function shouldSkipHostsName(name: string, hostname: string): boolean {
* `host-gateway` so they target the host's loopback instead of the container's.
*/
function forwardEtcHostsFlags(): string[] {
if (process.env.SHANNON_FORWARD_HOSTS === 'false') return [];
if (!envBool('SHANNON_FORWARD_HOSTS', true)) return [];
if (os.platform() === 'win32') return [];
let content: string;
@@ -255,6 +256,7 @@ export interface WorkerOptions {
workspace: string;
pipelineTesting?: boolean;
debug?: boolean;
piAuthHostPath?: string;
}
/**
@@ -305,6 +307,11 @@ export function spawnWorker(opts: WorkerOptions): ChildProcess {
args.push('-v', `${opts.outputDir}:/app/output`);
}
// Reuse the host's pi credentials: mount only the auth file, allowing token refreshes to persist.
if (opts.piAuthHostPath) {
args.push('-v', `${opts.piAuthHostPath}:${PI_AUTH_CONTAINER_PATH}`);
}
// Environment
args.push(...opts.envFlags);
+76 -19
View File
@@ -5,32 +5,73 @@
* 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,
type ProviderId,
resolveModelSpec,
SUPPORTED_PROVIDERS,
} 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'] as const;
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.
* 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: ProviderId): readonly string[] {
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.
@@ -70,22 +111,23 @@ interface CredentialValidation {
error?: string;
}
/**
* Whether the selected provider has a usable credential in the environment. Any
* one API key satisfies a key-based provider; Bedrock instead needs every one of
* its AWS_ vars.
*/
function hasCredential(providerId: ProviderId): boolean {
/** 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.length > 0 && !apiKeys.some((name) => Boolean(process.env[name]))) {
return false;
}
if (!apiKeys.some((name) => Boolean(process.env[name]))) return false;
return PROVIDER_EXTRA_ENV[providerId].every((name) => Boolean(process.env[name]));
}
/** Every provider that currently has a complete credential in the environment. */
function configuredProviders(): ProviderId[] {
return SUPPORTED_PROVIDERS.filter((providerId) => hasCredential(providerId));
/** 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));
}
/**
@@ -93,17 +135,32 @@ function configuredProviders(): ProviderId[] {
* Runs before any Docker work so mistakes fail immediately.
*/
export function validateCredentials(): CredentialValidation {
// 1. Model selection must parse and name a supported provider
// 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 ${PROVIDER_CREDENTIAL_HINT[spec.providerId]} in .env or export it.`
? `Set ${requirement} in .env or export it.`
: `Export the variables or run 'npx @keygraph/shannon setup'.`;
return {
valid: false,
+23 -18
View File
@@ -6,24 +6,35 @@
* rule are duplicated here deliberately and must stay in sync.
*/
/** Providers Shannon can currently reach. Each is a pi-ai provider id. */
export const SUPPORTED_PROVIDERS = ['anthropic', 'openai', 'xai', 'amazon-bedrock'] as const;
/**
* Providers Shannon curates with their own credential variables, config sections,
* and setup flows. Any other pi provider is reachable via the generic credential
* path. Mirrors CURATED_PROVIDERS in apps/worker/src/ai/models.ts.
*/
export const CURATED_PROVIDERS = ['anthropic', 'openai', 'xai', 'amazon-bedrock'] as const;
export type ProviderId = (typeof SUPPORTED_PROVIDERS)[number];
export type CuratedProviderId = (typeof CURATED_PROVIDERS)[number];
export function isCuratedProvider(value: string): value is CuratedProviderId {
return (CURATED_PROVIDERS as readonly string[]).includes(value);
}
/** Generic API key, honored for any provider Shannon does not curate. Mirrors the worker. */
export const GENERIC_API_KEY_ENV = 'SHANNON_AI_API_KEY';
/**
* Env vars carrying each provider's API key, in precedence order. Any one of them
* satisfies the provider. Mirrors PROVIDER_API_KEY_ENV in apps/worker/src/ai/models.ts.
* Env vars carrying each curated provider's API key, in precedence order. Any one of
* them satisfies the provider. Mirrors PROVIDER_API_KEY_ENV in apps/worker/src/ai/models.ts.
*/
export const PROVIDER_API_KEY_ENV: Readonly<Record<ProviderId, readonly string[]>> = {
export const PROVIDER_API_KEY_ENV: Readonly<Record<CuratedProviderId, readonly string[]>> = {
anthropic: ['ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN'],
openai: ['OPENAI_API_KEY'],
xai: ['XAI_API_KEY'],
'amazon-bedrock': ['AWS_BEARER_TOKEN_BEDROCK'],
};
/** Additional env vars a provider requires beyond its API key. All must be set. */
export const PROVIDER_EXTRA_ENV: Readonly<Record<ProviderId, readonly string[]>> = {
/** Additional env vars a curated provider requires beyond its API key. All must be set. */
export const PROVIDER_EXTRA_ENV: Readonly<Record<CuratedProviderId, readonly string[]>> = {
anthropic: [],
openai: [],
xai: [],
@@ -31,7 +42,7 @@ export const PROVIDER_EXTRA_ENV: Readonly<Record<ProviderId, readonly string[]>>
};
/** Human-readable credential requirement, used in "nothing configured" errors. */
export const PROVIDER_CREDENTIAL_HINT: Readonly<Record<ProviderId, string>> = {
export 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',
@@ -51,18 +62,15 @@ export const OPENAI_FORMATS = ['chat-completions', 'responses'] as const;
export type OpenAiFormat = (typeof OPENAI_FORMATS)[number];
export interface ModelSpec {
providerId: ProviderId;
providerId: string;
modelId: string;
}
function isSupportedProvider(value: string): value is ProviderId {
return (SUPPORTED_PROVIDERS as readonly string[]).includes(value);
}
/**
* Parse a `<provider>:<model-id>` spec. Splits on the first colon only, so colons
* inside a model ID survive (`amazon-bedrock:us.anthropic.claude-opus-4-5-20251101-v1:0`).
* Returns an error string rather than throwing, for the CLI's validation flow.
* The provider id is passed through as given the worker's preflight validates it
* against pi. Returns an error string rather than throwing, for the CLI's flow.
*/
export function parseModelSpec(spec: string): ModelSpec | string {
const trimmed = spec.trim();
@@ -74,9 +82,6 @@ export function parseModelSpec(spec: string): ModelSpec | string {
const modelId = trimmed.slice(separator + 1).trim();
if (!providerId || !modelId) return malformed;
if (!isSupportedProvider(providerId)) {
return `Unsupported provider "${providerId}" in SHANNON_AI_MODEL. Supported providers: ${SUPPORTED_PROVIDERS.join(', ')}`;
}
return { providerId, modelId };
}
+1 -2
View File
@@ -206,7 +206,6 @@
"properties": {
"description": {
"type": "string",
"minLength": 1,
"maxLength": 200,
"description": "Human-readable description of the rule"
},
@@ -222,7 +221,7 @@
"description": "Value to match"
}
},
"required": ["description", "type", "value"],
"required": ["type", "value"],
"additionalProperties": false
}
}
+5 -2
View File
@@ -89,7 +89,7 @@ Fields:
- `executive_summary`: 2-3 sentences summarizing the security posture for technical leadership (CTOs, CISOs, Engineering VPs). Must include the target URL and assessment date. Provide a high-level characterization based on the findings — severity distribution, most critical issues, and overall risk demonstrated by exploitation. If no vulnerabilities were confirmed in the assessed classes, state that scope clearly. A clean report is valid only when no <not_assessed_classes> block is present. If that block is present, explicitly say the listed classes were not assessed and do not assert they are free of vulnerabilities.
</exploit_mode_summary>
<analysis_mode_summary>
- `executive_summary`: 2-3 sentences summarizing the security posture for technical leadership (CTOs, CISOs, Engineering VPs). Must include the target URL and assessment date. Provide a high-level characterization based on the findings — confidence distribution, the most serious weaknesses identified, and overall risk. State plainly that this was an analysis-only assessment and that no finding was confirmed by exploitation; do not describe risk as demonstrated or proven. Findings carry no severity rating in this mode, so do not assert one. If no vulnerabilities were identified in the assessed classes, state that scope clearly. A clean report is valid only when no <not_assessed_classes> block is present. If that block is present, explicitly say the listed classes were not assessed and do not assert they are free of vulnerabilities.
- `executive_summary`: 2-3 sentences summarizing the security posture for technical leadership (CTOs, CISOs, Engineering VPs). Must include the target URL and assessment date. Provide a high-level characterization based on the findings — severity and confidence distribution, the most serious weaknesses identified, and overall risk. State plainly that this was an analysis-only assessment and that no finding was confirmed by exploitation; do not describe risk as demonstrated or proven, and present severity as assessed rather than measured. If no vulnerabilities were identified in the assessed classes, state that scope clearly. A clean report is valid only when no <not_assessed_classes> block is present. If that block is present, explicitly say the listed classes were not assessed and do not assert they are free of vulnerabilities.
</analysis_mode_summary>
</record_report_meta>
@@ -112,6 +112,7 @@ Map the finding's content from the per-class deliverable sections to `add_findin
</exploit_mode_fields>
<analysis_mode_fields>
- `confidence`: From the finding's "Confidence" field. Use as-is; do not reassess.
- `severity`: The analysis deliverables carry no severity field — no exploit ran to measure impact. Assess it from the vulnerability class and the impact you describe. It is an assessed rating, not a measured one.
</analysis_mode_fields>
- `owasp_category`: Map to the appropriate OWASP Top 10 (2025) category:
- `"A01:2025 — Broken Access Control"`
@@ -139,7 +140,7 @@ Map the finding's content from the per-class deliverable sections to `add_findin
<analysis_mode_fields>
- `impact`: What an attacker could achieve if this vulnerability were exploited. Derive it from the finding's "Impact" and "Overview" fields. Write it as assessed, never as achieved.
This run had no exploitation phase. Nothing was executed against the target, nothing was demonstrated, and no exploit evidence exists. Accordingly `severity`, `auth_state`, `prerequisites`, `exploitation_steps`, `proof_of_impact` and `status` are **not** part of your tool schema — the deliverables contain no source for any of them. `confidence` is the only rating this run produces; take it straight from the deliverable. Do not compensate for the missing fields by describing attack execution in `overview`, `impact` or `notes`. Report the weakness and how to fix it; that is the whole deliverable for this run.
This run had no exploitation phase. Nothing was executed against the target, nothing was demonstrated, and no exploit evidence exists. Accordingly `auth_state`, `prerequisites`, `exploitation_steps`, `proof_of_impact` and `status` are **not** part of your tool schema — the deliverables contain no source for any of them. `confidence` is the deliverable's own rating and carries over verbatim; `severity` is yours to assess, since nothing measured it. Do not compensate for the missing fields by describing attack execution in `overview`, `impact` or `notes`. Report the weakness and how to fix it; that is the whole deliverable for this run.
</analysis_mode_fields>
**Optional fields:**
@@ -160,6 +161,7 @@ If no valid findings exist after filtering, do not call `add_finding` at all. Th
- **No Fabrications:** Every piece of data must come from the deliverable files. If a finding has incomplete data, include it but note the gap in `overview`.
- **Nothing Was Demonstrated:** No exploit ran. Do not write that a vulnerability was confirmed, proven, exploited, or verified against the running target, and do not describe payloads, requests, or responses as having been sent.
- **No Confidence Changes:** Use the confidence from the deliverable as-is. Do not raise or lower it.
- **Severity Is Assessed:** Rate severity from the vulnerability class and the impact you describe. Never present it as measured or demonstrated.
</analysis_mode_constraints>
- **No Speculation:** Only record findings that appear in the deliverables with valid vulnerability IDs. Do not add your own assessments.
- **OWASP 2025:** Map all findings to OWASP Top 10 (2025) categories.
@@ -187,6 +189,7 @@ Before finalizing, verify:
</exploit_mode_checks>
<analysis_mode_checks>
- [ ] Does every finding have `confidence` carried over unchanged from the deliverable?
- [ ] Is every `severity` assessed from the impact I described, with no claim that it was measured?
- [ ] Is every `impact` phrased as assessed rather than demonstrated, with no claim that anything was executed?
</analysis_mode_checks>
- [ ] Are remediation recommendations specific and actionable (not generic)?
+66 -32
View File
@@ -21,21 +21,34 @@
* built over an in-memory credential store primed from the environment.
*/
import { existsSync } from 'node:fs';
import path from 'node:path';
import type { Api, Credential, CredentialInfo, CredentialStore, Model } from '@earendil-works/pi-ai';
import { ModelRuntime } from '@earendil-works/pi-coding-agent';
/** Providers Shannon can currently reach. Each is a pi-ai provider id. */
export const SUPPORTED_PROVIDERS = ['anthropic', 'openai', 'xai', 'amazon-bedrock'] as const;
export type ProviderId = (typeof SUPPORTED_PROVIDERS)[number];
import { getAgentDir, ModelRuntime } from '@earendil-works/pi-coding-agent';
/**
* Env vars carrying each provider's API key, in precedence order. Shannon does not
* invent credential names these are the variables each provider's own tooling
* uses. Bedrock pairs its bearer token with AWS_REGION, which is provider config
* rather than a credential.
* Providers Shannon curates with their own credential variables, config sections,
* and setup flows. Each is a pi-ai provider id; any other pi provider is still
* reachable through the generic credential path below.
*/
export const PROVIDER_API_KEY_ENV: Readonly<Record<ProviderId, readonly string[]>> = {
export const CURATED_PROVIDERS = ['anthropic', 'openai', 'xai', 'amazon-bedrock'] as const;
export type CuratedProviderId = (typeof CURATED_PROVIDERS)[number];
function isCuratedProvider(value: string): value is CuratedProviderId {
return (CURATED_PROVIDERS as readonly string[]).includes(value);
}
/** Generic API key, honored for any provider Shannon does not curate. */
export const GENERIC_API_KEY_ENV = 'SHANNON_AI_API_KEY';
/**
* Env vars carrying each curated provider's API key, in precedence order. Shannon
* does not invent credential names these are the variables each provider's own
* tooling uses. Bedrock pairs its bearer token with AWS_REGION, which is provider
* config rather than a credential.
*/
export const PROVIDER_API_KEY_ENV: Readonly<Record<CuratedProviderId, readonly string[]>> = {
anthropic: ['ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN'],
openai: ['OPENAI_API_KEY'],
xai: ['XAI_API_KEY'],
@@ -45,6 +58,9 @@ export const PROVIDER_API_KEY_ENV: Readonly<Record<ProviderId, readonly string[]
/** Model used when SHANNON_AI_MODEL is unset. */
export const DEFAULT_MODEL_SPEC = 'anthropic:claude-sonnet-4-6';
/** Browsable pi model catalogue — the source of valid `<provider>:<model-id>` ids. */
export const PI_CATALOG_URL = 'https://pi.dev/models';
/**
* Wire formats an OpenAI-compatible gateway may serve, named by
* SHANNON_AI_OPENAI_FORMAT. Only `openai` offers a choice: every other supported
@@ -82,18 +98,14 @@ export function resolveOpenAiFormat(): OpenAiFormat | undefined {
}
export interface ModelSpec {
providerId: ProviderId;
providerId: string;
modelId: string;
}
function isSupportedProvider(value: string): value is ProviderId {
return (SUPPORTED_PROVIDERS as readonly string[]).includes(value);
}
/**
* Parse a `<provider>:<model-id>` spec. Splits on the first colon only, so
* colons inside a model ID survive. Throws with the supported provider list on
* a malformed or unknown provider.
* Parse a `<provider>:<model-id>` spec. Splits on the first colon only, so colons
* inside a model ID survive. The provider id is passed through as given pi's
* registry validates it later so this throws only on a malformed spec.
*/
export function parseModelSpec(spec: string): ModelSpec {
const trimmed = spec.trim();
@@ -112,11 +124,6 @@ export function parseModelSpec(spec: string): ModelSpec {
`SHANNON_AI_MODEL must be "<provider>:<model-id>", got "${trimmed}". Example: ${DEFAULT_MODEL_SPEC}`,
);
}
if (!isSupportedProvider(providerId)) {
throw new Error(
`Unsupported provider "${providerId}" in SHANNON_AI_MODEL. Supported providers: ${SUPPORTED_PROVIDERS.join(', ')}`,
);
}
return { providerId, modelId };
}
@@ -133,17 +140,25 @@ export interface ProviderCredentials {
apiKey?: string;
}
/** Collect the API key and optional endpoint override for a provider. */
export function resolveProviderCredentials(providerId: ProviderId): ProviderCredentials {
/**
* Collect the API key and optional endpoint override for a provider. A curated
* provider's own variables win, then the generic SHANNON_AI_API_KEY. Bedrock is
* excluded it authenticates through its AWS_ variables, which pi reads directly.
*/
export function resolveProviderCredentials(providerId: string): ProviderCredentials {
const credentials: ProviderCredentials = {};
for (const name of PROVIDER_API_KEY_ENV[providerId]) {
const namedVars = isCuratedProvider(providerId) ? PROVIDER_API_KEY_ENV[providerId] : [];
for (const name of namedVars) {
const value = process.env[name];
if (value) {
credentials.apiKey = value;
break;
}
}
if (!credentials.apiKey && providerId !== 'amazon-bedrock' && process.env[GENERIC_API_KEY_ENV]) {
credentials.apiKey = process.env[GENERIC_API_KEY_ENV];
}
if (process.env.SHANNON_AI_BASE_URL) credentials.baseUrl = process.env.SHANNON_AI_BASE_URL;
return credentials;
@@ -190,12 +205,29 @@ class RuntimeCredentialStore implements CredentialStore {
}
}
/** The file pi reads credentials from: the agent dir's auth.json. */
function piAuthPath(): string {
return path.join(getAgentDir(), 'auth.json');
}
/** Whether the host's pi credentials are mounted (auth.json present in the agent dir). */
export function piAuthPresent(): boolean {
return existsSync(piAuthPath());
}
/**
* Build a ModelRuntime whose only credential is the one supplied. Model catalogs
* stay offline (`allowModelNetwork` defaults to false) so a scan never blocks on
* a catalog refresh.
*
* When the host's pi auth.json is present, the runtime reads it instead: pi's
* disk-backed store resolves the credential. The mount is writable so OAuth
* refreshes persist to the host for subsequent runs.
*/
export async function createModelRuntime(providerId: string, apiKey: string | undefined): Promise<ModelRuntime> {
if (piAuthPresent()) {
return ModelRuntime.create({ authPath: piAuthPath() });
}
return ModelRuntime.create({ credentials: new RuntimeCredentialStore(providerId, apiKey) });
}
@@ -203,7 +235,7 @@ export interface ModelSelection {
model: Model<Api>;
modelRuntime: ModelRuntime;
modelId: string;
providerId: ProviderId;
providerId: string;
}
/**
@@ -218,7 +250,7 @@ export interface ModelSelection {
* then describes the format in use. Every other provider has one API and only
* changes address.
*/
function pointAtGateway(model: Model<Api>, providerId: ProviderId, baseUrl: string, format: OpenAiFormat): Model<Api> {
function pointAtGateway(model: Model<Api>, providerId: string, baseUrl: string, format: OpenAiFormat): Model<Api> {
if (providerId !== 'openai') return { ...model, baseUrl };
if (format === 'responses') return { ...model, baseUrl, api: OPENAI_FORMATS.responses };
@@ -240,7 +272,7 @@ function pointAtGateway(model: Model<Api>, providerId: ProviderId, baseUrl: stri
*/
export function resolveModel(
modelRuntime: ModelRuntime,
providerId: ProviderId,
providerId: string,
modelId: string,
baseUrl: string | undefined,
format: OpenAiFormat = DEFAULT_OPENAI_FORMAT,
@@ -265,7 +297,7 @@ export function resolveModel(
* are configured, so it is rejected outside that combination rather than
* silently ignored.
*/
export function resolveGatewayFormat(providerId: ProviderId, baseUrl: string | undefined): OpenAiFormat {
export function resolveGatewayFormat(providerId: string, baseUrl: string | undefined): OpenAiFormat {
const configured = resolveOpenAiFormat();
if (!configured) return DEFAULT_OPENAI_FORMAT;
@@ -296,7 +328,9 @@ export async function resolveModelSelection(): Promise<ModelSelection> {
const model = resolveModel(modelRuntime, providerId, modelId, credentials.baseUrl, format);
if (!model) {
throw new Error(`Model not found in pi registry: provider="${providerId}" model="${modelId}"`);
throw new Error(
`Model not found in pi registry: provider="${providerId}" model="${modelId}". Browse valid providers and models at ${PI_CATALOG_URL}.`,
);
}
return {
+17 -10
View File
@@ -37,7 +37,7 @@ const OWASP_CATEGORY_VALUES = [
'A10:2025 — Mishandling of Exceptional Conditions',
] as const;
const SEVERITY_VALUES = ['critical', 'high', 'medium', 'low', 'informational'] as const;
const SEVERITY_VALUES = ['critical', 'high', 'medium', 'low'] as const;
const STATUS_VALUES = ['exploited', 'out_of_scope', 'blocked_by_constraints', 'false_positive'] as const;
const CONFIDENCE_VALUES = ['high', 'medium', 'low'] as const;
@@ -117,8 +117,18 @@ const AdditionalSectionSchema = Type.Object({
}),
});
function identityFields() {
/**
* `severity` is recorded in both modes, but it does not mean the same thing in each: an exploit
* run measures it from what the exploit demonstrated, an analysis run assesses it from the class
* of flaw. The description says which, so the agent never presents an assessment as a measurement.
*/
function identityFields(exploit: boolean) {
const severityDescription = exploit
? 'Severity of the finding, based on the impact the exploit demonstrated.'
: 'Severity of the finding, assessed from the vulnerability class and the impact it would have.';
return {
severity: stringEnum(SEVERITY_VALUES, { description: severityDescription }),
finding_id: Type.String({
minLength: 1,
description: 'Finding identifier (e.g., "AUTH-VULN-07", "INJ-VULN-03"). Must be unique per report.',
@@ -178,9 +188,6 @@ function narrativeFields(exploit: boolean) {
/** Fields that only mean something once an exploit has run. Absent from the analysis schema. */
function exploitOnlyFields() {
return {
severity: stringEnum(SEVERITY_VALUES, {
description: 'Severity of the finding, based on the impact the exploit demonstrated.',
}),
auth_state: Type.String({
minLength: 1,
description: 'Authentication state during testing (e.g., "Unauthenticated", "Any authenticated user").',
@@ -205,7 +212,7 @@ function exploitOnlyFields() {
};
}
/** Replaces `severity` when nothing was exploited. */
/** Accompanies `severity` when nothing was exploited — the rating the analysis deliverable itself carries. */
function analysisOnlyFields() {
return {
confidence: stringEnum(CONFIDENCE_VALUES, {
@@ -233,7 +240,7 @@ function sharedOptionalFields() {
export function buildAddFindingSchema(exploit: boolean) {
return Type.Object({
...identityFields(),
...identityFields(exploit),
...(exploit ? exploitOnlyFields() : analysisOnlyFields()),
...locationFields(),
...narrativeFields(exploit),
@@ -243,12 +250,12 @@ export function buildAddFindingSchema(exploit: boolean) {
/**
* Superset of both modes, for typing only. Consumers must check presence rather than assume:
* `report.json` from an analysis run has no `severity` or `exploitation_steps` key at all.
* `report.json` from an analysis run has no `exploitation_steps` key at all. `severity` is the
* exception both modes record it, so it is required here too.
*/
const AddFindingSupersetSchema = Type.Object({
...identityFields(),
...identityFields(true),
code_locations: Type.Optional(Type.Array(CodeLocationSchema)),
severity: Type.Optional(stringEnum(SEVERITY_VALUES)),
auth_state: Type.Optional(Type.String()),
prerequisites: Type.Optional(Type.String()),
exploitation_steps: Type.Optional(Type.Array(StructuredStepSchema)),
+11 -5
View File
@@ -514,7 +514,7 @@ const validateRulesSecurity = (rules: Rule[] | undefined, ruleType: string): voi
ErrorCode.CONFIG_VALIDATION_FAILED,
);
}
if (pattern.test(rule.description)) {
if (rule.description !== undefined && pattern.test(rule.description)) {
throw new PentestError(
`rules.${ruleType}[${index}].description contains potentially dangerous pattern: ${pattern.source}`,
'config',
@@ -656,11 +656,15 @@ const checkForConflicts = (avoidRules: Rule[] = [], focusRules: Rule[] = []): vo
};
const sanitizeRule = (rule: Rule): Rule => {
return {
description: rule.description.trim(),
const sanitized: Rule = {
type: rule.type.toLowerCase().trim() as Rule['type'],
value: rule.value.trim(),
};
const description = rule.description?.trim();
if (description) {
sanitized.description = description;
}
return sanitized;
};
export const distributeConfig = (config: Config | null): DistributedConfig => {
@@ -702,13 +706,15 @@ const sanitizeAuthentication = (auth: Authentication): Authentication => {
credentials: {
username: auth.credentials.username.trim(),
...(auth.credentials.password && { password: auth.credentials.password }),
...(auth.credentials.totp_secret && { totp_secret: auth.credentials.totp_secret.trim() }),
...(auth.credentials.totp_secret && {
totp_secret: auth.credentials.totp_secret.replace(/\s/g, ''),
}),
...(auth.credentials.email_login && {
email_login: {
address: auth.credentials.email_login.address.trim(),
password: auth.credentials.email_login.password,
...(auth.credentials.email_login.totp_secret && {
totp_secret: auth.credentials.email_login.totp_secret.trim(),
totp_secret: auth.credentials.email_login.totp_secret.replace(/\s/g, ''),
}),
},
}),
+27 -11
View File
@@ -36,10 +36,13 @@ import {
} from '@earendil-works/pi-coding-agent';
import { glob } from 'zx';
import {
type CuratedProviderId,
createModelRuntime,
GENERIC_API_KEY_ENV,
type ModelSpec,
type OpenAiFormat,
type ProviderId,
PI_CATALOG_URL,
piAuthPresent,
resolveGatewayFormat,
resolveModel,
resolveModelSpec,
@@ -179,7 +182,7 @@ type RuleKind = 'avoid' | 'focus';
interface MissingCodePath {
kind: RuleKind;
value: string;
description: string;
description?: string;
}
async function validateCodePathsExist(
@@ -202,12 +205,16 @@ async function validateCodePathsExist(
const missing: MissingCodePath[] = [];
for (const { kind, rule } of tagged) {
if (!(await patternMatchesAny(repoPath, rule.value))) {
missing.push({ kind, value: rule.value, description: rule.description });
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}`);
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` +
@@ -273,17 +280,24 @@ async function probeCredentialsWithPi(
return ok(undefined);
}
/** Credential env var a provider reads, for "credential missing" messages. */
const PROVIDER_CREDENTIAL_HINT: Readonly<Record<ProviderId, string>> = {
/** 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: ProviderId, baseUrl: string | undefined): string {
function describeAuth(providerId: string, baseUrl: string | undefined): string {
if (baseUrl) return `custom endpoint (${baseUrl})`;
if (piAuthPresent()) return `${providerId} credentials from pi auth.json`;
if (providerId === 'amazon-bedrock') return 'Bedrock bearer token';
return `${providerId} API key`;
}
@@ -329,12 +343,14 @@ async function validateCredentials(logger: ActivityLogger): Promise<Result<void,
);
}
// With a mounted pi auth.json the env-var checks don't apply — step 5's probe validates it.
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)) {
const missing =
isBedrock && !piAuthPresent() ? ['AWS_REGION', 'AWS_BEARER_TOKEN_BEDROCK'].filter((n) => !process.env[n]) : [];
if (!piAuthPresent() && (missing.length > 0 || (!isBedrock && !credentials.apiKey))) {
return err(
new PentestError(
`No credentials found for provider "${spec.providerId}". Set ${PROVIDER_CREDENTIAL_HINT[spec.providerId]} in .env.`,
`No credentials found for provider "${spec.providerId}". Set ${credentialHint(spec.providerId)} in .env.`,
'config',
false,
{ providerId: spec.providerId, ...(missing.length > 0 && { missing }) },
@@ -352,7 +368,7 @@ async function validateCredentials(logger: ActivityLogger): Promise<Result<void,
if (!baseModel) {
return err(
new PentestError(
`Model not found in pi registry: provider="${spec.providerId}" model="${spec.modelId}". Check SHANNON_AI_MODEL.`,
`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 },
+24 -62
View File
@@ -12,61 +12,32 @@ import type { Authentication, DistributedConfig, DistributedReportConfig, Rule,
import { isGlobPattern } from '../utils/glob.js';
import { handlePromptError, PentestError } from './error-handling.js';
function renderRuleLine(tag: string, value: string, description?: string): string {
const base = `- ${tag} ${value}`;
return description ? `${base} - ${description}` : base;
}
function renderUrlRules(rules: Rule[]): string {
if (rules.length === 0) return 'None';
return rules.map((r) => renderRuleLine(`[${r.type.toUpperCase()}]`, r.value, r.description)).join('\n');
}
function renderCodePathRules(rules: Rule[]): string {
const filtered = rules.filter((r) => r.type === 'code_path');
if (filtered.length === 0) return 'None';
return filtered
.map((r) => {
const kind = isGlobPattern(r.value) ? '[GLOB]' : '[FILE]';
return `- ${r.value} ${kind}${r.description}`;
})
.map((r) => renderRuleLine(isGlobPattern(r.value) ? '[GLOB]' : '[FILE]', r.value, r.description))
.join('\n');
}
interface VulnSummarySpec {
readonly heading: string;
readonly evidenceSection: string;
readonly noneFoundLabel: string;
}
const VULN_SUMMARY_SPECS: Record<VulnClass, VulnSummarySpec> = {
auth: {
heading: 'Authentication Vulnerabilities',
evidenceSection: 'Authentication Exploitation Evidence',
noneFoundLabel: 'authentication',
},
authz: {
heading: 'Authorization Vulnerabilities',
evidenceSection: 'Authorization Exploitation Evidence',
noneFoundLabel: 'authorization',
},
xss: {
heading: 'Cross-Site Scripting (XSS) Vulnerabilities',
evidenceSection: 'XSS Exploitation Evidence',
noneFoundLabel: 'XSS',
},
injection: {
heading: 'SQL/Command Injection Vulnerabilities',
evidenceSection: 'Injection Exploitation Evidence',
noneFoundLabel: 'SQL or command injection',
},
ssrf: {
heading: 'Server-Side Request Forgery (SSRF) Vulnerabilities',
evidenceSection: 'SSRF Exploitation Evidence',
noneFoundLabel: 'SSRF',
},
const VULN_CLASS_HEADINGS: Record<VulnClass, string> = {
auth: 'Authentication Vulnerabilities',
authz: 'Authorization Vulnerabilities',
xss: 'Cross-Site Scripting (XSS) Vulnerabilities',
injection: 'SQL/Command Injection Vulnerabilities',
ssrf: 'Server-Side Request Forgery (SSRF) Vulnerabilities',
};
function renderVulnSummarySubsections(selected: readonly VulnClass[]): string {
const classes = selected.length > 0 ? selected : (Object.keys(VULN_SUMMARY_SPECS) as VulnClass[]);
return classes
.map((cls) => {
const spec = VULN_SUMMARY_SPECS[cls];
return `**${spec.heading}:**\n{Check for "${spec.evidenceSection}" section. Include actually exploited vulnerabilities and those blocked by security controls. Exclude theoretical vulnerabilities requiring internal network access. If vulnerabilities exist, summarize their impact and severity. If section is missing or empty, state: "No ${spec.noneFoundLabel} vulnerabilities were found."}`;
})
.join('\n\n');
}
/**
* Renders the <not_assessed_classes> block. Empty when every class completed.
*
@@ -86,9 +57,8 @@ function renderNotAssessedClassesBlock(failed: readonly VulnClass[] = []): strin
];
for (const cls of classes) {
const spec = VULN_SUMMARY_SPECS[cls];
lines.push(
`- ${spec.heading}: analysis did not complete; this class was NOT assessed. Absence of findings here does not indicate the class is clean.`,
`- ${VULN_CLASS_HEADINGS[cls]}: analysis did not complete; this class was NOT assessed. Absence of findings here does not indicate the class is clean.`,
);
}
@@ -103,13 +73,13 @@ function renderNotAssessedClassesBlock(failed: readonly VulnClass[] = []): strin
/**
* Which configured filters this run can actually enforce.
*
* The two ratings are mode-exclusive (see ../collectors/finding-collector.ts): an exploited
* finding carries `severity`, an analysed one carries `confidence`. Handing the agent a
* threshold for the rating its findings do not have is a directive it cannot honor.
* Every finding carries `severity` (see ../collectors/finding-collector.ts), so a severity
* threshold always applies. `confidence` exists only on an analysed finding handing an
* exploit run a confidence threshold is a directive it cannot honor.
*/
function applicableFilters(report: DistributedReportConfig | undefined, exploitEnabled: boolean) {
return {
severity: Boolean(report?.min_severity) && exploitEnabled,
severity: Boolean(report?.min_severity),
confidence: Boolean(report?.min_confidence) && !exploitEnabled,
guidance: Boolean(report?.guidance?.trim()),
};
@@ -375,8 +345,8 @@ async function interpolateVariables(
if (avoidUrlRules.length === 0 && focusUrlRules.length === 0) {
result = result.replace(/<rules>[\s\S]*?<\/rules>\s*/g, '');
} else {
const avoidStr = avoidUrlRules.length > 0 ? avoidUrlRules.map((r) => `- ${r.description}`).join('\n') : 'None';
const focusStr = focusUrlRules.length > 0 ? focusUrlRules.map((r) => `- ${r.description}`).join('\n') : 'None';
const avoidStr = renderUrlRules(avoidUrlRules);
const focusStr = renderUrlRules(focusUrlRules);
result = replaceLiteral(result, /{{RULES_AVOID}}/g, avoidStr);
result = replaceLiteral(result, /{{RULES_FOCUS}}/g, focusStr);
}
@@ -416,7 +386,6 @@ async function interpolateVariables(
/{{VULN_CLASSES_TESTED}}/g,
vulnClasses.length > 0 ? vulnClasses.join(', ') : 'injection, xss, auth, authz, ssrf',
);
result = replaceLiteral(result, /{{VULN_SUMMARY_SUBSECTIONS}}/g, renderVulnSummarySubsections(vulnClasses));
result = replaceLiteral(
result,
/{{NOT_ASSESSED_CLASSES}}/g,
@@ -432,19 +401,12 @@ async function interpolateVariables(
result = result.replace(/<\/?(?:exploit|analysis)_mode_[a-z_]+>\n?/g, '');
result = replaceLiteral(result, /{{EXPLOITATION}}/g, exploitEnabled ? 'enabled' : 'disabled');
result = replaceLiteral(result, /{{REPORT_VULN_HEADING}}/g, exploitEnabled ? 'Exploitation Evidence' : 'Findings');
result = replaceLiteral(
result,
/{{REPORT_VULN_SUBHEADING}}/g,
exploitEnabled ? 'Successfully Exploited Vulnerabilities' : 'Identified Vulnerabilities',
);
if (config?.report?.min_severity && !exploitEnabled) {
logger.warn(
`report.min_severity="${config.report.min_severity}" is ignored when exploit=false: an ` +
'analysis-only run rates findings by confidence, not severity. Use report.min_confidence.',
);
}
if (config?.report?.min_confidence && exploitEnabled) {
logger.warn(
`report.min_confidence="${config.report.min_confidence}" is ignored when exploit=true: an ` +
+10 -1
View File
@@ -263,7 +263,16 @@ export function renderReport(data: ReportData): string {
sections.push(`### ${cat}`);
sections.push('');
for (const f of catFindings) {
const suffix = f.severity ? ` (${titleCase(f.severity)})` : '';
// Both ratings when the mode produced both. Confidence is labelled so it is never
// read as a severity in the position where a severity usually sits.
const ratings: string[] = [];
if (f.severity) {
ratings.push(titleCase(f.severity));
}
if (f.confidence) {
ratings.push(`${titleCase(f.confidence)} confidence`);
}
const suffix = ratings.length > 0 ? ` (${ratings.join(', ')})` : '';
sections.push(`- **${f.finding_id}:** ${f.title}${suffix}`);
}
sections.push('');
+1 -1
View File
@@ -11,7 +11,7 @@
export type RuleType = 'url_path' | 'subdomain' | 'domain' | 'method' | 'header' | 'parameter' | 'code_path';
export interface Rule {
description: string;
description?: string;
type: RuleType;
value: string;
}
+71 -17
View File
@@ -12,17 +12,29 @@ The provider half decides where the request goes, which credential is used, and
| Provider | Value | Credential |
| --- | --- | --- |
| Anthropic | `anthropic` | `ANTHROPIC_API_KEY` or `CLAUDE_CODE_OAUTH_TOKEN` |
| OpenAI | `openai` | `OPENAI_API_KEY` |
| xAI | `xai` | `XAI_API_KEY` |
| Anthropic | `anthropic` | `SHANNON_AI_API_KEY` (or `CLAUDE_CODE_OAUTH_TOKEN`) |
| OpenAI | `openai` | `SHANNON_AI_API_KEY` |
| xAI | `xai` | `SHANNON_AI_API_KEY` |
| AWS Bedrock | `amazon-bedrock` | `AWS_REGION` and `AWS_BEARER_TOKEN_BEDROCK` |
Shannon does not invent credential names — each is the variable that provider's own tooling already uses. If `SHANNON_AI_MODEL` is unset, Shannon uses `anthropic:claude-sonnet-4-6`.
`SHANNON_AI_API_KEY` holds the key for whichever provider `SHANNON_AI_MODEL` names. Bedrock is the exception — it authenticates through its `AWS_` variables only. If `SHANNON_AI_MODEL` is unset, Shannon uses `anthropic:claude-sonnet-4-6`.
Anthropic, OpenAI, and xAI also accept their native variables (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `XAI_API_KEY`); if one of those is set, it is used instead of `SHANNON_AI_API_KEY`.
Shannon forwards only the selected provider's credential into the scan container. Keys for other providers stay on your machine.
> [!NOTE]
> Only the **first** colon separates the provider from the model ID, so Bedrock IDs that contain colons work unchanged: `amazon-bedrock:us.anthropic.claude-opus-4-5-20251101-v1:0`.
### Any other provider
Shannon accepts any provider and model present in the Pi harness catalogue. Browse them at [pi.dev/models](https://pi.dev/models). These are technically supported but not recommended. Claude models are best-supported (see the note below).
```bash
export SHANNON_AI_API_KEY=your-api-key # the provider's API key
export SHANNON_AI_MODEL=openrouter:moonshotai/kimi-k3 # <provider>:<model-id>
```
This path covers providers whose credential is a single API key. Providers that need more than that are not currently supported.
`npx @keygraph/shannon setup` exposes this as the **Other provider** option.
> [!IMPORTANT]
> Claude models are the best-supported option. Shannon's evaluations, internal testing, and agent harness are tuned for Claude. Other models are permitted and validated against the harness catalogue, but may not follow Shannon's instructions or tool-use constraints as reliably. Use them at your own risk.
@@ -58,21 +70,21 @@ The pattern is learned once: export the provider's key, name the model. Two line
Anthropic (default):
```bash
export ANTHROPIC_API_KEY=sk-ant-...
export SHANNON_AI_API_KEY=sk-ant-...
export SHANNON_AI_MODEL=anthropic:claude-sonnet-4-6
```
OpenAI:
```bash
export OPENAI_API_KEY=sk-...
export SHANNON_AI_API_KEY=sk-...
export SHANNON_AI_MODEL=openai:gpt-5.6-sol
```
xAI:
```bash
export XAI_API_KEY=xai-...
export SHANNON_AI_API_KEY=xai-...
export SHANNON_AI_MODEL=xai:grok-4.5
```
@@ -96,16 +108,16 @@ To route model traffic through your own infrastructure — a corporate proxy, an
| Gateway serves | Model prefix | API key |
| --- | --- | --- |
| Anthropic Messages | `anthropic:` | `ANTHROPIC_API_KEY` |
| OpenAI Chat Completions | `openai:` | `OPENAI_API_KEY` |
| OpenAI Responses | `openai:` + `SHANNON_AI_OPENAI_FORMAT=responses` | `OPENAI_API_KEY` |
| Anthropic Messages | `anthropic:` | `SHANNON_AI_API_KEY` |
| OpenAI Chat Completions | `openai:` | `SHANNON_AI_API_KEY` |
| OpenAI Responses | `openai:` + `SHANNON_AI_OPENAI_FORMAT=responses` | `SHANNON_AI_API_KEY` |
The model ID is whatever name your gateway serves it under; it does not have to exist in Shannon's catalogue.
Anthropic Messages:
```bash
export ANTHROPIC_API_KEY=sk-ant-...
export SHANNON_AI_API_KEY=sk-ant-...
export SHANNON_AI_MODEL=anthropic:claude-sonnet-4-6
export SHANNON_AI_BASE_URL=https://llm-gateway.example.com
```
@@ -113,7 +125,7 @@ export SHANNON_AI_BASE_URL=https://llm-gateway.example.com
OpenAI Chat Completions:
```bash
export OPENAI_API_KEY=sk-...
export SHANNON_AI_API_KEY=sk-...
export SHANNON_AI_MODEL=openai:gpt-5.6-sol
export SHANNON_AI_BASE_URL=https://llm-gateway.example.com/v1
```
@@ -132,13 +144,55 @@ The variable is rejected in preflight where it cannot take effect: with a non-`o
`npx @keygraph/shannon setup` covers this under **Custom Base URL**, which asks which API your gateway serves and configures the matching provider for you.
## OpenAI Codex (ChatGPT Plus/Pro subscription)
A ChatGPT Plus or Pro Codex subscription can run Shannon. Shannon reuses a login created by Pi.
Before running a pentest, review the [cyber safeguards requirements](#cyber-safeguards-do-this-before-your-first-scan).
1. Install Pi by following the instructions at [pi.dev](https://pi.dev).
2. Log in with your subscription using Pi's [subscription authentication guide](https://pi.dev/docs/latest/providers#subscriptions). This creates `~/.pi/agent/auth.json` with an `openai-codex` entry.
3. Select a Codex model and enable Pi authentication:
```bash
export SHANNON_USE_PI_AUTH=1
export SHANNON_AI_MODEL=openai-codex:gpt-5.5
```
4. In npx mode, run `npx @keygraph/shannon start ...` from the same shell. In source-build mode, add the two variables to `.env` and run `./shannon start ...`.
Supported Codex models are `gpt-5.6-sol`, `gpt-5.5`, and `gpt-5.4`.
## Claude Code subscription
The latest version of Shannon does not support Claude Code subscriptions. The [`shannon-v1`](https://github.com/KeygraphHQ/shannon/tree/shannon-v1) branch is the final release built on the Claude Agent SDK and supports Claude Code OAuth.
Before running a pentest, review the [cyber safeguards requirements](#cyber-safeguards-do-this-before-your-first-scan).
1. Generate a Claude Code OAuth token:
```bash
claude setup-token
```
2. Run the setup flow for the final `shannon-v1` release:
```bash
npx @keygraph/shannon@1.9.0 setup
```
3. Select **OAuth Token** and enter the token generated by Claude Code.
4. Start the pentest with `npx @keygraph/shannon@1.9.0 start ...`.
These instructions apply only to `shannon-v1`.
## Validation
Checks run before a scan starts, so mistakes fail immediately rather than partway through a run:
- **Provider** always validated against the providers Shannon's harness knows. An unrecognised provider is rejected with the valid list.
- **Model ID** — validated against the harness catalogue for that provider, so a typo is caught instantly.
- **Credential presence** — always validated for the selected provider.
- **Provider and model ID** — validated against the Pi harness catalogue. An unknown provider or model ID fails preflight with a pointer to [pi.dev/models](https://pi.dev/models). A custom base URL exempts the model ID, since a gateway may serve its own names.
- **Credential presence** — validated for the selected provider, or read from Pi when `SHANNON_USE_PI_AUTH=1`.
- **Credential validity** — one minimal request against the model the scan will use, so a rejected key, an exhausted quota, or a model the account cannot reach fails before any agent runs. Bedrock included: its bearer token and region go through the same probe.
## Migrating from the three-tier configuration
+1 -1
View File
@@ -12,7 +12,7 @@ if [ -n "$TARGET_UID" ] && [ "$TARGET_UID" != "$CURRENT_UID" ]; then
groupadd -g "$TARGET_GID" pentest
useradd -u "$TARGET_UID" -g pentest -s /bin/bash -M pentest
chown -R pentest:pentest /app/sessions /app/workspaces /tmp/.claude
chown -R pentest:pentest /app/sessions /app/workspaces /tmp/.claude /tmp/.pi
fi
exec su -m pentest -c "exec $*"
+28 -17
View File
@@ -82,7 +82,7 @@ Sample penetration test reports from intentionally vulnerable applications, prod
- **Docker**: required for the worker container.
- **Node.js 18+**: required for the recommended `npx` workflow.
- **AI provider credentials**: Anthropic, OpenAI, xAI, or AWS Bedrock. Claude models are recommended. Gateway and proxy setups are documented separately.
- **AI provider credentials**: Anthropic, OpenAI, xAI, or AWS Bedrock - or [any other provider](docs/ai-providers.md#any-other-provider). Claude models are recommended. Gateway and proxy setups are documented separately.
- **Cyber safeguards cleared with your provider**: Anthropic and OpenAI apply real-time safeguards to cyber-security workloads, which can interrupt a scan mid-run. Complete their guidance for legitimate security testers before your first run - see [AI providers](docs/ai-providers.md#cyber-safeguards-do-this-before-your-first-scan).
### Run Shannon
@@ -592,17 +592,29 @@ The provider half decides where the request goes, which credential is used, and
| Provider | Value | Credential |
| --- | --- | --- |
| Anthropic | `anthropic` | `ANTHROPIC_API_KEY` or `CLAUDE_CODE_OAUTH_TOKEN` |
| OpenAI | `openai` | `OPENAI_API_KEY` |
| xAI | `xai` | `XAI_API_KEY` |
| Anthropic | `anthropic` | `SHANNON_AI_API_KEY` (or `CLAUDE_CODE_OAUTH_TOKEN`) |
| OpenAI | `openai` | `SHANNON_AI_API_KEY` |
| xAI | `xai` | `SHANNON_AI_API_KEY` |
| AWS Bedrock | `amazon-bedrock` | `AWS_REGION` and `AWS_BEARER_TOKEN_BEDROCK` |
Shannon does not invent credential names — each is the variable that provider's own tooling already uses. If `SHANNON_AI_MODEL` is unset, Shannon uses `anthropic:claude-sonnet-4-6`.
`SHANNON_AI_API_KEY` holds the key for whichever provider `SHANNON_AI_MODEL` names. Bedrock is the exception — it authenticates through its `AWS_` variables only. If `SHANNON_AI_MODEL` is unset, Shannon uses `anthropic:claude-sonnet-4-6`.
Anthropic, OpenAI, and xAI also accept their native variables (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `XAI_API_KEY`); if one of those is set, it is used instead of `SHANNON_AI_API_KEY`.
Shannon forwards only the selected provider's credential into the scan container. Keys for other providers stay on your machine.
> [!NOTE]
> Only the **first** colon separates the provider from the model ID, so Bedrock IDs that contain colons work unchanged: `amazon-bedrock:us.anthropic.claude-opus-4-5-20251101-v1:0`.
### Any other provider
Shannon accepts any provider and model present in the Pi harness catalogue. Browse them at [pi.dev/models](https://pi.dev/models). These are technically supported but not recommended. Claude models are best-supported (see the note below).
```bash
export SHANNON_AI_API_KEY=your-api-key # the provider's API key
export SHANNON_AI_MODEL=openrouter:moonshotai/kimi-k3 # <provider>:<model-id>
```
This path covers providers whose credential is a single API key. Providers that need more than that are not currently supported.
`npx @keygraph/shannon setup` exposes this as the **Other provider** option.
> [!IMPORTANT]
> Claude models are the best-supported option. Shannon's evaluations, internal testing, and agent harness are tuned for Claude. Other models are permitted and validated against the harness catalogue, but may not follow Shannon's instructions or tool-use constraints as reliably. Use them at your own risk.
@@ -638,21 +650,21 @@ The pattern is learned once: export the provider's key, name the model. Two line
Anthropic (default):
```bash
export ANTHROPIC_API_KEY=sk-ant-...
export SHANNON_AI_API_KEY=sk-ant-...
export SHANNON_AI_MODEL=anthropic:claude-sonnet-4-6
```
OpenAI:
```bash
export OPENAI_API_KEY=sk-...
export SHANNON_AI_API_KEY=sk-...
export SHANNON_AI_MODEL=openai:gpt-5.6-sol
```
xAI:
```bash
export XAI_API_KEY=xai-...
export SHANNON_AI_API_KEY=xai-...
export SHANNON_AI_MODEL=xai:grok-4.5
```
@@ -676,16 +688,16 @@ To route model traffic through your own infrastructure — a corporate proxy, an
| Gateway serves | Model prefix | API key |
| --- | --- | --- |
| Anthropic Messages | `anthropic:` | `ANTHROPIC_API_KEY` |
| OpenAI Chat Completions | `openai:` | `OPENAI_API_KEY` |
| OpenAI Responses | `openai:` + `SHANNON_AI_OPENAI_FORMAT=responses` | `OPENAI_API_KEY` |
| Anthropic Messages | `anthropic:` | `SHANNON_AI_API_KEY` |
| OpenAI Chat Completions | `openai:` | `SHANNON_AI_API_KEY` |
| OpenAI Responses | `openai:` + `SHANNON_AI_OPENAI_FORMAT=responses` | `SHANNON_AI_API_KEY` |
The model ID is whatever name your gateway serves it under; it does not have to exist in Shannon's catalogue.
Anthropic Messages:
```bash
export ANTHROPIC_API_KEY=sk-ant-...
export SHANNON_AI_API_KEY=sk-ant-...
export SHANNON_AI_MODEL=anthropic:claude-sonnet-4-6
export SHANNON_AI_BASE_URL=https://llm-gateway.example.com
```
@@ -693,7 +705,7 @@ export SHANNON_AI_BASE_URL=https://llm-gateway.example.com
OpenAI Chat Completions:
```bash
export OPENAI_API_KEY=sk-...
export SHANNON_AI_API_KEY=sk-...
export SHANNON_AI_MODEL=openai:gpt-5.6-sol
export SHANNON_AI_BASE_URL=https://llm-gateway.example.com/v1
```
@@ -716,8 +728,7 @@ The variable is rejected in preflight where it cannot take effect: with a non-`o
Checks run before a scan starts, so mistakes fail immediately rather than partway through a run:
- **Provider** — always validated against the providers Shannon's harness knows. An unrecognised provider is rejected with the valid list.
- **Model ID** — validated against the harness catalogue for that provider, so a typo is caught instantly.
- **Provider and model ID** — validated against the Pi harness catalogue. An unknown provider or model ID fails preflight with a pointer to [pi.dev/models](https://pi.dev/models). A custom base URL exempts the model ID, since a gateway may serve its own names.
- **Credential presence** — always validated for the selected provider.
- **Credential validity** — one minimal request against the model the scan will use, so a rejected key, an exhausted quota, or a model the account cannot reach fails before any agent runs. Bedrock included: its bearer token and region go through the same probe.
+1 -1
View File
@@ -13,7 +13,7 @@ Use this file as the concise entry point for AI agents and LLMs reading this rep
- [Development](docs/development.md): Source-build workflow, common CLI commands, repository paths, and output locations.
- [Configuration](docs/configuration.md): Authenticated testing, login flows, rules of engagement, report filters, credential precedence, adaptive thinking, and rate-limit settings.
- [AI Providers](docs/ai-providers.md): Anthropic, AWS Bedrock, and custom Anthropic-compatible endpoint setup.
- [AI Providers](docs/ai-providers.md): Anthropic, OpenAI, xAI, AWS Bedrock, any other Pi-supported provider, and custom gateway setup.
- [Platforms and Networking](docs/platforms.md): Windows/WSL2, Linux, macOS, Docker networking, local applications, and custom hostnames.
- [Workspaces and Resuming](docs/workspaces.md): Workspace storage, naming, resuming interrupted scans, and examples.
- [Safety and Limitations](docs/safety.md): Authorized-use requirements, non-production guidance, mutative effects, model caveats, scope limits, cost, and performance.