Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,18 @@ ANTHROPIC_API_KEY=your-api-key-here
# AWS_REGION=us-east-1
# AWS_BEARER_TOKEN_BEDROCK=your-bearer-token

# =============================================================================
# OPTION 4b: AWS Bedrock (Profile)
# =============================================================================
# Use an existing AWS CLI profile instead of a bearer token.
# The CLI mounts ~/.aws/ read-only into the container for credential auto-refresh.
# Supports SSO, IAM keys, assumed roles, and any profile type.
# Requires the model tier overrides above to be set with Bedrock-specific model IDs.

# CLAUDE_CODE_USE_BEDROCK=1
# AWS_PROFILE=default
# AWS_REGION=us-east-1

# =============================================================================
# OPTION 5: Google Vertex AI
# =============================================================================
Expand Down
1 change: 1 addition & 0 deletions apps/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"clean": "rm -rf dist"
},
"dependencies": {
"@aws-sdk/credential-providers": "^3.1021.0",
"@clack/prompts": "^1.1.0",
"chokidar": "^5.0.0",
"dotenv": "^17.3.1",
Expand Down
53 changes: 51 additions & 2 deletions apps/cli/src/commands/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fromNodeProviderChain } from '@aws-sdk/credential-providers';
import * as p from '@clack/prompts';
import { type ShannonConfig, saveConfig } from '../config/writer.js';

Expand Down Expand Up @@ -177,15 +178,63 @@ async function setupCustomBaseUrl(): Promise<ShannonConfig> {
}

