mirror of
https://github.com/KeygraphHQ/shannon.git
synced 2026-07-25 05:50:52 +02:00
feat(cli): restructure run folder and improve terminal UX (#384)
* feat: surface report at run root and nest run internals under .shannon * feat: use plain-language wording in user-facing terminal messages * feat(cli): guide users to watch scan progress and surface report path on start * docs: sync run-folder layout and CLI wording across docs and comments * feat(cli): add version command reporting package version or git SHA * feat(cli): detect TTY for interactive prompts, color, and progress output * docs: document --yes flag, version command, and tty module * fix(cli): FORCE_COLOR precedence and plain uninstall --yes output * fix(cli): respect empty NO_COLOR * fix(cli): let NO_COLOR take precedence over FORCE_COLOR * docs: mark claude-code-router integration as removed
This commit is contained in:
@@ -12,7 +12,7 @@
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
import { WORKSPACES_DIR } from '../paths.js';
|
||||
import { INTERNAL_DIR, WORKSPACES_DIR } from '../paths.js';
|
||||
import { ensureDirectory } from '../utils/file-io.js';
|
||||
|
||||
export type { SessionMetadata } from '../types/audit.js';
|
||||
@@ -35,8 +35,9 @@ export function generateSessionIdentifier(sessionMetadata: SessionMetadata): str
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate path to audit log directory for a session
|
||||
* Uses custom outputPath if provided, otherwise defaults to WORKSPACES_DIR
|
||||
* Generate path to a run directory for a session (its top level).
|
||||
* Uses custom outputPath if provided, otherwise defaults to WORKSPACES_DIR.
|
||||
* Only the final report lives here; all internals live under INTERNAL_DIR.
|
||||
*/
|
||||
export function generateAuditPath(sessionMetadata: SessionMetadata): string {
|
||||
const sessionIdentifier = generateSessionIdentifier(sessionMetadata);
|
||||
@@ -44,6 +45,14 @@ export function generateAuditPath(sessionMetadata: SessionMetadata): string {
|
||||
return path.join(baseDir, sessionIdentifier);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate path to the hidden internals directory inside a run directory.
|
||||
* Holds logs, prompts, session state, deliverables, and browser artifacts.
|
||||
*/
|
||||
export function generateInternalPath(sessionMetadata: SessionMetadata): string {
|
||||
return path.join(generateAuditPath(sessionMetadata), INTERNAL_DIR);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate path to agent log file
|
||||
*/
|
||||
@@ -53,25 +62,25 @@ export function generateLogPath(
|
||||
timestamp: number,
|
||||
attemptNumber: number,
|
||||
): string {
|
||||
const auditPath = generateAuditPath(sessionMetadata);
|
||||
const internalPath = generateInternalPath(sessionMetadata);
|
||||
const filename = `${timestamp}_${agentName}_attempt-${attemptNumber}.log`;
|
||||
return path.join(auditPath, 'agents', filename);
|
||||
return path.join(internalPath, 'agents', filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate path to prompt snapshot file
|
||||
*/
|
||||
export function generatePromptPath(sessionMetadata: SessionMetadata, agentName: string): string {
|
||||
const auditPath = generateAuditPath(sessionMetadata);
|
||||
return path.join(auditPath, 'prompts', `${agentName}.md`);
|
||||
const internalPath = generateInternalPath(sessionMetadata);
|
||||
return path.join(internalPath, 'prompts', `${agentName}.md`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate path to session.json file
|
||||
*/
|
||||
export function generateSessionJsonPath(sessionMetadata: SessionMetadata): string {
|
||||
const auditPath = generateAuditPath(sessionMetadata);
|
||||
return path.join(auditPath, 'session.json');
|
||||
const internalPath = generateInternalPath(sessionMetadata);
|
||||
return path.join(internalPath, 'session.json');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -79,29 +88,28 @@ export function generateSessionJsonPath(sessionMetadata: SessionMetadata): strin
|
||||
* validator and consumed by downstream agents via `_shared-session.txt`.
|
||||
*/
|
||||
export function authStateFile(sessionMetadata: SessionMetadata): string {
|
||||
return path.join(generateAuditPath(sessionMetadata), 'auth-state.json');
|
||||
return path.join(generateInternalPath(sessionMetadata), 'auth-state.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate path to workflow.log file
|
||||
*/
|
||||
export function generateWorkflowLogPath(sessionMetadata: SessionMetadata): string {
|
||||
const auditPath = generateAuditPath(sessionMetadata);
|
||||
return path.join(auditPath, 'workflow.log');
|
||||
const internalPath = generateInternalPath(sessionMetadata);
|
||||
return path.join(internalPath, 'workflow.log');
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize audit directory structure for a session
|
||||
* Creates: workspaces/{sessionId}/, agents/, prompts/, deliverables/
|
||||
* Initialize audit directory structure for a session.
|
||||
* Creates: workspaces/{sessionId}/.shannon/{agents,prompts}. The deliverables,
|
||||
* scratchpad, and browser dirs are created host-side and bind-mounted in.
|
||||
*/
|
||||
export async function initializeAuditStructure(sessionMetadata: SessionMetadata): Promise<void> {
|
||||
const auditPath = generateAuditPath(sessionMetadata);
|
||||
const agentsPath = path.join(auditPath, 'agents');
|
||||
const promptsPath = path.join(auditPath, 'prompts');
|
||||
const deliverablesPath = path.join(auditPath, 'deliverables');
|
||||
const internalPath = generateInternalPath(sessionMetadata);
|
||||
const agentsPath = path.join(internalPath, 'agents');
|
||||
const promptsPath = path.join(internalPath, 'prompts');
|
||||
|
||||
await ensureDirectory(auditPath);
|
||||
await ensureDirectory(internalPath);
|
||||
await ensureDirectory(agentsPath);
|
||||
await ensureDirectory(promptsPath);
|
||||
await ensureDirectory(deliverablesPath);
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ export class WorkflowLogger {
|
||||
private async writeHeader(): Promise<void> {
|
||||
const lines = [
|
||||
`================================================================================`,
|
||||
`Shannon Pentest - Workflow Log`,
|
||||
`Shannon Pentest - Scan Log`,
|
||||
`================================================================================`,
|
||||
`Workflow ID: ${this.workflowId ?? this.sessionMetadata.id}`,
|
||||
`Target URL: ${this.sessionMetadata.webUrl}`,
|
||||
@@ -337,7 +337,7 @@ export class WorkflowLogger {
|
||||
const lines: string[] = [
|
||||
'',
|
||||
'================================================================================',
|
||||
`Workflow ${status}`,
|
||||
`Scan ${status}`,
|
||||
'────────────────────────────────────────',
|
||||
`Workflow ID: ${this.workflowId ?? this.sessionMetadata.id}`,
|
||||
`Status: ${summary.status}`,
|
||||
|
||||
@@ -15,6 +15,36 @@ export const DEFAULT_DELIVERABLES_SUBDIR = '.shannon/deliverables';
|
||||
/** Default audit log directory */
|
||||
export const DEFAULT_AUDIT_DIR = './workspaces';
|
||||
|
||||
/**
|
||||
* Hidden subdirectory inside each run directory that holds all internals
|
||||
* (logs, prompts, session state, deliverables, browser artifacts). Keeps the
|
||||
* run folder's top level clean so only the final report is visible.
|
||||
*/
|
||||
export const INTERNAL_DIR = '.shannon';
|
||||
|
||||
/** Filename of the assembled report inside the deliverables dir (internal, source of the surfaced copy) */
|
||||
export const ASSEMBLED_REPORT_FILENAME = 'comprehensive_security_assessment_report.md';
|
||||
|
||||
/** Filename of the human-facing final report surfaced at the run directory root */
|
||||
export const FINAL_REPORT_FILENAME = 'Security-Assessment-Report.md';
|
||||
|
||||
/**
|
||||
* Resolve the session.json path for a run directory, preferring the current
|
||||
* `.shannon/` location and falling back to the legacy run-root location so
|
||||
* pre-restructure workspaces remain listable and resumable.
|
||||
*/
|
||||
export function resolveSessionJsonPath(runDir: string): string {
|
||||
const current = path.join(runDir, INTERNAL_DIR, 'session.json');
|
||||
if (fs.existsSync(current)) {
|
||||
return current;
|
||||
}
|
||||
const legacy = path.join(runDir, 'session.json');
|
||||
if (fs.existsSync(legacy)) {
|
||||
return legacy;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the deliverables directory for a given repoPath and optional subdir override.
|
||||
* @param repoPath - Absolute path to the target repository
|
||||
|
||||
@@ -20,4 +20,4 @@ export type { ContainerDependencies } from './container.js';
|
||||
export { Container, getContainer, getOrCreateContainer, removeContainer, setContainerFactory } from './container.js';
|
||||
export { ExploitationCheckerService } from './exploitation-checker.js';
|
||||
export { loadPrompt } from './prompt-manager.js';
|
||||
export { assembleFinalReport, injectModelIntoReport } from './reporting.js';
|
||||
export { assembleFinalReport, copyReportToRunRoot, injectModelIntoReport } from './reporting.js';
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// as published by the Free Software Foundation.
|
||||
|
||||
import { fs, path } from 'zx';
|
||||
import { deliverablesDir } from '../paths.js';
|
||||
import { ASSEMBLED_REPORT_FILENAME, deliverablesDir, FINAL_REPORT_FILENAME, resolveSessionJsonPath } from '../paths.js';
|
||||
import type { ActivityLogger } from '../types/activity-logger.js';
|
||||
import { ErrorCode } from '../types/errors.js';
|
||||
import { PentestError } from './error-handling.js';
|
||||
@@ -68,7 +68,7 @@ export async function assembleFinalReport(
|
||||
}
|
||||
|
||||
const finalContent = sections.join('\n\n');
|
||||
const finalReportPath = path.join(dir, 'comprehensive_security_assessment_report.md');
|
||||
const finalReportPath = path.join(dir, ASSEMBLED_REPORT_FILENAME);
|
||||
|
||||
try {
|
||||
await fs.ensureDir(dir);
|
||||
@@ -97,7 +97,7 @@ export async function injectModelIntoReport(
|
||||
logger: ActivityLogger,
|
||||
): Promise<void> {
|
||||
// 1. Read session.json to get model information
|
||||
const sessionJsonPath = path.join(outputPath, 'session.json');
|
||||
const sessionJsonPath = resolveSessionJsonPath(outputPath);
|
||||
|
||||
if (!(await fs.pathExists(sessionJsonPath))) {
|
||||
logger.warn('session.json not found, skipping model injection');
|
||||
@@ -129,10 +129,7 @@ export async function injectModelIntoReport(
|
||||
logger.info(`Injecting model info into report: ${modelStr}`);
|
||||
|
||||
// 3. Read the final report
|
||||
const reportPath = path.join(
|
||||
deliverablesDir(repoPath, deliverablesSubdir),
|
||||
'comprehensive_security_assessment_report.md',
|
||||
);
|
||||
const reportPath = path.join(deliverablesDir(repoPath, deliverablesSubdir), ASSEMBLED_REPORT_FILENAME);
|
||||
|
||||
if (!(await fs.pathExists(reportPath))) {
|
||||
logger.warn('Final report not found, skipping model injection');
|
||||
@@ -167,3 +164,26 @@ export async function injectModelIntoReport(
|
||||
// 5. Write modified report back
|
||||
await fs.writeFile(reportPath, reportContent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Surface the assembled report at the run directory's top level as the single
|
||||
* human-facing deliverable, so a customer opening the run folder sees only the
|
||||
* report. The source stays in the deliverables dir (git-checkpointed, used by resume).
|
||||
*/
|
||||
export async function copyReportToRunRoot(
|
||||
repoPath: string,
|
||||
deliverablesSubdir: string | undefined,
|
||||
runDir: string,
|
||||
logger: ActivityLogger,
|
||||
): Promise<void> {
|
||||
const source = path.join(deliverablesDir(repoPath, deliverablesSubdir), ASSEMBLED_REPORT_FILENAME);
|
||||
|
||||
if (!(await fs.pathExists(source))) {
|
||||
logger.warn(`Final report not found, skipping ${FINAL_REPORT_FILENAME}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const destination = path.join(runDir, FINAL_REPORT_FILENAME);
|
||||
await fs.copy(source, destination, { overwrite: true });
|
||||
logger.info(`Surfaced report at ${destination}`);
|
||||
}
|
||||
|
||||
@@ -22,10 +22,10 @@ import { writePlaywrightStealthConfig } from '../ai/playwright-config-writer.js'
|
||||
import { writeUserSettingsForCodePathAvoids } from '../ai/settings-writer.js';
|
||||
import { AuditSession } from '../audit/index.js';
|
||||
import type { ResumeAttempt } from '../audit/metrics-tracker.js';
|
||||
import { authStateFile, generateSessionJsonPath, type SessionMetadata } from '../audit/utils.js';
|
||||
import { authStateFile, generateAuditPath, generateSessionJsonPath, type SessionMetadata } from '../audit/utils.js';
|
||||
import type { WorkflowSummary } from '../audit/workflow-logger.js';
|
||||
import type { CheckpointContext } from '../interfaces/checkpoint-provider.js';
|
||||
import { DEFAULT_DELIVERABLES_SUBDIR, deliverablesDir } from '../paths.js';
|
||||
import { DEFAULT_DELIVERABLES_SUBDIR, deliverablesDir, resolveSessionJsonPath } from '../paths.js';
|
||||
import { getContainer, getOrCreateContainer, removeContainer } from '../services/container.js';
|
||||
import { classifyErrorForTemporal, PentestError } from '../services/error-handling.js';
|
||||
import { ExploitationCheckerService } from '../services/exploitation-checker.js';
|
||||
@@ -33,7 +33,7 @@ import { renderFindingsFromQueues } from '../services/findings-renderer.js';
|
||||
import { executeGitCommandWithRetry } from '../services/git-manager.js';
|
||||
import { runPreflightChecks } from '../services/preflight.js';
|
||||
import type { ExploitationDecision, VulnType } from '../services/queue-validation.js';
|
||||
import { assembleFinalReport, injectModelIntoReport } from '../services/reporting.js';
|
||||
import { assembleFinalReport, copyReportToRunRoot, injectModelIntoReport } from '../services/reporting.js';
|
||||
import { validateAuthentication } from '../services/validate-authentication.js';
|
||||
import { AGENTS } from '../session-manager.js';
|
||||
import type { AgentName } from '../types/agents.js';
|
||||
@@ -756,8 +756,8 @@ export async function loadResumeState(
|
||||
expectedRepoPath: string,
|
||||
deliverablesSubdir?: string,
|
||||
): Promise<ResumeState> {
|
||||
// 1. Validate workspace exists
|
||||
const sessionPath = path.join('./workspaces', workspaceName, 'session.json');
|
||||
// 1. Validate workspace exists (prefers .shannon/, falls back to legacy run-root layout)
|
||||
const sessionPath = resolveSessionJsonPath(path.join('./workspaces', workspaceName));
|
||||
|
||||
const exists = await fileExists(sessionPath);
|
||||
if (!exists) {
|
||||
@@ -1055,7 +1055,23 @@ export async function logWorkflowComplete(input: ActivityInput, summary: Workflo
|
||||
// 5. Write completion entry to workflow.log
|
||||
await auditSession.logWorkflowComplete(cumulativeSummary);
|
||||
|
||||
// 6. Drop the authenticated browser session
|
||||
// 6. Surface the final report at the run root. Done here (not in the report phase)
|
||||
// so it also runs when a resume skips an already-complete report phase.
|
||||
if (summary.status === 'completed') {
|
||||
try {
|
||||
await copyReportToRunRoot(
|
||||
input.repoPath,
|
||||
input.deliverablesSubdir,
|
||||
generateAuditPath(sessionMetadata),
|
||||
createActivityLogger(),
|
||||
);
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
console.warn(`Failed to surface report at run root: ${detail}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Drop the authenticated browser session
|
||||
try {
|
||||
await fs.rm(authStateFile(sessionMetadata), { force: true });
|
||||
} catch (error) {
|
||||
@@ -1063,7 +1079,7 @@ export async function logWorkflowComplete(input: ActivityInput, summary: Workflo
|
||||
console.warn(`Failed to clean up auth-state.json: ${detail}`);
|
||||
}
|
||||
|
||||
// 7. Clean up container
|
||||
// 8. Clean up container
|
||||
removeContainer(workflowId);
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ import { bundleWorkflowCode, NativeConnection, Worker } from '@temporalio/worker
|
||||
import dotenv from 'dotenv';
|
||||
import { sanitizeHostname } from '../audit/utils.js';
|
||||
import { parseConfig } from '../config-parser.js';
|
||||
import { deliverablesDir } from '../paths.js';
|
||||
import { ASSEMBLED_REPORT_FILENAME, deliverablesDir, FINAL_REPORT_FILENAME, resolveSessionJsonPath } from '../paths.js';
|
||||
import type { PipelineConfig, VulnClass } from '../types/config.js';
|
||||
import { fileExists, readJson } from '../utils/file-io.js';
|
||||
import * as activities from './activities.js';
|
||||
@@ -171,7 +171,7 @@ interface WorkspaceResolution {
|
||||
}
|
||||
|
||||
async function terminateExistingWorkflows(client: Client, workspaceName: string): Promise<string[]> {
|
||||
const sessionPath = path.join('./workspaces', workspaceName, 'session.json');
|
||||
const sessionPath = resolveSessionJsonPath(path.join('./workspaces', workspaceName));
|
||||
|
||||
if (!(await fileExists(sessionPath))) {
|
||||
throw new Error(`Workspace not found: ${workspaceName}\n` + `Expected path: ${sessionPath}`);
|
||||
@@ -192,16 +192,16 @@ async function terminateExistingWorkflows(client: Client, workspaceName: string)
|
||||
const description = await handle.describe();
|
||||
|
||||
if (description.status.name === 'RUNNING') {
|
||||
console.log(`Terminating running workflow: ${wfId}`);
|
||||
console.log(`Terminating running scan: ${wfId}`);
|
||||
await handle.terminate('Superseded by resume workflow');
|
||||
terminated.push(wfId);
|
||||
console.log(`Terminated: ${wfId}`);
|
||||
} else {
|
||||
console.log(`Workflow already ${description.status.name}: ${wfId}`);
|
||||
console.log(`Scan already ${description.status.name}: ${wfId}`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof WorkflowNotFoundError) {
|
||||
console.log(`Workflow not found (already cleaned up): ${wfId}`);
|
||||
console.log(`Scan not found (already cleaned up): ${wfId}`);
|
||||
} else {
|
||||
console.log(`Failed to terminate ${wfId}: ${error}`);
|
||||
}
|
||||
@@ -224,7 +224,7 @@ async function resolveWorkspace(client: Client, args: CliArgs): Promise<Workspac
|
||||
}
|
||||
|
||||
const workspace = args.resumeFromWorkspace;
|
||||
const sessionPath = path.join('./workspaces', workspace, 'session.json');
|
||||
const sessionPath = resolveSessionJsonPath(path.join('./workspaces', workspace));
|
||||
const workspaceExists = await fileExists(sessionPath);
|
||||
|
||||
if (workspaceExists) {
|
||||
@@ -233,7 +233,7 @@ async function resolveWorkspace(client: Client, args: CliArgs): Promise<Workspac
|
||||
|
||||
const terminatedWorkflows = await terminateExistingWorkflows(client, workspace);
|
||||
if (terminatedWorkflows.length > 0) {
|
||||
console.log(`Terminated ${terminatedWorkflows.length} previous workflow(s)\n`);
|
||||
console.log(`Terminated ${terminatedWorkflows.length} previous scan(s)\n`);
|
||||
}
|
||||
|
||||
const session = await readJson<SessionJson>(sessionPath);
|
||||
@@ -355,7 +355,9 @@ async function waitForWorkflowResult(
|
||||
|
||||
if (workspace.isResume) {
|
||||
try {
|
||||
const session = await readJson<SessionJson>(path.join('./workspaces', workspace.sessionId, 'session.json'));
|
||||
const session = await readJson<SessionJson>(
|
||||
resolveSessionJsonPath(path.join('./workspaces', workspace.sessionId)),
|
||||
);
|
||||
console.log(`Cumulative cost: $${session.metrics.total_cost_usd.toFixed(4)}`);
|
||||
} catch {
|
||||
// Non-fatal
|
||||
@@ -393,6 +395,12 @@ function copyDeliverables(repoPath: string, outputPath: string): void {
|
||||
fs.cpSync(src, dest, { recursive: true });
|
||||
}
|
||||
|
||||
// Surface the report under its human-facing name alongside the raw deliverables
|
||||
const assembledReport = path.join(outputDir, ASSEMBLED_REPORT_FILENAME);
|
||||
if (fs.existsSync(assembledReport)) {
|
||||
fs.copyFileSync(assembledReport, path.join(outputPath, FINAL_REPORT_FILENAME));
|
||||
}
|
||||
|
||||
console.log(`Copied ${files.length} deliverable(s) to ${outputPath}`);
|
||||
}
|
||||
|
||||
@@ -412,7 +420,7 @@ async function run(): Promise<void> {
|
||||
|
||||
try {
|
||||
// 3. Bundle workflows and create worker on per-invocation task queue
|
||||
console.log('Bundling workflows...');
|
||||
console.log('Preparing scan...');
|
||||
const workflowBundle = await bundleWorkflowCode({
|
||||
workflowsPath: path.join(__dirname, 'workflows.js'),
|
||||
});
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { WORKSPACES_DIR as DEFAULT_WORKSPACES_DIR } from '../paths.js';
|
||||
import { WORKSPACES_DIR as DEFAULT_WORKSPACES_DIR, resolveSessionJsonPath } from '../paths.js';
|
||||
|
||||
interface SessionJson {
|
||||
session: {
|
||||
@@ -82,7 +82,7 @@ async function listWorkspaces(): Promise<void> {
|
||||
const workspaces: WorkspaceInfo[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const sessionPath = path.join(workspacesDir, entry, 'session.json');
|
||||
const sessionPath = resolveSessionJsonPath(path.join(workspacesDir, entry));
|
||||
try {
|
||||
const content = await fs.readFile(sessionPath, 'utf8');
|
||||
const data = JSON.parse(content) as SessionJson;
|
||||
|
||||
Reference in New Issue
Block a user