feat: add configurable output directory with --output flag (#41)

* feat: add configurable output directory with --output flag

Add --output CLI flag to specify custom output directory for session
folders containing audit logs, prompts, agent logs, and deliverables.

Changes:
- Add --output <path> CLI flag parsing
- Update generateAuditPath() to use custom path when provided
- Add consolidateOutputs() to copy deliverables to session folder
- Update Docker examples with volume mounts for output directories
- Default remains ./audit-logs/ when --output is not specified

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* feat: add configurable output directory with --output flag

Add --output CLI flag to specify custom output directory for session
folders containing audit logs, prompts, agent logs, and deliverables.

Changes:
- Add --output <path> CLI flag parsing
- Store outputPath in Session interface for persistence
- Update generateAuditPath() to use custom path when provided
- Pass outputPath through pre-recon and checkpoint-manager
- Add consolidateOutputs() to copy deliverables to session folder
- Update Docker examples with volume mount instructions
- Default remains ./audit-logs/ when --output is not specified

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* chore: add gitkeep and fix formatting

* fix: correct docker run command formatting in README

Remove invalid inline comments after backslash continuations in docker
run commands. Comments cannot appear after backslash line continuations
in shell scripts, as the backslash escapes the newline character.

Reorganized comments to appear on separate lines before or after the
command block for better clarity and proper shell syntax.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
ezl-keygraph
2026-01-08 23:50:42 +05:30
committed by GitHub
parent dd18f4629b
commit 264b16991a
9 changed files with 102 additions and 25 deletions

View File

@@ -15,13 +15,14 @@ npm install
### Running the Penetration Testing Agent
```bash
shannon <WEB_URL> <REPO_PATH> --config <CONFIG_FILE>
shannon <WEB_URL> <REPO_PATH> [--config <CONFIG_FILE>] [--output <OUTPUT_DIR>]
```
Example:
```bash
shannon "https://example.com" "/path/to/local/repo"
shannon "https://juice-shop.herokuapp.com" "/home/user/juice-shop" --config juice-shop-config.yaml
shannon "https://example.com" "/path/to/repo" --output /path/to/reports
```
### Alternative Execution
@@ -194,10 +195,11 @@ The agent implements a sophisticated checkpoint system using git:
The agent implements a crash-safe, self-healing audit system (v3.0) with the following guarantees:
**Architecture:**
- **audit-logs/**: Centralized metrics and forensic logs (source of truth)
- **audit-logs/** (or custom `--output` path): Centralized metrics and forensic logs (source of truth)
- `{hostname}_{sessionId}/session.json` - Comprehensive metrics with attempt-level detail
- `{hostname}_{sessionId}/prompts/` - Exact prompts used for reproducibility
- `{hostname}_{sessionId}/agents/` - Turn-by-turn execution logs
- `{hostname}_{sessionId}/deliverables/` - Security reports and findings
- **.shannon-store.json**: Minimal orchestration state (completedAgents, checkpoints)
**Crash Safety:**
@@ -287,13 +289,15 @@ dist/ # Compiled JavaScript output
└── ... # Other compiled files
package.json # Node.js dependencies
.shannon-store.json # Orchestration state (minimal)
audit-logs/ # Centralized audit data (v3.0)
audit-logs/ # Centralized audit data (default, or use --output)
└── {hostname}_{sessionId}/
├── session.json # Comprehensive metrics
├── prompts/ # Prompt snapshots
│ └── {agent}.md
── agents/ # Agent execution logs
└── {timestamp}_{agent}_attempt-{N}.log
── agents/ # Agent execution logs
└── {timestamp}_{agent}_attempt-{N}.log
└── deliverables/ # Security reports and findings
└── ...
configs/ # Configuration files
├── config-schema.json # JSON Schema validation
├── example-config.yaml # Template configuration

View File

@@ -169,10 +169,15 @@ docker run --rm -it \
-e CLAUDE_CODE_MAX_OUTPUT_TOKENS=64000 \
-v "$(pwd)/repos:/app/repos" \
-v "$(pwd)/configs:/app/configs" \
# Comment below line if using custom output directory
-v "$(pwd)/audit-logs:/app/audit-logs" \
shannon:latest \
"https://your-app.com/" \
"/app/repos/your-app" \
--config /app/configs/example-config.yaml
# Optional: uncomment below for custom output directory
# -v "$(pwd)/reports:/app/reports" \
# --output /app/reports
```
**With Anthropic API Key:**
@@ -186,10 +191,15 @@ docker run --rm -it \
-e CLAUDE_CODE_MAX_OUTPUT_TOKENS=64000 \
-v "$(pwd)/repos:/app/repos" \
-v "$(pwd)/configs:/app/configs" \
# Comment below line if using custom output directory
-v "$(pwd)/audit-logs:/app/audit-logs" \
shannon:latest \
"https://your-app.com/" \
"/app/repos/your-app" \
--config /app/configs/example-config.yaml
# Optional: uncomment below for custom output directory
# -v "$(pwd)/reports:/app/reports" \
# --output /app/reports
```
#### Platform-Specific Instructions
@@ -217,10 +227,15 @@ docker run --rm -it \
-e CLAUDE_CODE_MAX_OUTPUT_TOKENS=64000 \
-v "$(pwd)/repos:/app/repos" \
-v "$(pwd)/configs:/app/configs" \
# Comment below line if using custom output directory
-v "$(pwd)/audit-logs:/app/audit-logs" \
shannon:latest \
"http://host.docker.internal:3000" \
"/app/repos/your-app" \
--config /app/configs/example-config.yaml
# Optional: uncomment below for custom output directory
# -v "$(pwd)/reports:/app/reports" \
# --output /app/reports
```
### Configuration (Optional)
@@ -281,7 +296,7 @@ docker run --rm shannon:latest --status
### Output and Results
All analysis results are saved to the `deliverables/` directory:
All results are saved to `./audit-logs/` by default. Use `--output <path>` to specify a custom directory. If using `--output`, ensure that path is mounted to an accessible host directory (e.g., `-v "$(pwd)/custom-directory:/app/reports"`).
- **Pre-reconnaissance reports** - External scan results
- **Vulnerability assessments** - Potential vulnerabilities from thorough code analysis and network mapping

0
audit-logs/.gitkeep Normal file
View File

View File

@@ -26,6 +26,7 @@ export interface SessionMetadata {
id: string;
webUrl: string;
repoPath?: string;
outputPath?: string;
[key: string]: unknown;
}
@@ -40,10 +41,12 @@ export function generateSessionIdentifier(sessionMetadata: SessionMetadata): str
/**
* Generate path to audit log directory for a session
* Uses custom outputPath if provided, otherwise defaults to AUDIT_LOGS_DIR
*/
export function generateAuditPath(sessionMetadata: SessionMetadata): string {
const sessionIdentifier = generateSessionIdentifier(sessionMetadata);
return path.join(AUDIT_LOGS_DIR, sessionIdentifier);
const baseDir = sessionMetadata.outputPath || AUDIT_LOGS_DIR;
return path.join(baseDir, sessionIdentifier);
}
/**

View File

@@ -293,7 +293,7 @@ const runSingleAgent = async (
AGENTS[agentName]!.displayName,
agentName,
getAgentColor(agentName),
{ id: currentSession.id, webUrl: currentSession.webUrl, repoPath: currentSession.repoPath }
{ id: currentSession.id, webUrl: currentSession.webUrl, repoPath: currentSession.repoPath, ...(currentSession.outputPath && { outputPath: currentSession.outputPath }) }
);
if (!result.success) {
@@ -727,7 +727,8 @@ export const rollbackTo = async (targetAgent: string, session: Session): Promise
const sessionMetadata: SessionMetadata = {
id: session.id,
webUrl: session.webUrl,
repoPath: session.repoPath
repoPath: session.repoPath,
...(session.outputPath && { outputPath: session.outputPath })
};
const auditSession = new AuditSession(sessionMetadata);
await auditSession.initialize();

View File

@@ -33,6 +33,9 @@ export function showHelp(): void {
console.log(
' --config <file> YAML configuration file for authentication and testing parameters'
);
console.log(
' --output <path> Custom output directory for session folder (default: ./audit-logs/)'
);
console.log(
' --pipeline-testing Use minimal prompts for fast pipeline testing (creates minimal deliverables)'
);
@@ -55,6 +58,7 @@ export function showHelp(): void {
console.log(' # Normal mode - create new session');
console.log(' shannon "https://example.com" "/path/to/local/repo"');
console.log(' shannon "https://example.com" "/path/to/local/repo" --config auth.yaml');
console.log(' shannon "https://example.com" "/path/to/local/repo" --output /path/to/reports');
console.log(
' shannon "https://example.com" "/path/to/local/repo" --setup-only # Setup only\n'
);

View File

@@ -137,7 +137,8 @@ async function runPreReconWave1(
variables: PromptVariables,
config: DistributedConfig | null,
pipelineTestingMode: boolean = false,
sessionId: string | null = null
sessionId: string | null = null,
outputPath: string | null = null
): Promise<Wave1Results> {
console.log(chalk.blue(' → Launching Wave 1 operations in parallel...'));
@@ -155,7 +156,7 @@ async function runPreReconWave1(
AGENTS['pre-recon'].displayName,
'pre-recon', // Agent name for snapshot creation
chalk.cyan,
{ id: sessionId!, webUrl, repoPath: sourceDir } // Session metadata for audit logging (STANDARD: use 'id' field)
{ id: sessionId!, webUrl, repoPath: sourceDir, ...(outputPath && { outputPath }) } // Session metadata for audit logging (STANDARD: use 'id' field)
)
);
const [codeAnalysis] = await Promise.all(operations);
@@ -178,7 +179,7 @@ async function runPreReconWave1(
AGENTS['pre-recon'].displayName,
'pre-recon', // Agent name for snapshot creation
chalk.cyan,
{ id: sessionId!, webUrl, repoPath: sourceDir } // Session metadata for audit logging (STANDARD: use 'id' field)
{ id: sessionId!, webUrl, repoPath: sourceDir, ...(outputPath && { outputPath }) } // Session metadata for audit logging (STANDARD: use 'id' field)
)
);
}
@@ -357,13 +358,14 @@ export async function executePreReconPhase(
config: DistributedConfig | null,
toolAvailability: ToolAvailability,
pipelineTestingMode: boolean,
sessionId: string | null = null
sessionId: string | null = null,
outputPath: string | null = null
): Promise<PreReconResult> {
console.log(chalk.yellow.bold('\n🔍 PHASE 1: PRE-RECONNAISSANCE'));
const timer = new Timer('phase-1-pre-recon');
console.log(chalk.yellow('Wave 1: Initial footprinting...'));
const wave1Results = await runPreReconWave1(webUrl, sourceDir, variables, config, pipelineTestingMode, sessionId);
const wave1Results = await runPreReconWave1(webUrl, sourceDir, variables, config, pipelineTestingMode, sessionId, outputPath);
console.log(chalk.green(' ✅ Wave 1 operations completed'));
console.log(chalk.yellow('Wave 2: Additional scanning...'));

View File

@@ -41,6 +41,7 @@ export interface Session {
repoPath: string;
configFile: string | null;
targetRepo: string;
outputPath: string | null;
status: 'in-progress' | 'completed' | 'failed';
completedAgents: AgentName[];
failedAgents: AgentName[];
@@ -265,7 +266,8 @@ export const createSession = async (
webUrl: string,
repoPath: string,
configFile: string | null = null,
targetRepo: string | null = null
targetRepo: string | null = null,
outputPath: string | null = null
): Promise<Session> => {
// Use targetRepo if provided, otherwise use repoPath
const resolvedTargetRepo = targetRepo || repoPath;
@@ -279,9 +281,12 @@ export const createSession = async (
console.log(chalk.blue(`📝 Reusing existing session: ${existingSession.id.substring(0, 8)}...`));
console.log(chalk.gray(` Progress: ${existingSession.completedAgents.length}/${Object.keys(AGENTS).length} agents completed`));
// Update last activity timestamp
await updateSession(existingSession.id, { lastActivity: new Date().toISOString() });
return existingSession;
// Update last activity timestamp and outputPath if provided
await updateSession(existingSession.id, {
lastActivity: new Date().toISOString(),
...(outputPath && { outputPath })
});
return { ...existingSession, ...(outputPath && { outputPath }) };
}
// If completed, create a new session (allows re-running after completion)
@@ -298,6 +303,7 @@ export const createSession = async (
repoPath,
configFile,
targetRepo: resolvedTargetRepo,
outputPath,
status: 'in-progress',
completedAgents: [],
failedAgents: [],

View File

@@ -80,6 +80,27 @@ interface MainResult {
// Configure zx to disable timeouts (let tools run as long as needed)
$.timeout = 0;
/**
* Consolidate deliverables from target repo into the session folder
* Copies deliverables directory from source repo to session audit path
*/
async function consolidateOutputs(sourceDir: string, sessionPath: string): Promise<void> {
const srcDeliverables = path.join(sourceDir, 'deliverables');
const destDeliverables = path.join(sessionPath, 'deliverables');
try {
if (await fs.pathExists(srcDeliverables)) {
await fs.copy(srcDeliverables, destDeliverables, { overwrite: true });
console.log(chalk.gray(`📄 Deliverables copied to session folder`));
} else {
console.log(chalk.yellow(`⚠️ No deliverables directory found at ${srcDeliverables}`));
}
} catch (error) {
const err = error as Error;
console.log(chalk.yellow(`⚠️ Failed to consolidate deliverables: ${err.message}`));
}
}
// Setup graceful cleanup on process signals
process.on('SIGINT', async () => {
console.log(chalk.yellow('\n⚠ Received SIGINT, cleaning up...'));
@@ -99,7 +120,8 @@ async function main(
repoPath: string,
configPath: string | null = null,
pipelineTestingMode: boolean = false,
disableLoader: boolean = false
disableLoader: boolean = false,
outputPath: string | null = null
): Promise<MainResult> {
// Set global flag for loader control
global.SHANNON_DISABLE_LOADER = disableLoader;
@@ -116,6 +138,9 @@ async function main(
if (configPath) {
console.log(chalk.cyan(`⚙️ Config: ${configPath}`));
}
if (outputPath) {
console.log(chalk.cyan(`📂 Output: ${outputPath}`));
}
console.log(chalk.gray('─'.repeat(60)));
// Parse configuration if provided
@@ -166,7 +191,7 @@ async function main(
const variables: PromptVariables = { webUrl, repoPath, sourceDir };
// Create session for tracking (in normal mode)
const session: Session = await createSession(webUrl, repoPath, configPath, sourceDir);
const session: Session = await createSession(webUrl, repoPath, configPath, sourceDir, outputPath);
console.log(chalk.blue(`📝 Session created: ${session.id.substring(0, 8)}...`));
// If setup-only mode, exit after session creation
@@ -243,7 +268,8 @@ async function main(
distributedConfig,
toolAvailability,
pipelineTestingMode,
session.id // Pass session ID for logging
session.id, // Pass session ID for logging
outputPath // Pass output path for audit logging
);
timingResults.phases['pre-recon'] = preReconDuration;
await updateSessionProgress('pre-recon');
@@ -262,7 +288,7 @@ async function main(
AGENTS['recon'].displayName,
'recon', // Agent name for snapshot creation
chalk.cyan,
{ id: session.id, webUrl, repoPath: sourceDir } // Session metadata for audit logging (STANDARD: use 'id' field)
{ id: session.id, webUrl, repoPath: sourceDir, ...(outputPath && { outputPath }) } // Session metadata for audit logging (STANDARD: use 'id' field)
);
const reconDuration = reconTimer.stop();
timingResults.phases['recon'] = reconDuration;
@@ -345,7 +371,7 @@ async function main(
'Executive Summary and Report Cleanup',
'report', // Agent name for snapshot creation
chalk.cyan,
{ id: session.id, webUrl, repoPath: sourceDir } // Session metadata for audit logging (STANDARD: use 'id' field)
{ id: session.id, webUrl, repoPath: sourceDir, ...(outputPath && { outputPath }) } // Session metadata for audit logging (STANDARD: use 'id' field)
);
const reportDuration = reportTimer.stop();
@@ -380,7 +406,11 @@ async function main(
console.log(chalk.gray('─'.repeat(60)));
// Calculate audit logs path
const auditLogsPath = generateAuditPath({ id: session.id, webUrl: session.webUrl, repoPath: session.repoPath });
const auditLogsPath = generateAuditPath({ id: session.id, webUrl: session.webUrl, repoPath: session.repoPath, ...(outputPath && { outputPath }) });
// Consolidate deliverables into the session folder
await consolidateOutputs(sourceDir, auditLogsPath);
console.log(chalk.green(`\n📂 All outputs consolidated: ${auditLogsPath}`));
// Return final report path and audit logs path for clickable output
return {
@@ -398,6 +428,7 @@ if (args[0] && args[0].includes('shannon')) {
// Parse flags and arguments
let configPath: string | null = null;
let outputPath: string | null = null;
let pipelineTestingMode = false;
let disableLoader = false;
const nonFlagArgs: string[] = [];
@@ -413,6 +444,14 @@ for (let i = 0; i < args.length; i++) {
console.log(chalk.red('❌ --config flag requires a file path'));
process.exit(1);
}
} else if (args[i] === '--output') {
if (i + 1 < args.length) {
outputPath = path.resolve(args[i + 1]!);
i++; // Skip the next argument
} else {
console.log(chalk.red('❌ --output flag requires a directory path'));
process.exit(1);
}
} else if (args[i] === '--pipeline-testing') {
pipelineTestingMode = true;
} else if (args[i] === '--disable-loader') {
@@ -494,6 +533,9 @@ console.log(chalk.green('✅ Input validation passed:'));
console.log(chalk.gray(` Target Web URL: ${webUrl}`));
console.log(chalk.gray(` Target Repository: ${repoPathValidation.path}\n`));
console.log(chalk.gray(` Config Path: ${configPath}\n`));
if (outputPath) {
console.log(chalk.gray(` Output Path: ${outputPath}\n`));
}
if (pipelineTestingMode) {
console.log(chalk.yellow('⚡ PIPELINE TESTING MODE ENABLED - Using minimal test prompts for fast pipeline validation\n'));
}
@@ -502,7 +544,7 @@ if (disableLoader) {
}
try {
const result = await main(webUrl!, repoPathValidation.path!, configPath, pipelineTestingMode, disableLoader);
const result = await main(webUrl!, repoPathValidation.path!, configPath, pipelineTestingMode, disableLoader, outputPath);
console.log(chalk.green.bold('\n📄 FINAL REPORT AVAILABLE:'));
console.log(chalk.cyan(result.reportPath));
console.log(chalk.green.bold('\n📂 AUDIT LOGS AVAILABLE:'));