async function setupBedrock(): Promise<ShannonConfig> {
// 1. Choose auth method
const authMethod = await p.select({
message: 'Authentication method',
options: [
{ value: 'token' as const, label: 'Bearer Token', hint: 'static API key from AWS' },
{ value: 'profile' as const, label: 'AWS Profile', hint: 'uses ~/.aws/ credentials (SSO, IAM, etc.)' },
],
});
if (p.isCancel(authMethod)) return cancelAndExit();

// 2. Collect region (common to both paths)
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');
let bedrockConfig: ShannonConfig['bedrock'];

if (authMethod === 'profile') {
// 3a. Profile path — collect profile name and validate
const profile = await p.text({
message: 'AWS Profile name',
placeholder: 'default',
initialValue: 'default',
validate: required('Profile name is required'),
});
if (p.isCancel(profile)) return cancelAndExit();

// Validate by attempting to resolve credentials
const spinner = p.spinner();
spinner.start(`Resolving credentials for profile "${profile}"...`);
try {
const provider = fromNodeProviderChain({ profile });
await provider();
spinner.stop(`Found credentials for profile "${profile}"`);
} catch (error) {
spinner.stop('Credential resolution failed');
const message = error instanceof Error ? error.message : String(error);
const isSsoExpiry = message.includes('SSO') || message.includes('sso') || message.includes('expired');
if (isSsoExpiry) {
p.log.error(`AWS SSO session expired. Run this first:\n aws sso login --profile ${profile}`);
} else {
p.log.error(`Failed to resolve credentials for profile "${profile}": ${message}`);
}
return cancelAndExit();
}

bedrockConfig = { use: true, region, profile };
} else {
// 3b. Token path — collect bearer token
const token = await promptSecret('Enter your AWS Bearer Token');
bedrockConfig = { use: true, region, token };
}

// 4. Model tiers (same for both paths)
const small = await p.text({
message: 'Small model ID',
placeholder: 'us.anthropic.claude-haiku-4-5-20251001-v1:0',
Expand All @@ -208,7 +257,7 @@ async function setupBedrock(): Promise<ShannonConfig> {
if (p.isCancel(large)) return cancelAndExit();

return {
bedrock: { use: true, region, token },
bedrock: bedrockConfig,
models: { small, medium, large },
};
}
Expand Down
4 changes: 4 additions & 0 deletions apps/cli/src/commands/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ export async function start(args: StartArgs): Promise<void> {
process.env.GOOGLE_APPLICATION_CREDENTIALS = '/app/credentials/google-sa-key.json';
}

// 9b. Detect AWS profile mode for Bedrock bind mounts
const needsAwsMounts = process.env.CLAUDE_CODE_USE_BEDROCK === '1' && !!process.env.AWS_PROFILE;

// 10. Resolve output directory
const outputDir = args.output ? path.resolve(args.output) : undefined;
if (outputDir) {
Expand Down Expand Up @@ -103,6 +106,7 @@ export async function start(args: StartArgs): Promise<void> {
...(outputDir && { outputDir }),
...(workspace && { workspace }),
...(args.pipelineTesting && { pipelineTesting: true }),
...(needsAwsMounts && { awsMounts: true }),
});

// 14. Wait for workflow to register, then display info
Expand Down
16 changes: 12 additions & 4 deletions apps/cli/src/config/resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const CONFIG_MAP: readonly ConfigMapping[] = [
{ env: 'CLAUDE_CODE_USE_BEDROCK', toml: 'bedrock.use', type: 'boolean' },
{ env: 'AWS_REGION', toml: 'bedrock.region', type: 'string' },
{ env: 'AWS_BEARER_TOKEN_BEDROCK', toml: 'bedrock.token', type: 'string' },
{ env: 'AWS_PROFILE', toml: 'bedrock.profile', type: 'string' },

// Vertex
{ env: 'CLAUDE_CODE_USE_VERTEX', toml: 'vertex.use', type: 'boolean' },
Expand Down Expand Up @@ -147,11 +148,18 @@ function validateProviderFields(config: TOMLConfig, provider: string, errors: st
}

case 'bedrock': {
const required = ['use', 'region', 'token'];
const missing = required.filter((k) => !keys.includes(k));
if (missing.length > 0) {
errors.push(`[bedrock] missing required keys: ${missing.join(', ')}`);
const requiredBase = ['use', 'region'];
const missingBase = requiredBase.filter((k) => !keys.includes(k));
if (missingBase.length > 0) {
errors.push(`[bedrock] missing required keys: ${missingBase.join(', ')}`);
}

const hasProfile = keys.includes('profile');
const hasToken = keys.includes('token');
if (!hasProfile && !hasToken) {
errors.push('[bedrock] requires either "profile" (AWS profile auth) or "token" (bearer token auth)');
}

validateModelTiers(config, 'bedrock', errors);
break;
}
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/src/config/writer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export interface ShannonConfig {
core?: { max_tokens?: number };
anthropic?: { api_key?: string; oauth_token?: string };
custom_base_url?: { base_url?: string; auth_token?: string };
bedrock?: { use?: boolean; region?: string; token?: string };
bedrock?: { use?: boolean; region?: string; token?: string; profile?: string };
vertex?: { use?: boolean; region?: string; project_id?: string; key_path?: string };
router?: { default?: string; openai_key?: string; openrouter_key?: string };
models?: { small?: string; medium?: string; large?: string };
Expand Down
21 changes: 21 additions & 0 deletions apps/cli/src/docker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import { type ChildProcess, execFileSync, spawn } from 'node:child_process';
import crypto from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { setTimeout as sleep } from 'node:timers/promises';
Expand Down Expand Up @@ -196,6 +197,7 @@ export interface WorkerOptions {
outputDir?: string;
workspace?: string;
pipelineTesting?: boolean;
awsMounts?: boolean;
}

/**
Expand Down Expand Up @@ -235,6 +237,25 @@ export function spawnWorker(opts: WorkerOptions): ChildProcess {
args.push('-v', `${opts.credentials}:/app/credentials/google-sa-key.json:ro`);
}

// Mount AWS config files for profile-based Bedrock auth
if (opts.awsMounts) {
const awsDir = path.join(os.homedir(), '.aws');
const configFile = path.join(awsDir, 'config');
const credentialsFile = path.join(awsDir, 'credentials');
const ssoDir = path.join(awsDir, 'sso');

// NOTE: Container HOME=/tmp (set in Dockerfile), so AWS SDK looks in /tmp/.aws/
if (fs.existsSync(configFile)) {
args.push('-v', `${configFile}:/tmp/.aws/config:ro`);
}
if (fs.existsSync(credentialsFile)) {
args.push('-v', `${credentialsFile}:/tmp/.aws/credentials:ro`);
}
if (fs.existsSync(ssoDir)) {
args.push('-v', `${ssoDir}:/tmp/.aws/sso:ro`);
}
}

// Environment
args.push(...opts.envFlags);

Expand Down
32 changes: 31 additions & 1 deletion apps/cli/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
* 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';
Expand All @@ -19,6 +22,7 @@ const FORWARD_VARS = [
'CLAUDE_CODE_USE_BEDROCK',
'AWS_REGION',
'AWS_BEARER_TOKEN_BEDROCK',
'AWS_PROFILE',
'CLAUDE_CODE_USE_VERTEX',
'CLOUD_ML_REGION',
'ANTHROPIC_VERTEX_PROJECT_ID',
Expand Down Expand Up @@ -115,12 +119,38 @@ export function validateCredentials(): CredentialValidation {
return { valid: true, mode: 'custom-base-url' };
}
if (process.env.CLAUDE_CODE_USE_BEDROCK === '1') {
const isProfileMode = !!process.env.AWS_PROFILE;
const isTokenMode = !!process.env.AWS_BEARER_TOKEN_BEDROCK;

if (isProfileMode && isTokenMode) {
return {
valid: false,
mode: 'bedrock',
error: 'Set either AWS_PROFILE (profile auth) or AWS_BEARER_TOKEN_BEDROCK (token auth), not both',
};
}

if (!isProfileMode && !isTokenMode) {
return {
valid: false,
mode: 'bedrock',
error: 'Bedrock mode requires either AWS_BEARER_TOKEN_BEDROCK (token auth) or AWS_PROFILE (profile auth)',
};
}

const missing: string[] = [];
if (!process.env.AWS_REGION) missing.push('AWS_REGION');
if (!process.env.AWS_BEARER_TOKEN_BEDROCK) missing.push('AWS_BEARER_TOKEN_BEDROCK');
if (!process.env.ANTHROPIC_SMALL_MODEL) missing.push('ANTHROPIC_SMALL_MODEL');
if (!process.env.ANTHROPIC_MEDIUM_MODEL) missing.push('ANTHROPIC_MEDIUM_MODEL');
if (!process.env.ANTHROPIC_LARGE_MODEL) missing.push('ANTHROPIC_LARGE_MODEL');

if (isProfileMode) {
const awsConfigPath = path.join(os.homedir(), '.aws', 'config');
if (!fs.existsSync(awsConfigPath)) {
missing.push('~/.aws/config (file not found)');
}
}

if (missing.length > 0) {
return {
valid: false,
Expand Down
1 change: 1 addition & 0 deletions apps/worker/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "catalog:",
"@aws-sdk/credential-providers": "^3.1021.0",
"@temporalio/activity": "^1.11.0",
"@temporalio/client": "^1.11.0",
"@temporalio/worker": "^1.11.0",
Expand Down
1 change: 1 addition & 0 deletions apps/worker/src/ai/claude-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ export async function runClaudePrompt(
'CLAUDE_CODE_USE_BEDROCK',
'AWS_REGION',
'AWS_BEARER_TOKEN_BEDROCK',
'AWS_PROFILE',
'CLAUDE_CODE_USE_VERTEX',
'CLOUD_ML_REGION',
'ANTHROPIC_VERTEX_PROJECT_ID',
Expand Down
73 changes: 64 additions & 9 deletions apps/worker/src/services/preflight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import http from 'node:http';
import https from 'node:https';
import type { SDKAssistantMessageError } from '@anthropic-ai/claude-agent-sdk';
import { query } from '@anthropic-ai/claude-agent-sdk';
import { fromNodeProviderChain } from '@aws-sdk/credential-providers';
import { resolveModel } from '../ai/models.js';
import { parseConfig } from '../config-parser.js';
import type { ActivityLogger } from '../types/activity-logger.js';
Expand Down Expand Up @@ -214,26 +215,80 @@ async function validateCredentials(logger: ActivityLogger): Promise<Result<void,

// 2. Bedrock mode — validate required AWS credentials are present
if (process.env.CLAUDE_CODE_USE_BEDROCK === '1') {
const required = [
'AWS_REGION',
'AWS_BEARER_TOKEN_BEDROCK',
'ANTHROPIC_SMALL_MODEL',
'ANTHROPIC_MEDIUM_MODEL',
'ANTHROPIC_LARGE_MODEL',
];
const isProfileMode = !!process.env.AWS_PROFILE;
const isTokenMode = !!process.env.AWS_BEARER_TOKEN_BEDROCK;

if (isProfileMode && isTokenMode) {
return err(
new PentestError(
'Set either AWS_PROFILE (profile auth) or AWS_BEARER_TOKEN_BEDROCK (token auth), not both',
'config',
false,
{},
ErrorCode.AUTH_FAILED,
),
);
}

if (!isProfileMode && !isTokenMode) {
return err(
new PentestError(
'Bedrock mode requires either AWS_BEARER_TOKEN_BEDROCK (token auth) or AWS_PROFILE (profile auth)',
'config',
false,
{},
ErrorCode.AUTH_FAILED,
),
);
}

const required = isTokenMode
? [
'AWS_REGION',
'AWS_BEARER_TOKEN_BEDROCK',
'ANTHROPIC_SMALL_MODEL',
'ANTHROPIC_MEDIUM_MODEL',
'ANTHROPIC_LARGE_MODEL',
]
: ['AWS_REGION', 'ANTHROPIC_SMALL_MODEL', 'ANTHROPIC_MEDIUM_MODEL', 'ANTHROPIC_LARGE_MODEL'];
const missing = required.filter((v) => !process.env[v]);
if (missing.length > 0) {
return err(
new PentestError(
`Bedrock mode requires the following env vars in .env: ${missing.join(', ')}`,
`Bedrock mode requires the following env vars: ${missing.join(', ')}`,
'config',
false,
{ missing },
ErrorCode.AUTH_FAILED,
),
);
}
logger.info('Bedrock credentials OK');

// For profile mode, verify credentials can be resolved (catches expired SSO sessions)
if (isProfileMode) {
const profile = process.env.AWS_PROFILE as string;
try {
const provider = fromNodeProviderChain({ profile });
await provider();
logger.info(`Bedrock credentials OK (profile: ${profile})`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const isSsoExpiry = message.includes('SSO') || message.includes('sso') || message.includes('expired');
const hint = isSsoExpiry ? `\nRun: aws sso login --profile ${profile}` : '';
return err(
new PentestError(
`Failed to resolve AWS credentials for profile "${profile}": ${message}${hint}`,
'config',
false,
{ profile },
ErrorCode.AUTH_FAILED,
),
);
}
} else {
logger.info('Bedrock credentials OK');
}

return ok(undefined);
}

Expand Down
Loading