feat: mount user repo as read-only with writable shannon overlay (#273)
* feat: mount user repo as read-only with deliverables bind-mount overlay * feat: add playground and .playwright-cli overlay mounts * feat: add filesystem context to pipeline-testing prompts * fix: use explicit REPO_PATH in filesystem prompt for clarity * fix: update filesystem prompts with playground notes and absolute screenshot paths * feat: namespace writable overlays under .shannon/ to avoid polluting host repo * refactor: rename playground to scratchpad * fix: redirect playwright-cli output to writable .shannon/ overlay * fix: pre-create .shannon/ overlay mount points for Linux compatibility * fix: exclude nested node_modules and dist from Docker build context * fix: enforce LF line endings for shell scripts on Windows
This commit is contained in:
@@ -72,7 +72,7 @@ async function writeErrorLog(
|
||||
},
|
||||
duration,
|
||||
};
|
||||
const logPath = path.join(sourceDir, 'error.log');
|
||||
const logPath = path.join(sourceDir, '.shannon', 'deliverables', 'error.log');
|
||||
await fs.appendFile(logPath, `${JSON.stringify(errorLog)}\n`);
|
||||
} catch {
|
||||
// Best-effort error log writing - don't propagate failures
|
||||
@@ -152,6 +152,7 @@ export async function runClaudePrompt(
|
||||
// 3. Build env vars to pass to SDK subprocesses
|
||||
const sdkEnv: Record<string, string> = {
|
||||
CLAUDE_CODE_MAX_OUTPUT_TOKENS: process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS || '64000',
|
||||
PLAYWRIGHT_MCP_OUTPUT_DIR: path.join(sourceDir, '.shannon', '.playwright-cli'),
|
||||
};
|
||||
const passthroughVars = [
|
||||
'ANTHROPIC_API_KEY',
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
* All functions are pure and crash-safe.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { WORKSPACES_DIR } from '../paths.js';
|
||||
import { ensureDirectory } from '../utils/file-io.js';
|
||||
@@ -98,33 +97,3 @@ export async function initializeAuditStructure(sessionMetadata: SessionMetadata)
|
||||
await ensureDirectory(promptsPath);
|
||||
await ensureDirectory(deliverablesPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy deliverable files from repo to workspaces for self-contained audit trail.
|
||||
* No-ops if source directory doesn't exist. Idempotent and parallel-safe.
|
||||
*/
|
||||
export async function copyDeliverablesToAudit(sessionMetadata: SessionMetadata, repoPath: string): Promise<void> {
|
||||
const sourceDir = path.join(repoPath, 'deliverables');
|
||||
const destDir = path.join(generateAuditPath(sessionMetadata), 'deliverables');
|
||||
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await fs.readdir(sourceDir);
|
||||
} catch {
|
||||
// Source directory doesn't exist yet — nothing to copy
|
||||
return;
|
||||
}
|
||||
|
||||
await ensureDirectory(destDir);
|
||||
|
||||
for (const entry of entries) {
|
||||
const sourcePath = path.join(sourceDir, entry);
|
||||
const destPath = path.join(destDir, entry);
|
||||
|
||||
// Only copy files, skip subdirectories
|
||||
const stat = await fs.stat(sourcePath);
|
||||
if (stat.isFile()) {
|
||||
await fs.copyFile(sourcePath, destPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ function parseArgs(argv: string[]): ParsedArgs {
|
||||
// === File Operations ===
|
||||
|
||||
function saveDeliverableFile(targetDir: string, filename: string, content: string): string {
|
||||
const deliverablesDir = join(targetDir, 'deliverables');
|
||||
const deliverablesDir = join(targetDir, '.shannon', 'deliverables');
|
||||
const filepath = join(deliverablesDir, filename);
|
||||
|
||||
try {
|
||||
|
||||
@@ -44,6 +44,7 @@ import { loadPrompt } from './prompt-manager.js';
|
||||
export interface AgentExecutionInput {
|
||||
webUrl: string;
|
||||
repoPath: string;
|
||||
deliverablesPath: string;
|
||||
configPath?: string | undefined;
|
||||
pipelineTestingMode?: boolean | undefined;
|
||||
attemptNumber: number;
|
||||
@@ -89,7 +90,7 @@ export class AgentExecutionService {
|
||||
auditSession: AuditSession,
|
||||
logger: ActivityLogger,
|
||||
): Promise<Result<AgentEndResult, PentestError>> {
|
||||
const { webUrl, repoPath, configPath, pipelineTestingMode = false, attemptNumber } = input;
|
||||
const { webUrl, repoPath, deliverablesPath, configPath, pipelineTestingMode = false, attemptNumber } = input;
|
||||
|
||||
// 1. Load config (if provided)
|
||||
const configResult = await this.configLoader.loadOptional(configPath);
|
||||
@@ -118,7 +119,7 @@ export class AgentExecutionService {
|
||||
|
||||
// 3. Create git checkpoint before execution
|
||||
try {
|
||||
await createGitCheckpoint(repoPath, agentName, attemptNumber, logger);
|
||||
await createGitCheckpoint(deliverablesPath, agentName, attemptNumber, logger);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return err(
|
||||
@@ -126,7 +127,7 @@ export class AgentExecutionService {
|
||||
`Failed to create git checkpoint for ${agentName}: ${errorMessage}`,
|
||||
'filesystem',
|
||||
false,
|
||||
{ agentName, repoPath, originalError: errorMessage },
|
||||
{ agentName, deliverablesPath, originalError: errorMessage },
|
||||
ErrorCode.GIT_CHECKPOINT_FAILED,
|
||||
),
|
||||
);
|
||||
@@ -153,7 +154,7 @@ export class AgentExecutionService {
|
||||
if (result.success && (result.turns ?? 0) <= 2 && (result.cost || 0) === 0) {
|
||||
const resultText = result.result || '';
|
||||
if (isSpendingCapBehavior(result.turns ?? 0, result.cost || 0, resultText)) {
|
||||
return this.failAgent(agentName, repoPath, auditSession, logger, {
|
||||
return this.failAgent(agentName, deliverablesPath, auditSession, logger, {
|
||||
attemptNumber,
|
||||
result,
|
||||
rollbackReason: 'spending cap detected',
|
||||
@@ -168,7 +169,7 @@ export class AgentExecutionService {
|
||||
|
||||
// 7. Handle execution failure
|
||||
if (!result.success) {
|
||||
return this.failAgent(agentName, repoPath, auditSession, logger, {
|
||||
return this.failAgent(agentName, deliverablesPath, auditSession, logger, {
|
||||
attemptNumber,
|
||||
result,
|
||||
rollbackReason: 'execution failure',
|
||||
@@ -183,7 +184,7 @@ export class AgentExecutionService {
|
||||
// 8. Write structured output to disk (vuln agents only)
|
||||
const queueFilename = getQueueFilename(agentName);
|
||||
if (result.structuredOutput !== undefined && queueFilename) {
|
||||
const deliverablesDir = path.join(repoPath, 'deliverables');
|
||||
const deliverablesDir = path.join(repoPath, '.shannon', 'deliverables');
|
||||
await fs.ensureDir(deliverablesDir);
|
||||
const queuePath = path.join(deliverablesDir, queueFilename);
|
||||
await fs.writeFile(queuePath, JSON.stringify(result.structuredOutput, null, 2), 'utf8');
|
||||
@@ -193,7 +194,7 @@ export class AgentExecutionService {
|
||||
// 9. Validate output
|
||||
const validationPassed = await validateAgentOutput(result, agentName, repoPath, logger);
|
||||
if (!validationPassed) {
|
||||
return this.failAgent(agentName, repoPath, auditSession, logger, {
|
||||
return this.failAgent(agentName, deliverablesPath, auditSession, logger, {
|
||||
attemptNumber,
|
||||
result,
|
||||
rollbackReason: 'validation failure',
|
||||
@@ -206,8 +207,8 @@ export class AgentExecutionService {
|
||||
}
|
||||
|
||||
// 10. Success - commit deliverables, then capture checkpoint hash
|
||||
await commitGitSuccess(repoPath, agentName, logger);
|
||||
const commitHash = await getGitCommitHash(repoPath);
|
||||
await commitGitSuccess(deliverablesPath, agentName, logger);
|
||||
const commitHash = await getGitCommitHash(deliverablesPath);
|
||||
|
||||
const endResult: AgentEndResult = {
|
||||
attemptNumber,
|
||||
@@ -224,12 +225,12 @@ export class AgentExecutionService {
|
||||
|
||||
private async failAgent(
|
||||
agentName: AgentName,
|
||||
repoPath: string,
|
||||
deliverablesPath: string,
|
||||
auditSession: AuditSession,
|
||||
logger: ActivityLogger,
|
||||
opts: FailAgentOpts,
|
||||
): Promise<Result<AgentEndResult, PentestError>> {
|
||||
await rollbackGitWorkspace(repoPath, opts.rollbackReason, logger);
|
||||
await rollbackGitWorkspace(deliverablesPath, opts.rollbackReason, logger);
|
||||
|
||||
const endResult: AgentEndResult = {
|
||||
attemptNumber: opts.attemptNumber,
|
||||
|
||||
@@ -133,8 +133,8 @@ const createPaths = (vulnType: VulnType, sourceDir: string): PathsBase | PathsWi
|
||||
|
||||
return Object.freeze({
|
||||
vulnType,
|
||||
deliverable: path.join(sourceDir, 'deliverables', config.deliverable),
|
||||
queue: path.join(sourceDir, 'deliverables', config.queue),
|
||||
deliverable: path.join(sourceDir, '.shannon', 'deliverables', config.deliverable),
|
||||
queue: path.join(sourceDir, '.shannon', 'deliverables', config.queue),
|
||||
sourceDir,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -28,7 +28,7 @@ export async function assembleFinalReport(sourceDir: string, logger: ActivityLog
|
||||
const sections: string[] = [];
|
||||
|
||||
for (const file of deliverableFiles) {
|
||||
const filePath = path.join(sourceDir, 'deliverables', file.path);
|
||||
const filePath = path.join(sourceDir, '.shannon', 'deliverables', file.path);
|
||||
try {
|
||||
if (await fs.pathExists(filePath)) {
|
||||
const content = await fs.readFile(filePath, 'utf8');
|
||||
@@ -55,7 +55,7 @@ export async function assembleFinalReport(sourceDir: string, logger: ActivityLog
|
||||
}
|
||||
|
||||
const finalContent = sections.join('\n\n');
|
||||
const deliverablesDir = path.join(sourceDir, 'deliverables');
|
||||
const deliverablesDir = path.join(sourceDir, '.shannon', 'deliverables');
|
||||
const finalReportPath = path.join(deliverablesDir, 'comprehensive_security_assessment_report.md');
|
||||
|
||||
try {
|
||||
@@ -117,7 +117,7 @@ export async function injectModelIntoReport(
|
||||
logger.info(`Injecting model info into report: ${modelStr}`);
|
||||
|
||||
// 3. Read the final report
|
||||
const reportPath = path.join(repoPath, 'deliverables', 'comprehensive_security_assessment_report.md');
|
||||
const reportPath = path.join(repoPath, '.shannon', 'deliverables', 'comprehensive_security_assessment_report.md');
|
||||
|
||||
if (!(await fs.pathExists(reportPath))) {
|
||||
logger.warn('Final report not found, skipping model injection');
|
||||
|
||||
@@ -143,7 +143,7 @@ function createVulnValidator(vulnType: VulnType): AgentValidator {
|
||||
// Factory function for exploit deliverable validators
|
||||
function createExploitValidator(vulnType: VulnType): AgentValidator {
|
||||
return async (sourceDir: string): Promise<boolean> => {
|
||||
const evidenceFile = path.join(sourceDir, 'deliverables', `${vulnType}_exploitation_evidence.md`);
|
||||
const evidenceFile = path.join(sourceDir, '.shannon', 'deliverables', `${vulnType}_exploitation_evidence.md`);
|
||||
return await fs.pathExists(evidenceFile);
|
||||
};
|
||||
}
|
||||
@@ -179,13 +179,13 @@ export const PLAYWRIGHT_SESSION_MAPPING: Record<string, PlaywrightSession> = Obj
|
||||
export const AGENT_VALIDATORS: Record<AgentName, AgentValidator> = Object.freeze({
|
||||
// Pre-reconnaissance agent - validates the code analysis deliverable created by the agent
|
||||
'pre-recon': async (sourceDir: string): Promise<boolean> => {
|
||||
const codeAnalysisFile = path.join(sourceDir, 'deliverables', 'code_analysis_deliverable.md');
|
||||
const codeAnalysisFile = path.join(sourceDir, '.shannon', 'deliverables', 'code_analysis_deliverable.md');
|
||||
return await fs.pathExists(codeAnalysisFile);
|
||||
},
|
||||
|
||||
// Reconnaissance agent
|
||||
recon: async (sourceDir: string): Promise<boolean> => {
|
||||
const reconFile = path.join(sourceDir, 'deliverables', 'recon_deliverable.md');
|
||||
const reconFile = path.join(sourceDir, '.shannon', 'deliverables', 'recon_deliverable.md');
|
||||
return await fs.pathExists(reconFile);
|
||||
},
|
||||
|
||||
@@ -205,7 +205,7 @@ export const AGENT_VALIDATORS: Record<AgentName, AgentValidator> = Object.freeze
|
||||
|
||||
// Executive report agent
|
||||
report: async (sourceDir: string, logger: ActivityLogger): Promise<boolean> => {
|
||||
const reportFile = path.join(sourceDir, 'deliverables', 'comprehensive_security_assessment_report.md');
|
||||
const reportFile = path.join(sourceDir, '.shannon', 'deliverables', 'comprehensive_security_assessment_report.md');
|
||||
|
||||
const reportExists = await fs.pathExists(reportFile);
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import path from 'node:path';
|
||||
import { ApplicationFailure, Context, heartbeat } from '@temporalio/activity';
|
||||
import { AuditSession } from '../audit/index.js';
|
||||
import type { ResumeAttempt } from '../audit/metrics-tracker.js';
|
||||
import { copyDeliverablesToAudit, type SessionMetadata } from '../audit/utils.js';
|
||||
import type { SessionMetadata } from '../audit/utils.js';
|
||||
import type { WorkflowSummary } from '../audit/workflow-logger.js';
|
||||
import { getContainer, getOrCreateContainer, removeContainer } from '../services/container.js';
|
||||
import { classifyErrorForTemporal, PentestError } from '../services/error-handling.js';
|
||||
@@ -126,11 +126,13 @@ async function runAgentActivity(agentName: AgentName, input: ActivityInput): Pro
|
||||
await auditSession.initialize(workflowId);
|
||||
|
||||
// 3. Execute agent via service (throws PentestError on failure)
|
||||
const deliverablesPath = path.join(repoPath, '.shannon', 'deliverables');
|
||||
const endResult = await container.agentExecution.executeOrThrow(
|
||||
agentName,
|
||||
{
|
||||
webUrl,
|
||||
repoPath,
|
||||
deliverablesPath,
|
||||
configPath,
|
||||
pipelineTestingMode,
|
||||
attemptNumber,
|
||||
@@ -311,6 +313,31 @@ export async function runPreflightValidation(input: ActivityInput): Promise<void
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize a private git repository inside the workspace deliverables directory.
|
||||
* Idempotent — skips if .git already exists (resume case).
|
||||
*/
|
||||
export async function initDeliverableGit(input: ActivityInput): Promise<void> {
|
||||
const deliverablesPath = path.join(input.repoPath, '.shannon', 'deliverables');
|
||||
await fs.mkdir(deliverablesPath, { recursive: true });
|
||||
|
||||
// Check for .git directly inside deliverables, not parent repo's .git
|
||||
const dotGitPath = path.join(deliverablesPath, '.git');
|
||||
try {
|
||||
await fs.stat(dotGitPath);
|
||||
return;
|
||||
} catch {
|
||||
// .git doesn't exist, proceed with init
|
||||
}
|
||||
|
||||
await executeGitCommandWithRetry(['git', 'init'], deliverablesPath, 'init deliverables repo');
|
||||
await executeGitCommandWithRetry(
|
||||
['git', 'commit', '--allow-empty', '-m', '📍 Initial deliverables checkpoint'],
|
||||
deliverablesPath,
|
||||
'initial checkpoint',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble the final report by concatenating exploitation evidence files.
|
||||
*/
|
||||
@@ -426,7 +453,7 @@ export async function loadResumeState(
|
||||
}
|
||||
|
||||
const deliverableFilename = AGENTS[agentName].deliverableFilename;
|
||||
const deliverablePath = `${expectedRepoPath}/deliverables/${deliverableFilename}`;
|
||||
const deliverablePath = `${expectedRepoPath}/.shannon/deliverables/${deliverableFilename}`;
|
||||
const deliverableExists = await fileExists(deliverablePath);
|
||||
|
||||
if (!deliverableExists) {
|
||||
@@ -460,7 +487,8 @@ export async function loadResumeState(
|
||||
}
|
||||
|
||||
// 5. Find the most recent checkpoint commit
|
||||
const checkpointHash = await findLatestCommit(expectedRepoPath, checkpoints);
|
||||
const deliverablesPath = path.join(expectedRepoPath, '.shannon', 'deliverables');
|
||||
const checkpointHash = await findLatestCommit(deliverablesPath, checkpoints);
|
||||
const originalWorkflowId = session.session.originalWorkflowId || session.session.id;
|
||||
|
||||
// 6. Log summary and return resume state
|
||||
@@ -480,7 +508,7 @@ export async function loadResumeState(
|
||||
};
|
||||
}
|
||||
|
||||
async function findLatestCommit(repoPath: string, commitHashes: string[]): Promise<string> {
|
||||
async function findLatestCommit(gitDir: string, commitHashes: string[]): Promise<string> {
|
||||
if (commitHashes.length === 1) {
|
||||
const hash = commitHashes[0];
|
||||
if (!hash) {
|
||||
@@ -497,7 +525,7 @@ async function findLatestCommit(repoPath: string, commitHashes: string[]): Promi
|
||||
|
||||
const result = await executeGitCommandWithRetry(
|
||||
['git', 'rev-list', '--max-count=1', ...commitHashes],
|
||||
repoPath,
|
||||
gitDir,
|
||||
'find latest commit',
|
||||
);
|
||||
|
||||
@@ -505,26 +533,29 @@ async function findLatestCommit(repoPath: string, commitHashes: string[]): Promi
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore git workspace to a checkpoint and clean up partial deliverables.
|
||||
* Restore deliverables git to a checkpoint.
|
||||
* Operates on the private git inside workspace deliverables, not the user's repo.
|
||||
*/
|
||||
export async function restoreGitCheckpoint(
|
||||
repoPath: string,
|
||||
checkpointHash: string,
|
||||
incompleteAgents: AgentName[],
|
||||
): Promise<void> {
|
||||
const deliverablesPath = path.join(repoPath, '.shannon', 'deliverables');
|
||||
const logger = createActivityLogger();
|
||||
logger.info(`Restoring git workspace to ${checkpointHash}...`);
|
||||
logger.info(`Restoring deliverables to ${checkpointHash}...`);
|
||||
|
||||
await executeGitCommandWithRetry(
|
||||
['git', 'reset', '--hard', checkpointHash],
|
||||
repoPath,
|
||||
'reset to checkpoint for resume',
|
||||
deliverablesPath,
|
||||
'reset deliverables to checkpoint',
|
||||
);
|
||||
await executeGitCommandWithRetry(['git', 'clean', '-fd'], repoPath, 'clean untracked files for resume');
|
||||
await executeGitCommandWithRetry(['git', 'clean', '-fd'], deliverablesPath, 'clean untracked deliverables');
|
||||
|
||||
// Explicitly delete partial deliverables for incomplete agents
|
||||
for (const agentName of incompleteAgents) {
|
||||
const deliverableFilename = AGENTS[agentName].deliverableFilename;
|
||||
const deliverablePath = `${repoPath}/deliverables/${deliverableFilename}`;
|
||||
const deliverablePath = path.join(deliverablesPath, deliverableFilename);
|
||||
try {
|
||||
const exists = await fileExists(deliverablePath);
|
||||
if (exists) {
|
||||
@@ -536,7 +567,7 @@ export async function restoreGitCheckpoint(
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Workspace restored to clean state');
|
||||
logger.info('Deliverables restored to clean state');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -589,7 +620,7 @@ export async function logPhaseTransition(
|
||||
* Cleans up container when done.
|
||||
*/
|
||||
export async function logWorkflowComplete(input: ActivityInput, summary: WorkflowSummary): Promise<void> {
|
||||
const { repoPath, workflowId } = input;
|
||||
const { workflowId } = input;
|
||||
const sessionMetadata = buildSessionMetadata(input);
|
||||
|
||||
// 1. Initialize audit session and mark final status
|
||||
@@ -631,16 +662,6 @@ export async function logWorkflowComplete(input: ActivityInput, summary: Workflo
|
||||
// 5. Write completion entry to workflow.log
|
||||
await auditSession.logWorkflowComplete(cumulativeSummary);
|
||||
|
||||
// 6. Copy deliverables to workspaces
|
||||
try {
|
||||
await copyDeliverablesToAudit(sessionMetadata, repoPath);
|
||||
} catch (copyErr) {
|
||||
const logger = createActivityLogger();
|
||||
logger.error('Failed to copy deliverables to workspaces', {
|
||||
error: copyErr instanceof Error ? copyErr.message : String(copyErr),
|
||||
});
|
||||
}
|
||||
|
||||
// 7. Clean up container
|
||||
// 6. Clean up container
|
||||
removeContainer(workflowId);
|
||||
}
|
||||
|
||||
@@ -360,7 +360,7 @@ async function waitForWorkflowResult(
|
||||
// === Deliverables Copy ===
|
||||
|
||||
function copyDeliverables(repoPath: string, outputPath: string): void {
|
||||
const deliverablesDir = path.join(repoPath, 'deliverables');
|
||||
const deliverablesDir = path.join(repoPath, '.shannon', 'deliverables');
|
||||
if (!fs.existsSync(deliverablesDir)) {
|
||||
console.log('No deliverables directory found, skipping copy');
|
||||
return;
|
||||
@@ -375,6 +375,7 @@ function copyDeliverables(repoPath: string, outputPath: string): void {
|
||||
fs.mkdirSync(outputPath, { recursive: true });
|
||||
|
||||
for (const file of files) {
|
||||
if (file === '.git') continue;
|
||||
const src = path.join(deliverablesDir, file);
|
||||
const dest = path.join(outputPath, file);
|
||||
fs.cpSync(src, dest, { recursive: true });
|
||||
|
||||
@@ -362,6 +362,9 @@ export async function pentestPipelineWorkflow(input: PipelineInput): Promise<Pip
|
||||
await preflightActs.runPreflightValidation(activityInput);
|
||||
log.info('Preflight validation passed');
|
||||
|
||||
// === Initialize Deliverables Git ===
|
||||
await a.initDeliverableGit(activityInput);
|
||||
|
||||
// === Phase 1: Pre-Reconnaissance ===
|
||||
await runSequentialPhase('pre-recon', 'pre-recon', a.runPreReconAgent);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user