mirror of
https://github.com/KeygraphHQ/shannon.git
synced 2026-06-10 01:13:57 +02:00
refactor: migrate to Turborepo + pnpm + Biome monorepo
Restructure into apps/worker, apps/cli, packages/mcp-server with Turborepo task orchestration, pnpm workspaces, Biome linting/formatting, and tsdown CLI bundling. Key changes: - src/ -> apps/worker/src/, cli/ -> apps/cli/, mcp-server/ -> packages/mcp-server/ - prompts/ and configs/ moved into apps/worker/ - npm replaced with pnpm, package-lock.json replaced with pnpm-lock.yaml - Dockerfile updated for pnpm-based builds - CLI logs command rewritten with chokidar for cross-platform reliability - Router health checking added for auto-detected router mode - Centralized path resolution via apps/worker/src/paths.ts
This commit is contained in:
+6
-5
@@ -47,11 +47,12 @@ ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# CLI package (runs on host, not in container)
|
||||
# Keep cli/package.json so npm workspaces resolve and npm ci works with the root lockfile
|
||||
cli/src/
|
||||
cli/dist/
|
||||
cli/infra/
|
||||
cli/tsconfig.json
|
||||
# Keep apps/cli/package.json so pnpm workspaces resolve
|
||||
apps/cli/src/
|
||||
apps/cli/dist/
|
||||
apps/cli/infra/
|
||||
apps/cli/tsconfig.json
|
||||
apps/cli/tsdown.config.ts
|
||||
|
||||
# Docker files (avoid recursive copying)
|
||||
Dockerfile*
|
||||
|
||||
@@ -26,16 +26,17 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm clean-install
|
||||
|
||||
- name: Verify dependency signatures
|
||||
run: npm audit signatures
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Probe semantic-release
|
||||
id: probe
|
||||
@@ -108,34 +109,35 @@ jobs:
|
||||
--certificate-identity https://github.com/${{ github.repository }}/.github/workflows/release.yml@${{ github.ref }} \
|
||||
keygraph/shannon@${{ steps.build.outputs.digest }}
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Configure npm registry
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
registry-url: https://registry.npmjs.org
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm clean-install
|
||||
|
||||
- name: Verify dependency signatures
|
||||
run: npm audit signatures
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Set CLI package version
|
||||
run: npm version "${{ needs.preflight.outputs.version }}" --workspace cli --no-git-tag-version --allow-same-version
|
||||
run: cd apps/cli && npm version "${{ needs.preflight.outputs.version }}" --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Sync lockfile with bumped versions
|
||||
run: npm i --package-lock-only
|
||||
- name: Sync lockfile with bumped version
|
||||
run: pnpm install --lockfile-only
|
||||
|
||||
- name: Build CLI
|
||||
run: npm run build:cli
|
||||
run: pnpm --filter @keygraph/shannon run build
|
||||
|
||||
- name: Publish npm package
|
||||
working-directory: cli
|
||||
working-directory: apps/cli
|
||||
run: |
|
||||
if npm view "@keygraph/shannon@${{ needs.preflight.outputs.version }}" version 2>/dev/null; then
|
||||
echo "Version already published, skipping"
|
||||
else
|
||||
npm publish --access public
|
||||
pnpm publish --access public --no-git-checks
|
||||
fi
|
||||
|
||||
release:
|
||||
@@ -151,13 +153,17 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm clean-install
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Create GitHub release
|
||||
env:
|
||||
|
||||
@@ -4,3 +4,4 @@ workspaces/
|
||||
credentials/
|
||||
dist/
|
||||
repos/
|
||||
.turbo/
|
||||
|
||||
+14
-8
@@ -3,13 +3,19 @@
|
||||
"plugins": [
|
||||
"@semantic-release/commit-analyzer",
|
||||
"@semantic-release/release-notes-generator",
|
||||
["@semantic-release/npm", {
|
||||
"npmPublish": false
|
||||
}],
|
||||
["@semantic-release/github", {
|
||||
"successCommentCondition": false,
|
||||
"failCommentCondition": false,
|
||||
"releasedLabels": false
|
||||
}]
|
||||
[
|
||||
"@semantic-release/npm",
|
||||
{
|
||||
"npmPublish": false
|
||||
}
|
||||
],
|
||||
[
|
||||
"@semantic-release/github",
|
||||
{
|
||||
"successCommentCondition": false,
|
||||
"failCommentCondition": false,
|
||||
"releasedLabels": false
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
@@ -13,13 +13,13 @@ Shannon supports two CLI modes, auto-detected based on the current working direc
|
||||
| | **npx** (`npx @keygraph/shannon`) | **Local** (`./shannon`) |
|
||||
|---|---|---|
|
||||
| **Install** | Zero-install via npm | Clone the repo |
|
||||
| **Image** | Pulled from Docker Hub (`keygraph/shannon:<version>`) | Built locally (`shannon-worker`) |
|
||||
| **Image** | Pulled from Docker Hub (`keygraph/shannon:latest`) | Built locally (`shannon-worker`) |
|
||||
| **State** | `~/.shannon/` | Project directory |
|
||||
| **Credentials** | `~/.shannon/config.toml` (via `shn setup`) or env vars | `./.env` |
|
||||
| **Config** | `~/.shannon/config.toml` (via `shn setup`) | N/A |
|
||||
| **Prompts** | Bundled in Docker image | Mounted from `./prompts/` (live-editable) |
|
||||
| **Prompts** | Bundled in Docker image | Mounted from `./apps/worker/prompts/` (live-editable) |
|
||||
|
||||
Mode detection: `./shannon` sets `SHANNON_LOCAL=1` env var; `cli/src/mode.ts` checks this to select local vs npx mode.
|
||||
Mode auto-detection: local mode activates when env var `SHANNON_LOCAL=1` is set by the `./shannon` entry point (`apps/cli/src/mode.ts`). Otherwise npx mode.
|
||||
|
||||
### npx Quick Start
|
||||
|
||||
@@ -45,7 +45,7 @@ echo "ANTHROPIC_API_KEY=your-key" > .env
|
||||
|
||||
# Run
|
||||
./shannon start -u <url> -r my-repo
|
||||
./shannon start -u <url> -r my-repo -c ./configs/my-config.yaml
|
||||
./shannon start -u <url> -r my-repo -c ./apps/worker/configs/my-config.yaml
|
||||
./shannon start -u <url> -r /any/path/to/repo
|
||||
```
|
||||
|
||||
@@ -75,55 +75,68 @@ npx @keygraph/shannon update # npx mode: pull latest image
|
||||
npx @keygraph/shannon uninstall # npx mode: remove ~/.shannon/ (confirms first)
|
||||
|
||||
# Build TypeScript (development)
|
||||
npm run build:all # Build core + CLI + MCP server
|
||||
pnpm run build # Build all packages via Turborepo
|
||||
pnpm run check # Type-check all packages
|
||||
pnpm biome # Biome lint + format + import sorting check
|
||||
pnpm biome:fix # Auto-fix lint, format, and import sorting
|
||||
```
|
||||
|
||||
TypeScript compiler options are shared via `tsconfig.base.json` at the root. All three packages extend it, overriding only `rootDir` and `outDir`. Shared devDependencies (`typescript`, `@types/node`) are hoisted to the root workspace.
|
||||
**Monorepo tooling:** pnpm workspaces, Turborepo for task orchestration, Biome for linting/formatting. TypeScript compiler options shared via `tsconfig.base.json` at the root. All packages extend it, overriding only `rootDir` and `outDir`. Shared devDependencies (`typescript`, `@types/node`, `turbo`, `@biomejs/biome`) are hoisted to the root workspace.
|
||||
|
||||
**Options:** `-c <file>` (YAML config), `-o <path>` (output directory), `-w <name>` (named workspace; auto-resumes if exists), `--pipeline-testing` (minimal prompts, 10s retries), `--router` (multi-model routing via [claude-code-router](https://github.com/musistudio/claude-code-router))
|
||||
|
||||
## Architecture
|
||||
|
||||
### CLI Package (`cli/`)
|
||||
Published as `@keygraph/shannon` on npm. Contains only Docker orchestration logic — no Temporal SDK, business logic, or prompts.
|
||||
### Monorepo Layout
|
||||
|
||||
- `cli/src/index.ts` — CLI dispatcher (`setup`, `start`, `stop`, `logs`, `workspaces`, `status`, `build`, `update`, `uninstall`, `info`)
|
||||
- `cli/src/mode.ts` — Auto-detection: local mode if `Dockerfile` + `docker-compose.yml` + `prompts/` exist in CWD
|
||||
- `cli/src/docker.ts` — Compose lifecycle, image pull/build, ephemeral `docker run` worker spawning
|
||||
- `cli/src/home.ts` — State directory management (`~/.shannon/` for npx, `./` for local)
|
||||
- `cli/src/env.ts` — `.env` loading, TOML fallback (npx only) via `cli/src/config/resolver.ts`, credential validation, env flag building
|
||||
- `cli/src/config/resolver.ts` — Cascading config (npx only): env vars → `~/.shannon/config.toml` (parsed with `smol-toml`)
|
||||
- `cli/src/config/writer.ts` — TOML serialization and secure file persistence (0o600)
|
||||
- `cli/src/commands/setup.ts` — Interactive TUI wizard (`@clack/prompts`) for provider credential setup (npx only)
|
||||
- `cli/src/paths.ts` — Repo/config path resolution (bare name → `./repos/<name>`, or any absolute/relative path)
|
||||
- `cli/src/commands/` — Command handlers
|
||||
- `cli/infra/compose.yml` — Bundled Temporal + router compose file for npx mode
|
||||
- `shannon` — Node.js entry point (`#!/usr/bin/env node`) that delegates to `cli/dist/index.js`
|
||||
```
|
||||
apps/cli/ — @keygraph/shannon (published to npm, bundled with tsdown)
|
||||
apps/worker/ — @shannon/worker (private, Temporal worker + pipeline logic)
|
||||
packages/mcp-server/ — @shannon/mcp-server (private, MCP tool server)
|
||||
```
|
||||
|
||||
### CLI Package (`apps/cli/`)
|
||||
Published as `@keygraph/shannon` on npm. Contains only Docker orchestration logic — no Temporal SDK, business logic, or prompts. Bundled with tsdown for single-file ESM output.
|
||||
|
||||
- `apps/cli/src/index.ts` — CLI dispatcher (`setup`, `start`, `stop`, `logs`, `workspaces`, `status`, `build`, `update`, `uninstall`, `info`)
|
||||
- `apps/cli/src/mode.ts` — Auto-detection: local mode if `SHANNON_LOCAL=1` env var is set
|
||||
- `apps/cli/src/docker.ts` — Compose lifecycle, image pull/build, ephemeral `docker run` worker spawning
|
||||
- `apps/cli/src/home.ts` — State directory management (`~/.shannon/` for npx, `./` for local)
|
||||
- `apps/cli/src/env.ts` — `.env` loading, TOML fallback (npx only) via `apps/cli/src/config/resolver.ts`, credential validation, env flag building
|
||||
- `apps/cli/src/config/resolver.ts` — Cascading config (npx only): env vars → `~/.shannon/config.toml` (parsed with `smol-toml`)
|
||||
- `apps/cli/src/config/writer.ts` — TOML serialization and secure file persistence (0o600)
|
||||
- `apps/cli/src/commands/setup.ts` — Interactive TUI wizard (`@clack/prompts`) for provider credential setup (npx only)
|
||||
- `apps/cli/src/paths.ts` — Repo/config path resolution (bare name → `./repos/<name>`, or any absolute/relative path)
|
||||
- `apps/cli/src/commands/` — Command handlers
|
||||
- `apps/cli/infra/compose.yml` — Bundled Temporal + router compose file for npx mode
|
||||
- `apps/cli/tsdown.config.ts` — tsdown bundler config
|
||||
- `shannon` — Node.js entry point (`#!/usr/bin/env node`) that delegates to `apps/cli/dist/index.mjs`
|
||||
|
||||
### Docker Architecture
|
||||
Infra (Temporal + router) runs via `docker-compose.yml`. Workers are ephemeral `docker run --rm` containers, one per scan, each with a unique task queue and isolated volume mounts.
|
||||
|
||||
- `docker-compose.yml` — Infra only: `shannon-temporal` (port 7233/8233) and `shannon-router` (port 3456, optional via profile). Network: `shannon-net`
|
||||
- `Dockerfile` — 2-stage build (builder + Chainguard Wolfi runtime). Entrypoint: `CMD ["node", "dist/temporal/worker.js"]`
|
||||
- `Dockerfile` — 2-stage build (builder + Chainguard Wolfi runtime). Uses pnpm. Entrypoint: `CMD ["node", "apps/worker/dist/temporal/worker.js"]`
|
||||
- No `docker-compose.docker.yml` — host gateway handled via `--add-host` flag in CLI
|
||||
|
||||
### Core Modules
|
||||
- `src/session-manager.ts` — Agent definitions (`AGENTS` record). Agent types in `src/types/agents.ts`
|
||||
- `src/config-parser.ts` — YAML config parsing with JSON Schema validation
|
||||
- `src/ai/claude-executor.ts` — Claude Agent SDK integration with retry logic
|
||||
- `src/services/` — Business logic layer (Temporal-agnostic). Activities delegate here. Key: `agent-execution.ts`, `error-handling.ts`, `container.ts`
|
||||
- `src/types/` — Consolidated types: `Result<T,E>`, `ErrorCode`, `AgentName`, `ActivityLogger`, etc.
|
||||
- `src/utils/` — Shared utilities (file I/O, formatting, concurrency)
|
||||
### Worker Package (`apps/worker/`)
|
||||
- `apps/worker/src/paths.ts` — Centralized path constants (`PROMPTS_DIR`, `CONFIGS_DIR`, `WORKSPACES_DIR`)
|
||||
- `apps/worker/src/session-manager.ts` — Agent definitions (`AGENTS` record). Agent types in `apps/worker/src/types/agents.ts`
|
||||
- `apps/worker/src/config-parser.ts` — YAML config parsing with JSON Schema validation
|
||||
- `apps/worker/src/ai/claude-executor.ts` — Claude Agent SDK integration with retry logic
|
||||
- `apps/worker/src/services/` — Business logic layer (Temporal-agnostic). Activities delegate here. Key: `agent-execution.ts`, `error-handling.ts`, `container.ts`
|
||||
- `apps/worker/src/types/` — Consolidated types: `Result<T,E>`, `ErrorCode`, `AgentName`, `ActivityLogger`, etc.
|
||||
- `apps/worker/src/utils/` — Shared utilities (file I/O, formatting, concurrency)
|
||||
|
||||
### Temporal Orchestration
|
||||
Durable workflow orchestration with crash recovery, queryable progress, intelligent retry, and parallel execution (5 concurrent agents in vuln/exploit phases).
|
||||
|
||||
- `src/temporal/workflows.ts` — Main workflow (`pentestPipelineWorkflow`)
|
||||
- `src/temporal/activities.ts` — Thin wrappers — heartbeat loop, error classification, container lifecycle. Business logic delegated to `src/services/`
|
||||
- `src/temporal/activity-logger.ts` — `TemporalActivityLogger` implementation of `ActivityLogger` interface
|
||||
- `src/temporal/summary-mapper.ts` — Maps `PipelineSummary` to `WorkflowSummary`
|
||||
- `src/temporal/worker.ts` — Combined worker + client entry point (per-invocation task queue, submits workflow, waits for result)
|
||||
- `src/temporal/shared.ts` — Types, interfaces, query definitions
|
||||
- `apps/worker/src/temporal/workflows.ts` — Main workflow (`pentestPipelineWorkflow`)
|
||||
- `apps/worker/src/temporal/activities.ts` — Thin wrappers — heartbeat loop, error classification, container lifecycle. Business logic delegated to `apps/worker/src/services/`
|
||||
- `apps/worker/src/temporal/activity-logger.ts` — `TemporalActivityLogger` implementation of `ActivityLogger` interface
|
||||
- `apps/worker/src/temporal/summary-mapper.ts` — Maps `PipelineSummary` to `WorkflowSummary`
|
||||
- `apps/worker/src/temporal/worker.ts` — Combined worker + client entry point (per-invocation task queue, submits workflow, waits for result)
|
||||
- `apps/worker/src/temporal/shared.ts` — Types, interfaces, query definitions
|
||||
### Five-Phase Pipeline
|
||||
|
||||
1. **Pre-Recon** (`pre-recon`) — External scans (nmap, subfinder, whatweb) + source code analysis
|
||||
@@ -133,24 +146,24 @@ Durable workflow orchestration with crash recovery, queryable progress, intellig
|
||||
5. **Reporting** (`report`) — Executive-level security report
|
||||
|
||||
### Supporting Systems
|
||||
- **Configuration** — YAML configs in `configs/` with JSON Schema validation (`config-schema.json`). Supports auth settings, MFA/TOTP, and per-app testing parameters. Credential resolution — local mode: env vars → `./.env`; npx mode: env vars → `~/.shannon/config.toml` (via `shn setup`)
|
||||
- **Prompts** — Per-phase templates in `prompts/` with variable substitution (`{{TARGET_URL}}`, `{{CONFIG_CONTEXT}}`). Shared partials in `prompts/shared/` via `src/services/prompt-manager.ts`
|
||||
- **SDK Integration** — Uses `@anthropic-ai/claude-agent-sdk` with `maxTurns: 10_000` and `bypassPermissions` mode. Playwright MCP for browser automation, TOTP generation via MCP tool. Login flow template at `prompts/shared/login-instructions.txt` supports form, SSO, API, and basic auth
|
||||
- **Audit System** — Crash-safe append-only logging in `workspaces/{hostname}_{sessionId}/`. Tracks session metrics, per-agent logs, prompts, and deliverables. WorkflowLogger (`audit/workflow-logger.ts`) provides unified human-readable per-workflow logs, backed by LogStream (`audit/log-stream.ts`) shared stream primitive
|
||||
- **Configuration** — YAML configs in `apps/worker/configs/` with JSON Schema validation (`config-schema.json`). Supports auth settings, MFA/TOTP, and per-app testing parameters. Credential resolution — local mode: env vars → `./.env`; npx mode: env vars → `~/.shannon/config.toml` (via `shn setup`)
|
||||
- **Prompts** — Per-phase templates in `apps/worker/prompts/` with variable substitution (`{{TARGET_URL}}`, `{{CONFIG_CONTEXT}}`). Shared partials in `apps/worker/prompts/shared/` via `apps/worker/src/services/prompt-manager.ts`
|
||||
- **SDK Integration** — Uses `@anthropic-ai/claude-agent-sdk` with `maxTurns: 10_000` and `bypassPermissions` mode. Playwright MCP for browser automation, TOTP generation via MCP tool. Login flow template at `apps/worker/prompts/shared/login-instructions.txt` supports form, SSO, API, and basic auth
|
||||
- **Audit System** — Crash-safe append-only logging in `workspaces/{hostname}_{sessionId}/`. Tracks session metrics, per-agent logs, prompts, and deliverables. WorkflowLogger (`apps/worker/src/audit/workflow-logger.ts`) provides unified human-readable per-workflow logs, backed by LogStream (`apps/worker/src/audit/log-stream.ts`) shared stream primitive
|
||||
- **Deliverables** — Saved to `deliverables/` in the target repo via the `save_deliverable` MCP tool
|
||||
- **Workspaces & Resume** — Named workspaces via `-w <name>` or auto-named from URL+timestamp. Resume detects completed agents via `session.json`. `loadResumeState()` in `src/temporal/activities.ts` validates deliverable existence, restores git checkpoints, and cleans up incomplete deliverables. Workspace listing via `src/temporal/workspaces.ts`
|
||||
- **Workspaces & Resume** — Named workspaces via `-w <name>` or auto-named from URL+timestamp. Resume detects completed agents via `session.json`. `loadResumeState()` in `apps/worker/src/temporal/activities.ts` validates deliverable existence, restores git checkpoints, and cleans up incomplete deliverables. Workspace listing via `apps/worker/src/temporal/workspaces.ts`
|
||||
|
||||
## Development Notes
|
||||
|
||||
### Adding a New Agent
|
||||
1. Define agent in `src/session-manager.ts` (add to `AGENTS` record). `ALL_AGENTS`/`AgentName` types live in `src/types/agents.ts`
|
||||
2. Create prompt template in `prompts/` (e.g., `vuln-newtype.txt`)
|
||||
3. Two-layer pattern: add a thin activity wrapper in `src/temporal/activities.ts` (heartbeat + error classification). `AgentExecutionService` in `src/services/agent-execution.ts` handles the agent lifecycle automatically via the `AGENTS` registry
|
||||
4. Register activity in `src/temporal/workflows.ts` within the appropriate phase
|
||||
1. Define agent in `apps/worker/src/session-manager.ts` (add to `AGENTS` record). `ALL_AGENTS`/`AgentName` types live in `apps/worker/src/types/agents.ts`
|
||||
2. Create prompt template in `apps/worker/prompts/` (e.g., `vuln-newtype.txt`)
|
||||
3. Two-layer pattern: add a thin activity wrapper in `apps/worker/src/temporal/activities.ts` (heartbeat + error classification). `AgentExecutionService` in `apps/worker/src/services/agent-execution.ts` handles the agent lifecycle automatically via the `AGENTS` registry
|
||||
4. Register activity in `apps/worker/src/temporal/workflows.ts` within the appropriate phase
|
||||
|
||||
### Modifying Prompts
|
||||
- Variable substitution: `{{TARGET_URL}}`, `{{CONFIG_CONTEXT}}`, `{{LOGIN_INSTRUCTIONS}}`
|
||||
- Shared partials in `prompts/shared/` included via `src/services/prompt-manager.ts`
|
||||
- Shared partials in `apps/worker/prompts/shared/` included via `apps/worker/src/services/prompt-manager.ts`
|
||||
- Test with `--pipeline-testing` for fast iteration
|
||||
|
||||
### Key Design Patterns
|
||||
@@ -158,8 +171,8 @@ Durable workflow orchestration with crash recovery, queryable progress, intellig
|
||||
- **Progressive Analysis** — Each phase builds on previous results
|
||||
- **SDK-First** — Claude Agent SDK handles autonomous analysis
|
||||
- **Modular Error Handling** — `ErrorCode` enum, `Result<T,E>` for explicit error propagation, automatic retry (3 attempts per agent)
|
||||
- **Services Boundary** — Activities are thin Temporal wrappers; `src/services/` owns business logic, accepts `ActivityLogger`, returns `Result<T,E>`. No Temporal imports in services
|
||||
- **DI Container** — Per-workflow in `src/services/container.ts`. `AuditSession` excluded (parallel safety)
|
||||
- **Services Boundary** — Activities are thin Temporal wrappers; `apps/worker/src/services/` owns business logic, accepts `ActivityLogger`, returns `Result<T,E>`. No Temporal imports in services
|
||||
- **DI Container** — Per-workflow in `apps/worker/src/services/container.ts`. `AuditSession` excluded (parallel safety)
|
||||
- **Ephemeral Workers** — Each scan runs in its own `docker run --rm` container with a per-invocation task queue. Temporal routes activities by queue name, so per-scan queues ensure activities never land on a worker with the wrong repo mounted
|
||||
|
||||
### Security
|
||||
@@ -167,6 +180,9 @@ Defensive security tool only. Use only on systems you own or have explicit permi
|
||||
|
||||
## Code Style Guidelines
|
||||
|
||||
### Formatting
|
||||
Biome handles formatting and linting. Run `pnpm biome:fix` to auto-fix. Config in `biome.json`: single quotes, semicolons, trailing commas, 2-space indent, 120 char line width.
|
||||
|
||||
### Clarity Over Brevity
|
||||
- Optimize for readability, not line count — three clear lines beat one dense expression
|
||||
- Use descriptive names that convey intent
|
||||
@@ -209,15 +225,15 @@ Comments must be **timeless** — no references to this conversation, refactorin
|
||||
|
||||
## Key Files
|
||||
|
||||
**CLI:** `shannon` (entry point), `cli/src/index.ts` (dispatcher), `cli/src/docker.ts` (orchestration), `cli/src/mode.ts` (auto-detection)
|
||||
**CLI:** `shannon` (entry point), `apps/cli/src/index.ts` (dispatcher), `apps/cli/src/docker.ts` (orchestration), `apps/cli/src/mode.ts` (auto-detection)
|
||||
|
||||
**Entry Points:** `src/temporal/workflows.ts`, `src/temporal/activities.ts`, `src/temporal/worker.ts`
|
||||
**Entry Points:** `apps/worker/src/temporal/workflows.ts`, `apps/worker/src/temporal/activities.ts`, `apps/worker/src/temporal/worker.ts`
|
||||
|
||||
**Core Logic:** `src/session-manager.ts`, `src/ai/claude-executor.ts`, `src/config-parser.ts`, `src/services/`, `src/audit/`
|
||||
**Core Logic:** `apps/worker/src/session-manager.ts`, `apps/worker/src/ai/claude-executor.ts`, `apps/worker/src/config-parser.ts`, `apps/worker/src/services/`, `apps/worker/src/audit/`
|
||||
|
||||
**Config:** `docker-compose.yml`, `cli/infra/compose.yml`, `configs/`, `prompts/`, `tsconfig.base.json` (shared compiler options)
|
||||
**Config:** `docker-compose.yml`, `apps/cli/infra/compose.yml`, `apps/worker/configs/`, `apps/worker/prompts/`, `tsconfig.base.json` (shared compiler options), `turbo.json`, `biome.json`
|
||||
|
||||
**CI/CD:** `.github/workflows/release.yml` (semantic-release: npm publish + Docker push + GitHub release), `.github/workflows/rollback.yml` (rollback npm and Docker latest tags)
|
||||
**CI/CD:** `.github/workflows/release.yml` (Docker Hub push + npm publish + GitHub release, manual dispatch)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
+26
-11
@@ -50,16 +50,27 @@ RUN git clone --depth 1 https://github.com/urbanadventurer/WhatWeb.git /opt/what
|
||||
# Install Python-based tools
|
||||
RUN pip3 install --no-cache-dir schemathesis
|
||||
|
||||
# Install pnpm
|
||||
RUN npm install -g pnpm@10
|
||||
|
||||
# Build Node.js application in builder to avoid QEMU emulation failures in CI
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
COPY cli/package.json ./cli/
|
||||
COPY mcp-server/package*.json ./mcp-server/
|
||||
RUN npm ci && npm cache clean --force
|
||||
|
||||
# Copy workspace manifests for install layer caching
|
||||
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml .npmrc ./
|
||||
COPY apps/worker/package.json ./apps/worker/
|
||||
COPY apps/cli/package.json ./apps/cli/
|
||||
COPY packages/mcp-server/package.json ./packages/mcp-server/
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY . .
|
||||
RUN cd mcp-server && npm run build && cd .. && npm run build
|
||||
RUN npm prune --production && \
|
||||
cd mcp-server && npm prune --production
|
||||
|
||||
# Build mcp-server (dependency) then worker. CLI not needed in Docker
|
||||
RUN pnpm --filter @shannon/mcp-server run build && \
|
||||
pnpm --filter @shannon/worker run build
|
||||
|
||||
RUN pnpm prune --prod
|
||||
|
||||
# Runtime stage - Minimal production image
|
||||
FROM cgr.dev/chainguard/wolfi-base:latest AS runtime
|
||||
@@ -119,13 +130,17 @@ RUN addgroup -g 1001 pentest && \
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy built application from builder
|
||||
COPY --from=builder /app /app
|
||||
# Copy only what the worker needs (skip CLI source, infra, tsdown artifacts)
|
||||
COPY --from=builder /app/package.json /app/pnpm-workspace.yaml /app/pnpm-lock.yaml /app/.npmrc /app/
|
||||
COPY --from=builder /app/node_modules /app/node_modules
|
||||
COPY --from=builder /app/apps/worker /app/apps/worker
|
||||
COPY --from=builder /app/apps/cli/package.json /app/apps/cli/package.json
|
||||
COPY --from=builder /app/packages /app/packages
|
||||
|
||||
RUN npm install -g @anthropic-ai/claude-code
|
||||
|
||||
# Create directories for session data and ensure proper permissions
|
||||
RUN mkdir -p /app/sessions /app/deliverables /app/repos /app/configs /app/workspaces && \
|
||||
RUN mkdir -p /app/sessions /app/deliverables /app/repos /app/workspaces && \
|
||||
mkdir -p /tmp/.cache /tmp/.config /tmp/.npm && \
|
||||
chmod 777 /app && \
|
||||
chmod 777 /tmp/.cache && \
|
||||
@@ -152,4 +167,4 @@ RUN git config --global user.email "agent@localhost" && \
|
||||
git config --global user.name "Pentest Agent" && \
|
||||
git config --global --add safe.directory '*'
|
||||
|
||||
CMD ["node", "dist/temporal/worker.js"]
|
||||
CMD ["node", "apps/worker/dist/temporal/worker.js"]
|
||||
|
||||
@@ -19,9 +19,7 @@
|
||||
"name": "openrouter",
|
||||
"api_base_url": "https://openrouter.ai/api/v1/chat/completions",
|
||||
"api_key": "$OPENROUTER_API_KEY",
|
||||
"models": [
|
||||
"google/gemini-3-flash-preview"
|
||||
],
|
||||
"models": ["google/gemini-3-flash-preview"],
|
||||
"transformer": {
|
||||
"use": ["openrouter"]
|
||||
}
|
||||
@@ -3,20 +3,22 @@
|
||||
"version": "0.0.0",
|
||||
"description": "Shannon - Autonomous white-box AI pentester for web applications and APIs by Keygraph",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"main": "dist/index.mjs",
|
||||
"bin": {
|
||||
"shannon": "dist/index.js"
|
||||
"shannon": "dist/index.mjs"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"infra"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch"
|
||||
"build": "tsdown",
|
||||
"check": "tsc --noEmit",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.1.0",
|
||||
"chokidar": "^5.0.0",
|
||||
"dotenv": "^17.3.1",
|
||||
"smol-toml": "^1.6.0"
|
||||
},
|
||||
@@ -37,9 +39,12 @@
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/KeygraphHQ/shannon.git",
|
||||
"directory": "cli"
|
||||
"directory": "apps/cli"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsdown": "^0.21.2"
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,8 @@
|
||||
* Only available in local mode (running from cloned repository).
|
||||
*/
|
||||
|
||||
import { isLocal } from '../mode.js';
|
||||
import { buildImage } from '../docker.js';
|
||||
import { isLocal } from '../mode.js';
|
||||
|
||||
export function build(noCache: boolean): void {
|
||||
if (!isLocal()) {
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* `shannon logs` command — tail a workspace's workflow log.
|
||||
*
|
||||
* Uses chokidar for reliable cross-platform file watching and
|
||||
* bounded synchronous reads to prevent duplicate output.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { watch } from 'chokidar';
|
||||
import { getWorkspacesDir } from '../home.js';
|
||||
|
||||
// Match the exact line the worker writes — anchored to prevent false positives from agent output
|
||||
const COMPLETION_PATTERN = /^Workflow (COMPLETED|FAILED)$/m;
|
||||
|
||||
/** Read a byte range from a file and return it as a UTF-8 string. */
|
||||
function readRange(filePath: string, start: number, end: number): string {
|
||||
const length = end - start;
|
||||
const buffer = Buffer.alloc(length);
|
||||
const fd = fs.openSync(filePath, 'r');
|
||||
try {
|
||||
fs.readSync(fd, buffer, 0, length, start);
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
return buffer.toString('utf-8');
|
||||
}
|
||||
|
||||
/** Resolve a workspace ID to its workflow.log path, or exit with an error. */
|
||||
function resolveLogFile(workspaceId: string): string {
|
||||
const workspacesDir = getWorkspacesDir();
|
||||
|
||||
// 1. Direct match
|
||||
const directPath = path.join(workspacesDir, workspaceId, 'workflow.log');
|
||||
if (fs.existsSync(directPath)) return directPath;
|
||||
|
||||
// 2. Resume workflow ID (e.g. workspace_resume_123)
|
||||
const resumeBase = workspaceId.replace(/_resume_\d+$/, '');
|
||||
if (resumeBase !== workspaceId) {
|
||||
const resumePath = path.join(workspacesDir, resumeBase, 'workflow.log');
|
||||
if (fs.existsSync(resumePath)) return resumePath;
|
||||
}
|
||||
|
||||
// 3. Named workspace ID (e.g. workspace_shannon-123)
|
||||
const namedBase = workspaceId.replace(/_shannon-\d+$/, '');
|
||||
if (namedBase !== workspaceId) {
|
||||
const namedPath = path.join(workspacesDir, namedBase, 'workflow.log');
|
||||
if (fs.existsSync(namedPath)) return namedPath;
|
||||
}
|
||||
|
||||
console.error(`ERROR: Workflow log not found for: ${workspaceId}`);
|
||||
console.error('');
|
||||
console.error('Possible causes:');
|
||||
console.error(" - Workflow hasn't started yet");
|
||||
console.error(' - Workspace ID is incorrect');
|
||||
console.error('');
|
||||
console.error('Check the Temporal Web UI at http://localhost:8233 for workflow details');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
export function logs(workspaceId: string): void {
|
||||
const logFile = resolveLogFile(workspaceId);
|
||||
let position = 0;
|
||||
|
||||
/**
|
||||
* Output any new content appended since the last read.
|
||||
* Returns true when the workflow completion marker is detected.
|
||||
*/
|
||||
function flush(): boolean {
|
||||
try {
|
||||
const { size } = fs.statSync(logFile);
|
||||
if (size <= position) return false;
|
||||
|
||||
const data = readRange(logFile, position, size);
|
||||
process.stdout.write(data);
|
||||
position = size;
|
||||
|
||||
return COMPLETION_PATTERN.test(data);
|
||||
} catch {
|
||||
// File deleted or unreadable — treat as done
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Tailing workflow log: ${logFile}`);
|
||||
|
||||
// 1. Output existing content
|
||||
if (flush()) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// 2. Watch for appended content via chokidar
|
||||
const watcher = watch(logFile, { persistent: true });
|
||||
|
||||
const shutdown = (): void => {
|
||||
watcher.close().finally(() => process.exit(0));
|
||||
// Safety net — force exit if watcher.close() stalls
|
||||
setTimeout(() => process.exit(0), 1000).unref();
|
||||
};
|
||||
|
||||
watcher.on('change', () => {
|
||||
if (flush()) shutdown();
|
||||
});
|
||||
|
||||
process.on('SIGINT', shutdown);
|
||||
}
|
||||
@@ -6,8 +6,8 @@
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import * as p from '@clack/prompts';
|
||||
import { type ShannonConfig, saveConfig } from '../config/writer.js';
|
||||
|
||||
@@ -42,10 +42,14 @@ export async function setup(): Promise<void> {
|
||||
|
||||
async function setupProvider(provider: Provider): Promise<ShannonConfig> {
|
||||
switch (provider) {
|
||||
case 'anthropic': return setupAnthropic();
|
||||
case 'bedrock': return setupBedrock();
|
||||
case 'vertex': return setupVertex();
|
||||
case 'router': return setupRouter();
|
||||
case 'anthropic':
|
||||
return setupAnthropic();
|
||||
case 'bedrock':
|
||||
return setupBedrock();
|
||||
case 'vertex':
|
||||
return setupVertex();
|
||||
case 'router':
|
||||
return setupRouter();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,7 +194,7 @@ async function setupRouter(): Promise<ShannonConfig> {
|
||||
if (p.isCancel(routerProvider)) return cancelAndExit();
|
||||
|
||||
const apiKey = await promptSecret(
|
||||
routerProvider === 'openai' ? 'Enter your OpenAI API key' : 'Enter your OpenRouter API key'
|
||||
routerProvider === 'openai' ? 'Enter your OpenAI API key' : 'Enter your OpenRouter API key',
|
||||
);
|
||||
|
||||
let defaultModel: string;
|
||||
@@ -207,9 +211,7 @@ async function setupRouter(): Promise<ShannonConfig> {
|
||||
} else {
|
||||
const model = await p.select({
|
||||
message: 'Default model',
|
||||
options: [
|
||||
{ value: 'google/gemini-3-flash-preview' as const, label: 'Google Gemini 3 Flash Preview' },
|
||||
],
|
||||
options: [{ value: 'google/gemini-3-flash-preview' as const, label: 'Google Gemini 3 Flash Preview' }],
|
||||
});
|
||||
if (p.isCancel(model)) return cancelAndExit();
|
||||
defaultModel = `openrouter,${model}`;
|
||||
@@ -5,14 +5,14 @@
|
||||
* and npx mode (Docker Hub pull, ~/.shannon/).
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { ensureImage, ensureInfra, randomSuffix, spawnWorker } from '../docker.js';
|
||||
import { buildEnvFlags, isRouterConfigured, loadEnv, validateCredentials } from '../env.js';
|
||||
import { getCredentialsDir, getCredentialsPath, getWorkspacesDir, initHome } from '../home.js';
|
||||
import { isLocal } from '../mode.js';
|
||||
import { initHome, getWorkspacesDir, getCredentialsPath, getCredentialsDir } from '../home.js';
|
||||
import { loadEnv, buildEnvFlags, validateCredentials, isRouterConfigured } from '../env.js';
|
||||
import { resolveRepo, resolveConfig, ensureDeliverables } from '../paths.js';
|
||||
import { ensureInfra, ensureImage, spawnWorker, randomSuffix } from '../docker.js';
|
||||
import { ensureDeliverables, resolveConfig, resolveRepo } from '../paths.js';
|
||||
import { displaySplash } from '../splash.js';
|
||||
|
||||
export interface StartArgs {
|
||||
@@ -83,7 +83,7 @@ export function start(args: StartArgs): void {
|
||||
}
|
||||
|
||||
// 11. Resolve prompts directory (local mode only)
|
||||
const promptsDir = isLocal() ? path.resolve('prompts') : undefined;
|
||||
const promptsDir = isLocal() ? path.resolve('apps/worker/prompts') : undefined;
|
||||
|
||||
// 12. Display splash screen
|
||||
displaySplash(isLocal() ? undefined : args.version);
|
||||
@@ -165,8 +165,14 @@ export function start(args: StartArgs): void {
|
||||
}
|
||||
};
|
||||
|
||||
process.on('SIGINT', () => { cleanup(); process.exit(0); });
|
||||
process.on('SIGTERM', () => { cleanup(); process.exit(0); });
|
||||
process.on('SIGINT', () => {
|
||||
cleanup();
|
||||
process.exit(0);
|
||||
});
|
||||
process.on('SIGTERM', () => {
|
||||
cleanup();
|
||||
process.exit(0);
|
||||
});
|
||||
process.on('exit', cleanup);
|
||||
}
|
||||
|
||||
@@ -176,7 +182,7 @@ function printInfo(
|
||||
workspace: string,
|
||||
workflowId: string,
|
||||
repoPath: string,
|
||||
workspacesDir: string
|
||||
workspacesDir: string,
|
||||
): void {
|
||||
const logsCmd = isLocal() ? `./shannon logs ${workspace}` : `npx @keygraph/shannon logs ${workspace}`;
|
||||
const reportsPath = path.join(workspacesDir, workspace);
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import * as p from '@clack/prompts';
|
||||
import { stopWorkers, stopInfra } from '../docker.js';
|
||||
import { stopInfra, stopWorkers } from '../docker.js';
|
||||
|
||||
export async function stop(clean: boolean): Promise<void> {
|
||||
if (clean) {
|
||||
@@ -3,10 +3,10 @@
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import * as p from '@clack/prompts';
|
||||
import { stopWorkers, stopInfra } from '../docker.js';
|
||||
import { stopInfra, stopWorkers } from '../docker.js';
|
||||
|
||||
const SHANNON_HOME = path.join(os.homedir(), '.shannon');
|
||||
|
||||
@@ -3,21 +3,29 @@
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { getWorkspacesDir } from '../home.js';
|
||||
import { getWorkerImage } from '../docker.js';
|
||||
import { getWorkspacesDir } from '../home.js';
|
||||
|
||||
export function workspaces(version: string): void {
|
||||
const workspacesDir = getWorkspacesDir();
|
||||
const image = getWorkerImage(version);
|
||||
|
||||
try {
|
||||
execFileSync('docker', [
|
||||
'run', '--rm',
|
||||
'-v', `${workspacesDir}:/app/workspaces`,
|
||||
'-e', 'WORKSPACES_DIR=/app/workspaces',
|
||||
image,
|
||||
'node', 'dist/temporal/workspaces.js',
|
||||
], { stdio: 'inherit' });
|
||||
execFileSync(
|
||||
'docker',
|
||||
[
|
||||
'run',
|
||||
'--rm',
|
||||
'-v',
|
||||
`${workspacesDir}:/app/workspaces`,
|
||||
'-e',
|
||||
'WORKSPACES_DIR=/app/workspaces',
|
||||
image,
|
||||
'node',
|
||||
'apps/worker/dist/temporal/workspaces.js',
|
||||
],
|
||||
{ stdio: 'inherit' },
|
||||
);
|
||||
} catch {
|
||||
console.error('ERROR: Failed to list workspaces. Is the Docker image available?');
|
||||
console.error(` Run: docker pull ${image}`);
|
||||
@@ -118,11 +118,7 @@ function buildSchema(): Map<string, Map<string, TOMLType>> {
|
||||
}
|
||||
|
||||
/** Check that a provider section has all required fields and dependencies. */
|
||||
function validateProviderFields(
|
||||
config: TOMLConfig,
|
||||
provider: string,
|
||||
errors: string[],
|
||||
): void {
|
||||
function validateProviderFields(config: TOMLConfig, provider: string, errors: string[]): void {
|
||||
const section = config[provider] as Record<string, unknown> | undefined;
|
||||
if (!section) return;
|
||||
const keys = Object.keys(section);
|
||||
@@ -171,11 +167,7 @@ function validateProviderFields(
|
||||
}
|
||||
|
||||
/** Bedrock and Vertex require a [models] section with all three tiers. */
|
||||
function validateModelTiers(
|
||||
config: TOMLConfig,
|
||||
provider: string,
|
||||
errors: string[],
|
||||
): void {
|
||||
function validateModelTiers(config: TOMLConfig, provider: string, errors: string[]): void {
|
||||
const models = config.models as Record<string, unknown> | undefined;
|
||||
if (!models || typeof models !== 'object') {
|
||||
errors.push(`[${provider}] requires a [models] section with small, medium, and large`);
|
||||
@@ -222,9 +214,7 @@ function validateConfig(config: TOMLConfig): string[] {
|
||||
}
|
||||
|
||||
if (typeof value !== expectedType) {
|
||||
errors.push(
|
||||
`[${section}].${key} must be ${expectedType}, got ${typeof value}`
|
||||
);
|
||||
errors.push(`[${section}].${key} must be ${expectedType}, got ${typeof value}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -243,7 +233,7 @@ function validateConfig(config: TOMLConfig): string[] {
|
||||
});
|
||||
if (present.length > 1) {
|
||||
errors.push(
|
||||
`Multiple providers configured: [${present.join('], [')}]. Only one provider section is allowed at a time`
|
||||
`Multiple providers configured: [${present.join('], [')}]. Only one provider section is allowed at a time`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
* NPX mode: pulls from Docker Hub, uses bundled compose.yml.
|
||||
*/
|
||||
|
||||
import { execFileSync, spawn, type ChildProcess } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { type ChildProcess, execFileSync, spawn } from 'node:child_process';
|
||||
import crypto from 'node:crypto';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { getMode } from './mode.js';
|
||||
|
||||
@@ -56,37 +56,77 @@ function runOutput(cmd: string, args: string[]): string {
|
||||
*/
|
||||
export function isTemporalReady(): boolean {
|
||||
const output = runOutput('docker', [
|
||||
'exec', 'shannon-temporal',
|
||||
'temporal', 'operator', 'cluster', 'health', '--address', 'localhost:7233',
|
||||
'exec',
|
||||
'shannon-temporal',
|
||||
'temporal',
|
||||
'operator',
|
||||
'cluster',
|
||||
'health',
|
||||
'--address',
|
||||
'localhost:7233',
|
||||
]);
|
||||
return output.includes('SERVING');
|
||||
}
|
||||
|
||||
/** Check if the router container is running and healthy. */
|
||||
function isRouterReady(): boolean {
|
||||
const status = runOutput('docker', ['inspect', '--format', '{{.State.Health.Status}}', 'shannon-router']);
|
||||
return status === 'healthy';
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure Temporal (and optionally router) are running via compose.
|
||||
* If Temporal is already up but router is needed and missing, starts router only.
|
||||
*/
|
||||
export function ensureInfra(useRouter: boolean): void {
|
||||
if (isTemporalReady()) {
|
||||
const temporalReady = isTemporalReady();
|
||||
const routerNeeded = useRouter && !isRouterReady();
|
||||
|
||||
if (temporalReady && !routerNeeded) {
|
||||
return;
|
||||
}
|
||||
|
||||
const composeFile = getComposeFile();
|
||||
console.log('Starting Shannon infrastructure...');
|
||||
const composeArgs = ['compose', '-f', composeFile];
|
||||
if (useRouter) composeArgs.push('--profile', 'router');
|
||||
composeArgs.push('up', '-d');
|
||||
|
||||
if (temporalReady && routerNeeded) {
|
||||
console.log('Starting router...');
|
||||
} else {
|
||||
console.log('Starting Shannon infrastructure...');
|
||||
}
|
||||
execFileSync('docker', composeArgs, { stdio: 'inherit' });
|
||||
|
||||
console.log('Waiting for Temporal to be ready...');
|
||||
for (let i = 0; i < 30; i++) {
|
||||
if (isTemporalReady()) {
|
||||
console.log('Temporal is ready!');
|
||||
return;
|
||||
// Wait for Temporal if it wasn't already running
|
||||
if (!temporalReady) {
|
||||
console.log('Waiting for Temporal to be ready...');
|
||||
for (let i = 0; i < 30; i++) {
|
||||
if (isTemporalReady()) {
|
||||
console.log('Temporal is ready!');
|
||||
break;
|
||||
}
|
||||
if (i === 29) {
|
||||
console.error('Timeout waiting for Temporal');
|
||||
process.exit(1);
|
||||
}
|
||||
execFileSync('sleep', ['2']);
|
||||
}
|
||||
execFileSync('sleep', ['2']);
|
||||
}
|
||||
console.error('Timeout waiting for Temporal');
|
||||
process.exit(1);
|
||||
|
||||
// Wait for router if needed
|
||||
if (routerNeeded) {
|
||||
console.log('Waiting for router to be ready...');
|
||||
for (let i = 0; i < 15; i++) {
|
||||
if (isRouterReady()) {
|
||||
console.log('Router is ready!');
|
||||
return;
|
||||
}
|
||||
execFileSync('sleep', ['2']);
|
||||
}
|
||||
console.error('Timeout waiting for router');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -173,7 +213,7 @@ export function spawnWorker(opts: WorkerOptions): ChildProcess {
|
||||
|
||||
// Local mode: mount prompts for live editing
|
||||
if (opts.promptsDir) {
|
||||
args.push('-v', `${opts.promptsDir}:/app/prompts:ro`);
|
||||
args.push('-v', `${opts.promptsDir}:/app/apps/worker/prompts:ro`);
|
||||
}
|
||||
|
||||
if (opts.config) {
|
||||
@@ -202,7 +242,7 @@ export function spawnWorker(opts: WorkerOptions): ChildProcess {
|
||||
args.push(getWorkerImage(opts.version));
|
||||
|
||||
// Worker command
|
||||
args.push('node', 'dist/temporal/worker.js', opts.url, opts.repo.containerPath);
|
||||
args.push('node', 'apps/worker/dist/temporal/worker.js', opts.url, opts.repo.containerPath);
|
||||
args.push('--task-queue', opts.taskQueue);
|
||||
if (opts.config) {
|
||||
args.push('--config', opts.config.containerPath);
|
||||
@@ -262,9 +302,7 @@ export function pullImage(version: string): void {
|
||||
* Remove old keygraph/shannon images that don't match the current version.
|
||||
*/
|
||||
function pruneOldImages(currentVersion: string): void {
|
||||
const output = runOutput('docker', [
|
||||
'images', NPX_IMAGE_REPO, '--format', '{{.Tag}}',
|
||||
]);
|
||||
const output = runOutput('docker', ['images', NPX_IMAGE_REPO, '--format', '{{.Tag}}']);
|
||||
if (!output) return;
|
||||
|
||||
const currentTag = currentVersion;
|
||||
@@ -279,7 +317,10 @@ function pruneOldImages(currentVersion: string): void {
|
||||
*/
|
||||
export function listRunningWorkers(): string {
|
||||
return runOutput('docker', [
|
||||
'ps', '--filter', 'name=shannon-worker-',
|
||||
'--format', 'table {{.Names}}\t{{.Status}}\t{{.RunningFor}}',
|
||||
'ps',
|
||||
'--filter',
|
||||
'name=shannon-worker-',
|
||||
'--format',
|
||||
'table {{.Names}}\t{{.Status}}\t{{.RunningFor}}',
|
||||
]);
|
||||
}
|
||||
@@ -6,8 +6,8 @@
|
||||
*/
|
||||
|
||||
import dotenv from 'dotenv';
|
||||
import { getMode } from './mode.js';
|
||||
import { resolveConfig } from './config/resolver.js';
|
||||
import { getMode } from './mode.js';
|
||||
|
||||
/** Environment variables forwarded to worker containers. */
|
||||
const FORWARD_VARS = [
|
||||
@@ -148,9 +148,10 @@ export function validateCredentials(): CredentialValidation {
|
||||
return { valid: true, mode: 'router' };
|
||||
}
|
||||
|
||||
const hint = getMode() === 'local'
|
||||
? `No credentials found. Set ANTHROPIC_API_KEY in .env or export it.`
|
||||
: `Authentication not configured. Export variables or run 'npx @keygraph/shannon setup'.`;
|
||||
const hint =
|
||||
getMode() === 'local'
|
||||
? `No credentials found. Set ANTHROPIC_API_KEY in .env or export it.`
|
||||
: `Authentication not configured. Export variables or run 'npx @keygraph/shannon setup'.`;
|
||||
return {
|
||||
valid: false,
|
||||
mode: 'api-key',
|
||||
@@ -6,8 +6,8 @@
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { getMode } from './mode.js';
|
||||
|
||||
const SHANNON_HOME = path.join(os.homedir(), '.shannon');
|
||||
@@ -17,9 +17,7 @@ export function getConfigFile(): string {
|
||||
}
|
||||
|
||||
export function getWorkspacesDir(): string {
|
||||
return getMode() === 'local'
|
||||
? path.resolve('workspaces')
|
||||
: path.join(SHANNON_HOME, 'workspaces');
|
||||
return getMode() === 'local' ? path.resolve('workspaces') : path.join(SHANNON_HOME, 'workspaces');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1,5 +1,3 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Shannon CLI — AI Penetration Testing Framework
|
||||
*
|
||||
@@ -14,16 +12,16 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { getMode } from './mode.js';
|
||||
import { start } from './commands/start.js';
|
||||
import { stop } from './commands/stop.js';
|
||||
import { logs } from './commands/logs.js';
|
||||
import { workspaces } from './commands/workspaces.js';
|
||||
import { status } from './commands/status.js';
|
||||
import { update } from './commands/update.js';
|
||||
import { build } from './commands/build.js';
|
||||
import { logs } from './commands/logs.js';
|
||||
import { setup } from './commands/setup.js';
|
||||
import { start } from './commands/start.js';
|
||||
import { status } from './commands/status.js';
|
||||
import { stop } from './commands/stop.js';
|
||||
import { uninstall } from './commands/uninstall.js';
|
||||
import { update } from './commands/update.js';
|
||||
import { workspaces } from './commands/workspaces.js';
|
||||
import { getMode } from './mode.js';
|
||||
import { displaySplash } from './splash.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
@@ -45,16 +43,24 @@ function showHelp(): void {
|
||||
console.log(`
|
||||
Shannon - AI Penetration Testing Framework
|
||||
|
||||
Usage:${mode === 'local' ? '' : `
|
||||
${prefix} setup Configure credentials`}
|
||||
Usage:${
|
||||
mode === 'local'
|
||||
? ''
|
||||
: `
|
||||
${prefix} setup Configure credentials`
|
||||
}
|
||||
${prefix} start --url <url> --repo <path> [options] Start a pentest scan
|
||||
${prefix} stop [--clean] Stop all containers
|
||||
${prefix} workspaces List all workspaces
|
||||
${prefix} logs <workspace> Tail workflow log
|
||||
${prefix} status Show running workers${mode === 'local' ? `
|
||||
${prefix} build [--no-cache] Build worker image` : `
|
||||
${prefix} status Show running workers${
|
||||
mode === 'local'
|
||||
? `
|
||||
${prefix} build [--no-cache] Build worker image`
|
||||
: `
|
||||
${prefix} update Pull latest image
|
||||
${prefix} uninstall Remove ~/.shannon/ and all data`}
|
||||
${prefix} uninstall Remove ~/.shannon/ and all data`
|
||||
}
|
||||
${prefix} info Show splash screen
|
||||
${prefix} help Show this help
|
||||
|
||||
@@ -72,9 +78,13 @@ Examples:
|
||||
${prefix} start -u https://example.com -r /path/to/repo -c config.yaml -w q1-audit
|
||||
${prefix} logs q1-audit
|
||||
${prefix} stop --clean
|
||||
${mode === 'local' ? `
|
||||
State directory: ./workspaces/` : `
|
||||
State directory: ~/.shannon/`}
|
||||
${
|
||||
mode === 'local'
|
||||
? `
|
||||
State directory: ./workspaces/`
|
||||
: `
|
||||
State directory: ~/.shannon/`
|
||||
}
|
||||
Monitor workflows at http://localhost:8233
|
||||
`);
|
||||
}
|
||||
@@ -105,23 +115,38 @@ function parseStartArgs(argv: string[]): ParsedStartArgs {
|
||||
switch (arg) {
|
||||
case '-u':
|
||||
case '--url':
|
||||
if (next && !next.startsWith('-')) { url = next; i++; }
|
||||
if (next && !next.startsWith('-')) {
|
||||
url = next;
|
||||
i++;
|
||||
}
|
||||
break;
|
||||
case '-r':
|
||||
case '--repo':
|
||||
if (next && !next.startsWith('-')) { repo = next; i++; }
|
||||
if (next && !next.startsWith('-')) {
|
||||
repo = next;
|
||||
i++;
|
||||
}
|
||||
break;
|
||||
case '-c':
|
||||
case '--config':
|
||||
if (next && !next.startsWith('-')) { config = next; i++; }
|
||||
if (next && !next.startsWith('-')) {
|
||||
config = next;
|
||||
i++;
|
||||
}
|
||||
break;
|
||||
case '-w':
|
||||
case '--workspace':
|
||||
if (next && !next.startsWith('-')) { workspace = next; i++; }
|
||||
if (next && !next.startsWith('-')) {
|
||||
workspace = next;
|
||||
i++;
|
||||
}
|
||||
break;
|
||||
case '-o':
|
||||
case '--output':
|
||||
if (next && !next.startsWith('-')) { output = next; i++; }
|
||||
if (next && !next.startsWith('-')) {
|
||||
output = next;
|
||||
i++;
|
||||
}
|
||||
break;
|
||||
case '--pipeline-testing':
|
||||
pipelineTesting = true;
|
||||
@@ -142,7 +167,15 @@ function parseStartArgs(argv: string[]): ParsedStartArgs {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return { url, repo, pipelineTesting, router, ...(config && { config }), ...(workspace && { workspace }), ...(output && { output }) };
|
||||
return {
|
||||
url,
|
||||
repo,
|
||||
pipelineTesting,
|
||||
router,
|
||||
...(config && { config }),
|
||||
...(workspace && { workspace }),
|
||||
...(output && { output }),
|
||||
};
|
||||
}
|
||||
|
||||
// === Main Dispatch ===
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist"
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'tsdown';
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['src/index.ts'],
|
||||
format: 'esm',
|
||||
target: 'node18',
|
||||
outDir: 'dist',
|
||||
clean: true,
|
||||
deps: { neverBundle: ['@clack/prompts', 'dotenv', 'smol-toml'] },
|
||||
banner: { js: '#!/usr/bin/env node' },
|
||||
});
|
||||
@@ -124,11 +124,7 @@
|
||||
"deprecated": true
|
||||
}
|
||||
},
|
||||
"anyOf": [
|
||||
{"required": ["authentication"]},
|
||||
{"required": ["rules"]},
|
||||
{"required": ["authentication", "rules"]}
|
||||
],
|
||||
"anyOf": [{ "required": ["authentication"] }, { "required": ["rules"] }, { "required": ["authentication", "rules"] }],
|
||||
"additionalProperties": false,
|
||||
"$defs": {
|
||||
"rule": {
|
||||
@@ -157,4 +153,4 @@
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "@shannon/worker",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"check": "tsc --noEmit",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "catalog:",
|
||||
"@shannon/mcp-server": "workspace:*",
|
||||
"@temporalio/activity": "^1.11.0",
|
||||
"@temporalio/client": "^1.11.0",
|
||||
"@temporalio/worker": "^1.11.0",
|
||||
"@temporalio/workflow": "^1.11.0",
|
||||
"ajv": "^8.12.0",
|
||||
"ajv-formats": "^2.1.1",
|
||||
"dotenv": "^16.4.5",
|
||||
"js-yaml": "^4.1.0",
|
||||
"zx": "^8.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/js-yaml": "^4.0.9"
|
||||
}
|
||||
}
|
||||
@@ -6,26 +6,23 @@
|
||||
|
||||
// Production Claude agent execution with retry, git checkpoints, and audit logging
|
||||
|
||||
import { fs, path } from 'zx';
|
||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
|
||||
import { createShannonHelperServer } from '@shannon/mcp-server';
|
||||
import { fs, path } from 'zx';
|
||||
import type { AuditSession } from '../audit/index.js';
|
||||
import { isRetryableError, PentestError } from '../services/error-handling.js';
|
||||
import { isSpendingCapBehavior } from '../utils/billing-detection.js';
|
||||
import { Timer } from '../utils/metrics.js';
|
||||
import { formatTimestamp } from '../utils/formatting.js';
|
||||
import { AGENT_VALIDATORS, MCP_AGENT_MAPPING } from '../session-manager.js';
|
||||
import { AuditSession } from '../audit/index.js';
|
||||
import { createShannonHelperServer } from '../../mcp-server/dist/index.js';
|
||||
import { AGENTS } from '../session-manager.js';
|
||||
import type { AgentName } from '../types/index.js';
|
||||
|
||||
import { dispatchMessage } from './message-handlers.js';
|
||||
import { detectExecutionContext, formatErrorOutput, formatCompletionMessage } from './output-formatters.js';
|
||||
import { createProgressManager } from './progress-manager.js';
|
||||
import { createAuditLogger } from './audit-logger.js';
|
||||
import { getActualModelName } from './router-utils.js';
|
||||
import { resolveModel, type ModelTier } from './models.js';
|
||||
import { AGENT_VALIDATORS, AGENTS, MCP_AGENT_MAPPING } from '../session-manager.js';
|
||||
import type { ActivityLogger } from '../types/activity-logger.js';
|
||||
import type { AgentName } from '../types/index.js';
|
||||
import { isSpendingCapBehavior } from '../utils/billing-detection.js';
|
||||
import { formatTimestamp } from '../utils/formatting.js';
|
||||
import { Timer } from '../utils/metrics.js';
|
||||
import { createAuditLogger } from './audit-logger.js';
|
||||
import { dispatchMessage } from './message-handlers.js';
|
||||
import { type ModelTier, resolveModel } from './models.js';
|
||||
import { detectExecutionContext, formatCompletionMessage, formatErrorOutput } from './output-formatters.js';
|
||||
import { createProgressManager } from './progress-manager.js';
|
||||
import { getActualModelName } from './router-utils.js';
|
||||
|
||||
declare global {
|
||||
var SHANNON_DISABLE_LOADER: boolean | undefined;
|
||||
@@ -59,7 +56,7 @@ type McpServer = ReturnType<typeof createShannonHelperServer> | StdioMcpServer;
|
||||
function buildMcpServers(
|
||||
sourceDir: string,
|
||||
agentName: string | null,
|
||||
logger: ActivityLogger
|
||||
logger: ActivityLogger,
|
||||
): Record<string, McpServer> {
|
||||
// 1. Create the shannon-helper server (always present)
|
||||
const shannonHelperServer = createShannonHelperServer(sourceDir);
|
||||
@@ -81,11 +78,7 @@ function buildMcpServers(
|
||||
// 3. Configure Playwright MCP args with Docker/local browser handling
|
||||
const isDocker = process.env.SHANNON_DOCKER === 'true';
|
||||
|
||||
const mcpArgs: string[] = [
|
||||
'@playwright/mcp@0.0.68',
|
||||
'--isolated',
|
||||
'--user-data-dir', userDataDir,
|
||||
];
|
||||
const mcpArgs: string[] = ['@playwright/mcp@0.0.68', '--isolated', '--user-data-dir', userDataDir];
|
||||
|
||||
if (isDocker) {
|
||||
mcpArgs.push('--executable-path', '/usr/bin/chromium-browser');
|
||||
@@ -139,7 +132,7 @@ async function writeErrorLog(
|
||||
err: Error & { code?: string; status?: number },
|
||||
sourceDir: string,
|
||||
fullPrompt: string,
|
||||
duration: number
|
||||
duration: number,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const errorLog = {
|
||||
@@ -150,17 +143,17 @@ async function writeErrorLog(
|
||||
message: err.message,
|
||||
code: err.code,
|
||||
status: err.status,
|
||||
stack: err.stack
|
||||
stack: err.stack,
|
||||
},
|
||||
context: {
|
||||
sourceDir,
|
||||
prompt: fullPrompt.slice(0, 200) + '...',
|
||||
retryable: isRetryableError(err)
|
||||
prompt: `${fullPrompt.slice(0, 200)}...`,
|
||||
retryable: isRetryableError(err),
|
||||
},
|
||||
duration
|
||||
duration,
|
||||
};
|
||||
const logPath = path.join(sourceDir, 'error.log');
|
||||
await fs.appendFile(logPath, JSON.stringify(errorLog) + '\n');
|
||||
await fs.appendFile(logPath, `${JSON.stringify(errorLog)}\n`);
|
||||
} catch {
|
||||
// Best-effort error log writing - don't propagate failures
|
||||
}
|
||||
@@ -170,7 +163,7 @@ export async function validateAgentOutput(
|
||||
result: ClaudePromptResult,
|
||||
agentName: string | null,
|
||||
sourceDir: string,
|
||||
logger: ActivityLogger
|
||||
logger: ActivityLogger,
|
||||
): Promise<boolean> {
|
||||
logger.info(`Validating ${agentName} agent output`);
|
||||
|
||||
@@ -202,7 +195,6 @@ export async function validateAgentOutput(
|
||||
}
|
||||
|
||||
return validationResult;
|
||||
|
||||
} catch (error) {
|
||||
const errMsg = error instanceof Error ? error.message : String(error);
|
||||
logger.error(`Validation failed with error: ${errMsg}`);
|
||||
@@ -220,7 +212,7 @@ export async function runClaudePrompt(
|
||||
agentName: string | null = null,
|
||||
auditSession: AuditSession | null = null,
|
||||
logger: ActivityLogger,
|
||||
modelTier: ModelTier = 'medium'
|
||||
modelTier: ModelTier = 'medium',
|
||||
): Promise<ClaudePromptResult> {
|
||||
// 1. Initialize timing and prompt
|
||||
const timer = new Timer(`agent-${description.toLowerCase().replace(/\s+/g, '-')}`);
|
||||
@@ -230,7 +222,7 @@ export async function runClaudePrompt(
|
||||
const execContext = detectExecutionContext(description);
|
||||
const progress = createProgressManager(
|
||||
{ description, useCleanOutput: execContext.useCleanOutput },
|
||||
global.SHANNON_DISABLE_LOADER ?? false
|
||||
global.SHANNON_DISABLE_LOADER ?? false,
|
||||
);
|
||||
const auditLogger = createAuditLogger(auditSession);
|
||||
|
||||
@@ -293,7 +285,7 @@ export async function runClaudePrompt(
|
||||
fullPrompt,
|
||||
options,
|
||||
{ execContext, description, progress, auditLogger, logger },
|
||||
timer
|
||||
timer,
|
||||
);
|
||||
|
||||
turnCount = messageLoopResult.turnCount;
|
||||
@@ -309,7 +301,7 @@ export async function runClaudePrompt(
|
||||
throw new PentestError(
|
||||
`Spending cap likely reached (turns=${turnCount}, cost=$0): ${result?.slice(0, 100)}`,
|
||||
'billing',
|
||||
true // Retryable - Temporal will use 5-30 min backoff
|
||||
true, // Retryable - Temporal will use 5-30 min backoff
|
||||
);
|
||||
}
|
||||
|
||||
@@ -330,9 +322,8 @@ export async function runClaudePrompt(
|
||||
cost: totalCost,
|
||||
model,
|
||||
partialCost: totalCost,
|
||||
apiErrorDetected
|
||||
apiErrorDetected,
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
// 9. Handle errors — log, write error file, return failure
|
||||
const duration = timer.stop();
|
||||
@@ -347,16 +338,15 @@ export async function runClaudePrompt(
|
||||
return {
|
||||
error: err.message,
|
||||
errorType: err.constructor.name,
|
||||
prompt: fullPrompt.slice(0, 100) + '...',
|
||||
prompt: `${fullPrompt.slice(0, 100)}...`,
|
||||
success: false,
|
||||
duration,
|
||||
cost: totalCost,
|
||||
retryable: isRetryableError(err)
|
||||
retryable: isRetryableError(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
interface MessageLoopResult {
|
||||
turnCount: number;
|
||||
result: string | null;
|
||||
@@ -377,7 +367,7 @@ async function processMessageStream(
|
||||
fullPrompt: string,
|
||||
options: NonNullable<Parameters<typeof query>[0]['options']>,
|
||||
deps: MessageLoopDeps,
|
||||
timer: Timer
|
||||
timer: Timer,
|
||||
): Promise<MessageLoopResult> {
|
||||
const { execContext, description, progress, auditLogger, logger } = deps;
|
||||
const HEARTBEAT_INTERVAL = 30000;
|
||||
@@ -402,11 +392,13 @@ async function processMessageStream(
|
||||
turnCount++;
|
||||
}
|
||||
|
||||
const dispatchResult = await dispatchMessage(
|
||||
message as { type: string; subtype?: string },
|
||||
turnCount,
|
||||
{ execContext, description, progress, auditLogger, logger }
|
||||
);
|
||||
const dispatchResult = await dispatchMessage(message as { type: string; subtype?: string }, turnCount, {
|
||||
execContext,
|
||||
description,
|
||||
progress,
|
||||
auditLogger,
|
||||
logger,
|
||||
});
|
||||
|
||||
if (dispatchResult.type === 'throw') {
|
||||
throw dispatchResult.error;
|
||||
@@ -4,35 +4,35 @@
|
||||
// it under the terms of the GNU Affero General Public License version 3
|
||||
// as published by the Free Software Foundation.
|
||||
|
||||
import type { SDKAssistantMessageError } from '@anthropic-ai/claude-agent-sdk';
|
||||
import { PentestError } from '../services/error-handling.js';
|
||||
import type { ActivityLogger } from '../types/activity-logger.js';
|
||||
import { ErrorCode } from '../types/errors.js';
|
||||
import { matchesBillingTextPattern } from '../utils/billing-detection.js';
|
||||
import { filterJsonToolCalls } from './output-formatters.js';
|
||||
import { formatTimestamp } from '../utils/formatting.js';
|
||||
import { getActualModelName } from './router-utils.js';
|
||||
import type { ActivityLogger } from '../types/activity-logger.js';
|
||||
import type { AuditLogger } from './audit-logger.js';
|
||||
import {
|
||||
filterJsonToolCalls,
|
||||
formatAssistantOutput,
|
||||
formatResultOutput,
|
||||
formatToolUseOutput,
|
||||
formatToolResultOutput,
|
||||
formatToolUseOutput,
|
||||
} from './output-formatters.js';
|
||||
import type { AuditLogger } from './audit-logger.js';
|
||||
import type { ProgressManager } from './progress-manager.js';
|
||||
import type { SDKAssistantMessageError } from '@anthropic-ai/claude-agent-sdk';
|
||||
import { getActualModelName } from './router-utils.js';
|
||||
import type {
|
||||
AssistantMessage,
|
||||
ResultMessage,
|
||||
ToolUseMessage,
|
||||
ToolResultMessage,
|
||||
AssistantResult,
|
||||
ResultData,
|
||||
ToolUseData,
|
||||
ToolResultData,
|
||||
ApiErrorDetection,
|
||||
AssistantMessage,
|
||||
AssistantResult,
|
||||
ContentBlock,
|
||||
SystemInitMessage,
|
||||
ExecutionContext,
|
||||
ResultData,
|
||||
ResultMessage,
|
||||
SystemInitMessage,
|
||||
ToolResultData,
|
||||
ToolResultMessage,
|
||||
ToolUseData,
|
||||
ToolUseMessage,
|
||||
} from './types.js';
|
||||
|
||||
// Handles both array and string content formats from SDK
|
||||
@@ -40,9 +40,7 @@ function extractMessageContent(message: AssistantMessage): string {
|
||||
const messageContent = message.message;
|
||||
|
||||
if (Array.isArray(messageContent.content)) {
|
||||
return messageContent.content
|
||||
.map((c: ContentBlock) => c.text || JSON.stringify(c))
|
||||
.join('\n');
|
||||
return messageContent.content.map((c: ContentBlock) => c.text || JSON.stringify(c)).join('\n');
|
||||
}
|
||||
|
||||
return String(messageContent.content);
|
||||
@@ -81,7 +79,7 @@ function detectApiError(content: string): ApiErrorDetection {
|
||||
'billing',
|
||||
true, // RETRYABLE - Temporal will use 5-30 min backoff
|
||||
{},
|
||||
ErrorCode.SPENDING_CAP_REACHED
|
||||
ErrorCode.SPENDING_CAP_REACHED,
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -104,10 +102,7 @@ function detectApiError(content: string): ApiErrorDetection {
|
||||
}
|
||||
|
||||
// Maps SDK structured error types to our error handling.
|
||||
function handleStructuredError(
|
||||
errorType: SDKAssistantMessageError,
|
||||
content: string
|
||||
): ApiErrorDetection {
|
||||
function handleStructuredError(errorType: SDKAssistantMessageError, content: string): ApiErrorDetection {
|
||||
switch (errorType) {
|
||||
case 'billing_error':
|
||||
return {
|
||||
@@ -117,7 +112,7 @@ function handleStructuredError(
|
||||
'billing',
|
||||
true, // Retryable with backoff
|
||||
{},
|
||||
ErrorCode.INSUFFICIENT_CREDITS
|
||||
ErrorCode.INSUFFICIENT_CREDITS,
|
||||
),
|
||||
};
|
||||
case 'rate_limit':
|
||||
@@ -128,7 +123,7 @@ function handleStructuredError(
|
||||
'network',
|
||||
true, // Retryable with backoff
|
||||
{},
|
||||
ErrorCode.API_RATE_LIMITED
|
||||
ErrorCode.API_RATE_LIMITED,
|
||||
),
|
||||
};
|
||||
case 'authentication_failed':
|
||||
@@ -137,7 +132,7 @@ function handleStructuredError(
|
||||
shouldThrow: new PentestError(
|
||||
`Authentication failed: ${content.slice(0, 100)}`,
|
||||
'config',
|
||||
false // Not retryable - needs API key fix
|
||||
false, // Not retryable - needs API key fix
|
||||
),
|
||||
};
|
||||
case 'server_error':
|
||||
@@ -146,7 +141,7 @@ function handleStructuredError(
|
||||
shouldThrow: new PentestError(
|
||||
`Server error (structured): ${content.slice(0, 100)}`,
|
||||
'network',
|
||||
true // Retryable
|
||||
true, // Retryable
|
||||
),
|
||||
};
|
||||
case 'invalid_request':
|
||||
@@ -155,7 +150,7 @@ function handleStructuredError(
|
||||
shouldThrow: new PentestError(
|
||||
`Invalid request: ${content.slice(0, 100)}`,
|
||||
'config',
|
||||
false // Not retryable - needs code fix
|
||||
false, // Not retryable - needs code fix
|
||||
),
|
||||
};
|
||||
case 'max_output_tokens':
|
||||
@@ -164,19 +159,15 @@ function handleStructuredError(
|
||||
shouldThrow: new PentestError(
|
||||
`Max output tokens reached: ${content.slice(0, 100)}`,
|
||||
'billing',
|
||||
true // Retryable - may succeed with different content
|
||||
true, // Retryable - may succeed with different content
|
||||
),
|
||||
};
|
||||
case 'unknown':
|
||||
default:
|
||||
return { detected: true };
|
||||
}
|
||||
}
|
||||
|
||||
function handleAssistantMessage(
|
||||
message: AssistantMessage,
|
||||
turnCount: number
|
||||
): AssistantResult {
|
||||
function handleAssistantMessage(message: AssistantMessage, turnCount: number): AssistantResult {
|
||||
const content = extractMessageContent(message);
|
||||
const cleanedContent = filterJsonToolCalls(content);
|
||||
|
||||
@@ -246,8 +237,7 @@ function handleToolUseMessage(message: ToolUseMessage): ToolUseData {
|
||||
// Truncates long results for display (500 char limit), preserves full content for logging
|
||||
function handleToolResultMessage(message: ToolResultMessage): ToolResultData {
|
||||
const content = message.content;
|
||||
const contentStr =
|
||||
typeof content === 'string' ? content : JSON.stringify(content, null, 2);
|
||||
const contentStr = typeof content === 'string' ? content : JSON.stringify(content, null, 2);
|
||||
|
||||
const displayContent =
|
||||
contentStr.length > 500
|
||||
@@ -284,7 +274,7 @@ export interface MessageDispatchDeps {
|
||||
export async function dispatchMessage(
|
||||
message: { type: string; subtype?: string },
|
||||
turnCount: number,
|
||||
deps: MessageDispatchDeps
|
||||
deps: MessageDispatchDeps,
|
||||
): Promise<MessageDispatchAction> {
|
||||
const { execContext, description, progress, auditLogger, logger } = deps;
|
||||
|
||||
@@ -298,12 +288,7 @@ export async function dispatchMessage(
|
||||
|
||||
if (assistantResult.cleanedContent.trim()) {
|
||||
progress.stop();
|
||||
outputLines(formatAssistantOutput(
|
||||
assistantResult.cleanedContent,
|
||||
execContext,
|
||||
turnCount,
|
||||
description
|
||||
));
|
||||
outputLines(formatAssistantOutput(assistantResult.cleanedContent, execContext, turnCount, description));
|
||||
progress.start();
|
||||
}
|
||||
|
||||
@@ -324,7 +309,7 @@ export async function dispatchMessage(
|
||||
if (!execContext.useCleanOutput) {
|
||||
logger.info(`Model: ${actualModel}, Permission: ${initMsg.permissionMode}`);
|
||||
if (initMsg.mcp_servers && initMsg.mcp_servers.length > 0) {
|
||||
const mcpStatus = initMsg.mcp_servers.map(s => `${s.name}(${s.status})`).join(', ');
|
||||
const mcpStatus = initMsg.mcp_servers.map((s) => `${s.name}(${s.status})`).join(', ');
|
||||
logger.info(`MCP: ${mcpStatus}`);
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,8 @@
|
||||
// it under the terms of the GNU Affero General Public License version 3
|
||||
// as published by the Free Software Foundation.
|
||||
|
||||
import { extractAgentType, formatDuration } from '../utils/formatting.js';
|
||||
import { AGENTS } from '../session-manager.js';
|
||||
import { extractAgentType, formatDuration } from '../utils/formatting.js';
|
||||
import type { ExecutionContext, ResultData } from './types.js';
|
||||
|
||||
interface ToolCallInput {
|
||||
@@ -247,11 +247,7 @@ export function filterJsonToolCalls(content: string | null | undefined): string
|
||||
if (browserAction) {
|
||||
processedLines.push(browserAction);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Hide all other tool calls (Read, Write, Grep, etc.)
|
||||
continue;
|
||||
} catch {
|
||||
// If JSON parsing fails, treat as regular text
|
||||
processedLines.push(line);
|
||||
@@ -266,8 +262,7 @@ export function filterJsonToolCalls(content: string | null | undefined): string
|
||||
}
|
||||
|
||||
export function detectExecutionContext(description: string): ExecutionContext {
|
||||
const isParallelExecution =
|
||||
description.includes('vuln agent') || description.includes('exploit agent');
|
||||
const isParallelExecution = description.includes('vuln agent') || description.includes('exploit agent');
|
||||
|
||||
const useCleanOutput =
|
||||
description.includes('Pre-recon agent') ||
|
||||
@@ -287,7 +282,7 @@ export function formatAssistantOutput(
|
||||
cleanedContent: string,
|
||||
context: ExecutionContext,
|
||||
turnCount: number,
|
||||
description: string
|
||||
description: string,
|
||||
): string[] {
|
||||
if (!cleanedContent.trim()) {
|
||||
return [];
|
||||
@@ -341,7 +336,7 @@ export function formatErrorOutput(
|
||||
description: string,
|
||||
duration: number,
|
||||
sourceDir: string,
|
||||
isRetryable: boolean
|
||||
isRetryable: boolean,
|
||||
): string[] {
|
||||
const lines: string[] = [];
|
||||
|
||||
@@ -374,7 +369,7 @@ export function formatCompletionMessage(
|
||||
context: ExecutionContext,
|
||||
description: string,
|
||||
turnCount: number,
|
||||
duration: number
|
||||
duration: number,
|
||||
): string {
|
||||
if (context.isParallelExecution) {
|
||||
const prefix = getAgentPrefix(description);
|
||||
@@ -388,10 +383,7 @@ export function formatCompletionMessage(
|
||||
return ` Claude Code completed: ${description} (${turnCount} turns) in ${formatDuration(duration)}`;
|
||||
}
|
||||
|
||||
export function formatToolUseOutput(
|
||||
toolName: string,
|
||||
input: Record<string, unknown> | undefined
|
||||
): string[] {
|
||||
export function formatToolUseOutput(toolName: string, input: Record<string, unknown> | undefined): string[] {
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push(`\n Using Tool: ${toolName}`);
|
||||
@@ -63,10 +63,7 @@ class NullProgressManager implements ProgressManager {
|
||||
}
|
||||
|
||||
// Returns no-op when disabled
|
||||
export function createProgressManager(
|
||||
context: ProgressContext,
|
||||
disableLoader: boolean
|
||||
): ProgressManager {
|
||||
export function createProgressManager(context: ProgressContext, disableLoader: boolean): ProgressManager {
|
||||
if (!context.useCleanOutput || disableLoader) {
|
||||
return new NullProgressManager();
|
||||
}
|
||||
@@ -25,4 +25,3 @@ export function getActualModelName(sdkReportedModel?: string): string | undefine
|
||||
// Fall back to SDK-reported model
|
||||
return sdkReportedModel;
|
||||
}
|
||||
|
||||
@@ -53,7 +53,6 @@ export interface ContentBlock {
|
||||
text?: string;
|
||||
}
|
||||
|
||||
|
||||
export interface AssistantMessage {
|
||||
type: 'assistant';
|
||||
error?: SDKAssistantMessageError;
|
||||
@@ -99,4 +98,3 @@ export interface SystemInitMessage {
|
||||
export interface UserMessage {
|
||||
type: 'user';
|
||||
}
|
||||
|
||||
@@ -11,15 +11,15 @@
|
||||
* crash-safe audit logging.
|
||||
*/
|
||||
|
||||
import { AgentLogger } from './logger.js';
|
||||
import { WorkflowLogger, type AgentLogDetails, type WorkflowSummary } from './workflow-logger.js';
|
||||
import { MetricsTracker } from './metrics-tracker.js';
|
||||
import { initializeAuditStructure, type SessionMetadata } from './utils.js';
|
||||
import { formatTimestamp } from '../utils/formatting.js';
|
||||
import { SessionMutex } from '../utils/concurrency.js';
|
||||
import type { AgentEndResult } from '../types/index.js';
|
||||
import { PentestError } from '../services/error-handling.js';
|
||||
import { ErrorCode } from '../types/errors.js';
|
||||
import type { AgentEndResult } from '../types/index.js';
|
||||
import { SessionMutex } from '../utils/concurrency.js';
|
||||
import { formatTimestamp } from '../utils/formatting.js';
|
||||
import { AgentLogger } from './logger.js';
|
||||
import { MetricsTracker } from './metrics-tracker.js';
|
||||
import { initializeAuditStructure, type SessionMetadata } from './utils.js';
|
||||
import { type AgentLogDetails, WorkflowLogger, type WorkflowSummary } from './workflow-logger.js';
|
||||
|
||||
// Global mutex instance
|
||||
const sessionMutex = new SessionMutex();
|
||||
@@ -47,7 +47,7 @@ export class AuditSession {
|
||||
'config',
|
||||
false,
|
||||
{ field: 'sessionMetadata.id' },
|
||||
ErrorCode.CONFIG_VALIDATION_FAILED
|
||||
ErrorCode.CONFIG_VALIDATION_FAILED,
|
||||
);
|
||||
}
|
||||
if (!this.sessionMetadata.webUrl) {
|
||||
@@ -56,7 +56,7 @@ export class AuditSession {
|
||||
'config',
|
||||
false,
|
||||
{ field: 'sessionMetadata.webUrl' },
|
||||
ErrorCode.CONFIG_VALIDATION_FAILED
|
||||
ErrorCode.CONFIG_VALIDATION_FAILED,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -100,11 +100,7 @@ export class AuditSession {
|
||||
/**
|
||||
* Start agent execution
|
||||
*/
|
||||
async startAgent(
|
||||
agentName: string,
|
||||
promptContent: string,
|
||||
attemptNumber: number = 1
|
||||
): Promise<void> {
|
||||
async startAgent(agentName: string, promptContent: string, attemptNumber: number = 1): Promise<void> {
|
||||
await this.ensureInitialized();
|
||||
|
||||
// 1. Save prompt snapshot (only on first attempt)
|
||||
@@ -140,7 +136,7 @@ export class AuditSession {
|
||||
'validation',
|
||||
false,
|
||||
{},
|
||||
ErrorCode.AGENT_EXECUTION_FAILED
|
||||
ErrorCode.AGENT_EXECUTION_FAILED,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -152,18 +148,10 @@ export class AuditSession {
|
||||
const agentName = this.currentAgentName || 'unknown';
|
||||
switch (eventType) {
|
||||
case 'tool_start':
|
||||
await this.workflowLogger.logToolStart(
|
||||
agentName,
|
||||
String(data.toolName || ''),
|
||||
data.parameters
|
||||
);
|
||||
await this.workflowLogger.logToolStart(agentName, String(data.toolName || ''), data.parameters);
|
||||
break;
|
||||
case 'llm_response':
|
||||
await this.workflowLogger.logLlmResponse(
|
||||
agentName,
|
||||
Number(data.turn || 0),
|
||||
String(data.content || '')
|
||||
);
|
||||
await this.workflowLogger.logLlmResponse(agentName, Number(data.turn || 0), String(data.content || ''));
|
||||
break;
|
||||
// tool_end and error events are intentionally not logged to workflow log
|
||||
// to reduce noise - the agent completion message captures the outcome
|
||||
@@ -266,11 +254,7 @@ export class AuditSession {
|
||||
* @param terminatedWorkflows - IDs of workflows that were terminated
|
||||
* @param checkpointHash - Git checkpoint hash that was restored
|
||||
*/
|
||||
async addResumeAttempt(
|
||||
workflowId: string,
|
||||
terminatedWorkflows: string[],
|
||||
checkpointHash?: string
|
||||
): Promise<void> {
|
||||
async addResumeAttempt(workflowId: string, terminatedWorkflows: string[], checkpointHash?: string): Promise<void> {
|
||||
await this.ensureInitialized();
|
||||
|
||||
const unlock = await sessionMutex.lock(this.sessionId);
|
||||
@@ -12,8 +12,8 @@
|
||||
* and proper cleanup.
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { ensureDirectory } from '../utils/file-io.js';
|
||||
|
||||
/**
|
||||
@@ -103,7 +103,7 @@ export class LogStream {
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
this.stream!.end(() => {
|
||||
this.stream?.end(() => {
|
||||
this._isOpen = false;
|
||||
this.stream = null;
|
||||
resolve();
|
||||
@@ -11,14 +11,10 @@
|
||||
* Uses LogStream for stream management with backpressure handling.
|
||||
*/
|
||||
|
||||
import {
|
||||
generateLogPath,
|
||||
generatePromptPath,
|
||||
type SessionMetadata,
|
||||
} from './utils.js';
|
||||
import { atomicWrite } from '../utils/file-io.js';
|
||||
import { formatTimestamp } from '../utils/formatting.js';
|
||||
import { LogStream } from './log-stream.js';
|
||||
import { generateLogPath, generatePromptPath, type SessionMetadata } from './utils.js';
|
||||
|
||||
interface LogEvent {
|
||||
type: string;
|
||||
@@ -103,11 +99,7 @@ export class AgentLogger {
|
||||
* Save prompt snapshot to prompts directory
|
||||
* Static method - doesn't require logger instance
|
||||
*/
|
||||
static async savePrompt(
|
||||
sessionMetadata: SessionMetadata,
|
||||
agentName: string,
|
||||
promptContent: string
|
||||
): Promise<void> {
|
||||
static async savePrompt(sessionMetadata: SessionMetadata, agentName: string, promptContent: string): Promise<void> {
|
||||
const promptPath = generatePromptPath(sessionMetadata, agentName);
|
||||
|
||||
// Create header with metadata
|
||||
@@ -11,16 +11,13 @@
|
||||
* Tracks attempt-level data for complete forensic trail.
|
||||
*/
|
||||
|
||||
import {
|
||||
generateSessionJsonPath,
|
||||
type SessionMetadata,
|
||||
} from './utils.js';
|
||||
import { atomicWrite, readJson, fileExists } from '../utils/file-io.js';
|
||||
import { formatTimestamp, calculatePercentage } from '../utils/formatting.js';
|
||||
import { AGENT_PHASE_MAP, type PhaseName } from '../session-manager.js';
|
||||
import { PentestError } from '../services/error-handling.js';
|
||||
import { AGENT_PHASE_MAP, type PhaseName } from '../session-manager.js';
|
||||
import { ErrorCode } from '../types/errors.js';
|
||||
import type { AgentName, AgentEndResult } from '../types/index.js';
|
||||
import type { AgentEndResult, AgentName } from '../types/index.js';
|
||||
import { atomicWrite, fileExists, readJson } from '../utils/file-io.js';
|
||||
import { calculatePercentage, formatTimestamp } from '../utils/formatting.js';
|
||||
import { generateSessionJsonPath, type SessionMetadata } from './utils.js';
|
||||
|
||||
interface AttemptData {
|
||||
attempt_number: number;
|
||||
@@ -166,7 +163,7 @@ export class MetricsTracker {
|
||||
'validation',
|
||||
false,
|
||||
{},
|
||||
ErrorCode.AGENT_EXECUTION_FAILED
|
||||
ErrorCode.AGENT_EXECUTION_FAILED,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -254,18 +251,14 @@ export class MetricsTracker {
|
||||
* @param terminatedWorkflows - IDs of workflows that were terminated
|
||||
* @param checkpointHash - Git checkpoint hash that was restored
|
||||
*/
|
||||
async addResumeAttempt(
|
||||
workflowId: string,
|
||||
terminatedWorkflows: string[],
|
||||
checkpointHash?: string
|
||||
): Promise<void> {
|
||||
async addResumeAttempt(workflowId: string, terminatedWorkflows: string[], checkpointHash?: string): Promise<void> {
|
||||
if (!this.data) {
|
||||
throw new PentestError(
|
||||
'MetricsTracker not initialized',
|
||||
'validation',
|
||||
false,
|
||||
{},
|
||||
ErrorCode.AGENT_EXECUTION_FAILED
|
||||
ErrorCode.AGENT_EXECUTION_FAILED,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -307,15 +300,10 @@ export class MetricsTracker {
|
||||
const agents = this.data.metrics.agents;
|
||||
|
||||
// Only count successful agents
|
||||
const successfulAgents = Object.entries(agents).filter(
|
||||
([, data]) => data.status === 'success'
|
||||
);
|
||||
const successfulAgents = Object.entries(agents).filter(([, data]) => data.status === 'success');
|
||||
|
||||
// Calculate total duration and cost
|
||||
const totalDuration = successfulAgents.reduce(
|
||||
(sum, [, data]) => sum + data.final_duration_ms,
|
||||
0
|
||||
);
|
||||
const totalDuration = successfulAgents.reduce((sum, [, data]) => sum + data.final_duration_ms, 0);
|
||||
|
||||
const totalCost = successfulAgents.reduce((sum, [, data]) => sum + data.total_cost_usd, 0);
|
||||
|
||||
@@ -329,15 +317,13 @@ export class MetricsTracker {
|
||||
/**
|
||||
* Calculate phase-level metrics
|
||||
*/
|
||||
private calculatePhaseMetrics(
|
||||
successfulAgents: Array<[string, AgentAuditMetrics]>
|
||||
): Record<string, PhaseMetrics> {
|
||||
private calculatePhaseMetrics(successfulAgents: Array<[string, AgentAuditMetrics]>): Record<string, PhaseMetrics> {
|
||||
const phases: Record<PhaseName, AgentAuditMetrics[]> = {
|
||||
'pre-recon': [],
|
||||
'recon': [],
|
||||
recon: [],
|
||||
'vulnerability-analysis': [],
|
||||
'exploitation': [],
|
||||
'reporting': [],
|
||||
exploitation: [],
|
||||
reporting: [],
|
||||
};
|
||||
|
||||
// Group agents by phase using imported AGENT_PHASE_MAP
|
||||
@@ -11,22 +11,15 @@
|
||||
* All functions are pure and crash-safe.
|
||||
*/
|
||||
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { WORKSPACES_DIR } from '../paths.js';
|
||||
import { ensureDirectory } from '../utils/file-io.js';
|
||||
|
||||
export type { SessionMetadata } from '../types/audit.js';
|
||||
|
||||
import type { SessionMetadata } from '../types/audit.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Get Shannon repository root
|
||||
const SHANNON_ROOT = path.resolve(__dirname, '..', '..');
|
||||
const WORKSPACES_DIR = path.join(SHANNON_ROOT, 'workspaces');
|
||||
|
||||
/**
|
||||
* Extract and sanitize hostname from URL for use in identifiers
|
||||
*/
|
||||
@@ -59,7 +52,7 @@ export function generateLogPath(
|
||||
sessionMetadata: SessionMetadata,
|
||||
agentName: string,
|
||||
timestamp: number,
|
||||
attemptNumber: number
|
||||
attemptNumber: number,
|
||||
): string {
|
||||
const auditPath = generateAuditPath(sessionMetadata);
|
||||
const filename = `${timestamp}_${agentName}_attempt-${attemptNumber}.log`;
|
||||
@@ -110,10 +103,7 @@ export async function initializeAuditStructure(sessionMetadata: SessionMetadata)
|
||||
* 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> {
|
||||
export async function copyDeliverablesToAudit(sessionMetadata: SessionMetadata, repoPath: string): Promise<void> {
|
||||
const sourceDir = path.join(repoPath, 'deliverables');
|
||||
const destDir = path.join(generateAuditPath(sessionMetadata), 'deliverables');
|
||||
|
||||
@@ -11,10 +11,10 @@
|
||||
* Optimized for `tail -f` viewing during concurrent workflow execution.
|
||||
*/
|
||||
|
||||
import fs from 'fs/promises';
|
||||
import { generateWorkflowLogPath, type SessionMetadata } from './utils.js';
|
||||
import fs from 'node:fs/promises';
|
||||
import { formatDuration, formatTimestamp } from '../utils/formatting.js';
|
||||
import { LogStream } from './log-stream.js';
|
||||
import { generateWorkflowLogPath, type SessionMetadata } from './utils.js';
|
||||
|
||||
export interface AgentLogDetails {
|
||||
attemptNumber?: number;
|
||||
@@ -147,11 +147,7 @@ export class WorkflowLogger {
|
||||
/**
|
||||
* Log an agent event
|
||||
*/
|
||||
async logAgent(
|
||||
agentName: string,
|
||||
event: 'start' | 'end',
|
||||
details?: AgentLogDetails
|
||||
): Promise<void> {
|
||||
async logAgent(agentName: string, event: 'start' | 'end', details?: AgentLogDetails): Promise<void> {
|
||||
await this.ensureInitialized();
|
||||
|
||||
let message: string;
|
||||
@@ -160,7 +156,7 @@ export class WorkflowLogger {
|
||||
const attempt = details?.attemptNumber ?? 1;
|
||||
message = `${agentName}: Starting (attempt ${attempt})`;
|
||||
} else {
|
||||
const parts: string[] = [agentName + ':'];
|
||||
const parts: string[] = [`${agentName}:`];
|
||||
|
||||
if (details?.success === false) {
|
||||
parts.push('Failed');
|
||||
@@ -213,7 +209,7 @@ export class WorkflowLogger {
|
||||
*/
|
||||
private truncate(str: string, maxLen: number): string {
|
||||
if (str.length <= maxLen) return str;
|
||||
return str.slice(0, maxLen - 3) + '...';
|
||||
return `${str.slice(0, maxLen - 3)}...`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -327,11 +323,9 @@ export class WorkflowLogger {
|
||||
const label = 'Error: ';
|
||||
const indent = ' '.repeat(label.length);
|
||||
|
||||
const lines = segments.map((segment, i) =>
|
||||
i === 0 ? `${label}${segment.trim()}` : `${indent}${segment.trim()}`
|
||||
);
|
||||
const lines = segments.map((segment, i) => (i === 0 ? `${label}${segment.trim()}` : `${indent}${segment.trim()}`));
|
||||
|
||||
return lines.join('\n') + '\n';
|
||||
return `${lines.join('\n')}\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -342,35 +336,40 @@ export class WorkflowLogger {
|
||||
|
||||
const status = summary.status === 'completed' ? 'COMPLETED' : 'FAILED';
|
||||
|
||||
await this.logStream.write('\n');
|
||||
await this.logStream.write(`================================================================================\n`);
|
||||
await this.logStream.write(`Workflow ${status}\n`);
|
||||
await this.logStream.write(`────────────────────────────────────────\n`);
|
||||
await this.logStream.write(`Workflow ID: ${this.workflowId ?? this.sessionMetadata.id}\n`);
|
||||
await this.logStream.write(`Status: ${summary.status}\n`);
|
||||
await this.logStream.write(`Duration: ${formatDuration(summary.totalDurationMs)}\n`);
|
||||
await this.logStream.write(`Total Cost: $${summary.totalCostUsd.toFixed(4)}\n`);
|
||||
await this.logStream.write(`Agents: ${summary.completedAgents.length} completed\n`);
|
||||
const lines: string[] = [
|
||||
'',
|
||||
'================================================================================',
|
||||
`Workflow ${status}`,
|
||||
'────────────────────────────────────────',
|
||||
`Workflow ID: ${this.workflowId ?? this.sessionMetadata.id}`,
|
||||
`Status: ${summary.status}`,
|
||||
`Duration: ${formatDuration(summary.totalDurationMs)}`,
|
||||
`Total Cost: $${summary.totalCostUsd.toFixed(4)}`,
|
||||
`Agents: ${summary.completedAgents.length} completed`,
|
||||
];
|
||||
|
||||
if (summary.error) {
|
||||
await this.logStream.write(this.formatErrorBlock(summary.error));
|
||||
lines.push(this.formatErrorBlock(summary.error).trimEnd());
|
||||
}
|
||||
|
||||
await this.logStream.write(`\n`);
|
||||
await this.logStream.write(`Agent Breakdown:\n`);
|
||||
lines.push('');
|
||||
lines.push('Agent Breakdown:');
|
||||
|
||||
for (const agentName of summary.completedAgents) {
|
||||
const metrics = summary.agentMetrics[agentName];
|
||||
if (metrics) {
|
||||
const duration = formatDuration(metrics.durationMs);
|
||||
const cost = metrics.costUsd !== null ? `$${metrics.costUsd.toFixed(4)}` : 'N/A';
|
||||
await this.logStream.write(` - ${agentName} (${duration}, ${cost})\n`);
|
||||
lines.push(` - ${agentName} (${duration}, ${cost})`);
|
||||
} else {
|
||||
await this.logStream.write(` - ${agentName}\n`);
|
||||
lines.push(` - ${agentName}`);
|
||||
}
|
||||
}
|
||||
|
||||
await this.logStream.write(`================================================================================\n`);
|
||||
lines.push('================================================================================');
|
||||
|
||||
// Single atomic write to prevent interleaved/duplicate output in log tailers
|
||||
await this.logStream.write(`${lines.join('\n')}\n`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -4,19 +4,14 @@
|
||||
// it under the terms of the GNU Affero General Public License version 3
|
||||
// as published by the Free Software Foundation.
|
||||
|
||||
import { createRequire } from 'module';
|
||||
import { fs } from 'zx';
|
||||
import yaml from 'js-yaml';
|
||||
import { Ajv, type ValidateFunction, type ErrorObject } from 'ajv';
|
||||
import { createRequire } from 'node:module';
|
||||
import { Ajv, type ErrorObject, type ValidateFunction } from 'ajv';
|
||||
import type { FormatsPlugin } from 'ajv-formats';
|
||||
import yaml from 'js-yaml';
|
||||
import { fs } from 'zx';
|
||||
import { PentestError } from './services/error-handling.js';
|
||||
import type { Authentication, Config, DistributedConfig, Rule } from './types/config.js';
|
||||
import { ErrorCode } from './types/errors.js';
|
||||
import type {
|
||||
Config,
|
||||
Rule,
|
||||
Authentication,
|
||||
DistributedConfig,
|
||||
} from './types/config.js';
|
||||
|
||||
// Handle ESM/CJS interop for ajv-formats using require
|
||||
const require = createRequire(import.meta.url);
|
||||
@@ -35,12 +30,10 @@ try {
|
||||
validateSchema = ajv.compile(configSchema);
|
||||
} catch (error) {
|
||||
const errMsg = error instanceof Error ? error.message : String(error);
|
||||
throw new PentestError(
|
||||
`Failed to load configuration schema: ${errMsg}`,
|
||||
'config',
|
||||
false,
|
||||
{ schemaPath: '../configs/config-schema.json', originalError: errMsg }
|
||||
);
|
||||
throw new PentestError(`Failed to load configuration schema: ${errMsg}`, 'config', false, {
|
||||
schemaPath: '../configs/config-schema.json',
|
||||
originalError: errMsg,
|
||||
});
|
||||
}
|
||||
|
||||
const DANGEROUS_PATTERNS: RegExp[] = [
|
||||
@@ -185,7 +178,7 @@ export const parseConfig = async (configPath: string): Promise<Config> => {
|
||||
'config',
|
||||
false,
|
||||
{ configPath },
|
||||
ErrorCode.CONFIG_NOT_FOUND
|
||||
ErrorCode.CONFIG_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -198,7 +191,7 @@ export const parseConfig = async (configPath: string): Promise<Config> => {
|
||||
'config',
|
||||
false,
|
||||
{ configPath, fileSize: stats.size, maxFileSize },
|
||||
ErrorCode.CONFIG_VALIDATION_FAILED
|
||||
ErrorCode.CONFIG_VALIDATION_FAILED,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -211,7 +204,7 @@ export const parseConfig = async (configPath: string): Promise<Config> => {
|
||||
'config',
|
||||
false,
|
||||
{ configPath },
|
||||
ErrorCode.CONFIG_VALIDATION_FAILED
|
||||
ErrorCode.CONFIG_VALIDATION_FAILED,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -230,7 +223,7 @@ export const parseConfig = async (configPath: string): Promise<Config> => {
|
||||
'config',
|
||||
false,
|
||||
{ configPath, originalError: errMsg },
|
||||
ErrorCode.CONFIG_PARSE_ERROR
|
||||
ErrorCode.CONFIG_PARSE_ERROR,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -241,7 +234,7 @@ export const parseConfig = async (configPath: string): Promise<Config> => {
|
||||
'config',
|
||||
false,
|
||||
{ configPath },
|
||||
ErrorCode.CONFIG_PARSE_ERROR
|
||||
ErrorCode.CONFIG_PARSE_ERROR,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -260,7 +253,7 @@ export const parseConfig = async (configPath: string): Promise<Config> => {
|
||||
'config',
|
||||
false,
|
||||
{ configPath, originalError: errMsg },
|
||||
ErrorCode.CONFIG_PARSE_ERROR
|
||||
ErrorCode.CONFIG_PARSE_ERROR,
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -272,7 +265,7 @@ const validateConfig = (config: Config): void => {
|
||||
'config',
|
||||
false,
|
||||
{},
|
||||
ErrorCode.CONFIG_VALIDATION_FAILED
|
||||
ErrorCode.CONFIG_VALIDATION_FAILED,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -282,7 +275,7 @@ const validateConfig = (config: Config): void => {
|
||||
'config',
|
||||
false,
|
||||
{},
|
||||
ErrorCode.CONFIG_VALIDATION_FAILED
|
||||
ErrorCode.CONFIG_VALIDATION_FAILED,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -295,7 +288,7 @@ const validateConfig = (config: Config): void => {
|
||||
'config',
|
||||
false,
|
||||
{ validationErrors: errorMessages },
|
||||
ErrorCode.CONFIG_VALIDATION_FAILED
|
||||
ErrorCode.CONFIG_VALIDATION_FAILED,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -303,12 +296,10 @@ const validateConfig = (config: Config): void => {
|
||||
|
||||
if (!config.rules && !config.authentication) {
|
||||
console.warn(
|
||||
'⚠️ Configuration file contains no rules or authentication. The pentest will run without any scoping restrictions or login capabilities.'
|
||||
'⚠️ Configuration file contains no rules or authentication. The pentest will run without any scoping restrictions or login capabilities.',
|
||||
);
|
||||
} else if (config.rules && !config.rules.avoid && !config.rules.focus) {
|
||||
console.warn(
|
||||
'⚠️ Configuration file contains no rules. The pentest will run without any scoping restrictions.'
|
||||
);
|
||||
console.warn('⚠️ Configuration file contains no rules. The pentest will run without any scoping restrictions.');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -325,7 +316,7 @@ const performSecurityValidation = (config: Config): void => {
|
||||
'config',
|
||||
false,
|
||||
{ field: 'login_url', pattern: pattern.source },
|
||||
ErrorCode.CONFIG_VALIDATION_FAILED
|
||||
ErrorCode.CONFIG_VALIDATION_FAILED,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -339,7 +330,7 @@ const performSecurityValidation = (config: Config): void => {
|
||||
'config',
|
||||
false,
|
||||
{ field: 'credentials.username', pattern: pattern.source },
|
||||
ErrorCode.CONFIG_VALIDATION_FAILED
|
||||
ErrorCode.CONFIG_VALIDATION_FAILED,
|
||||
);
|
||||
}
|
||||
if (pattern.test(auth.credentials.password)) {
|
||||
@@ -348,7 +339,7 @@ const performSecurityValidation = (config: Config): void => {
|
||||
'config',
|
||||
false,
|
||||
{ field: 'credentials.password', pattern: pattern.source },
|
||||
ErrorCode.CONFIG_VALIDATION_FAILED
|
||||
ErrorCode.CONFIG_VALIDATION_FAILED,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -363,7 +354,7 @@ const performSecurityValidation = (config: Config): void => {
|
||||
'config',
|
||||
false,
|
||||
{ field: `login_flow[${index}]`, pattern: pattern.source },
|
||||
ErrorCode.CONFIG_VALIDATION_FAILED
|
||||
ErrorCode.CONFIG_VALIDATION_FAILED,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -392,7 +383,7 @@ const validateRulesSecurity = (rules: Rule[] | undefined, ruleType: string): voi
|
||||
'config',
|
||||
false,
|
||||
{ field: `rules.${ruleType}[${index}].url_path`, pattern: pattern.source },
|
||||
ErrorCode.CONFIG_VALIDATION_FAILED
|
||||
ErrorCode.CONFIG_VALIDATION_FAILED,
|
||||
);
|
||||
}
|
||||
if (pattern.test(rule.description)) {
|
||||
@@ -401,7 +392,7 @@ const validateRulesSecurity = (rules: Rule[] | undefined, ruleType: string): voi
|
||||
'config',
|
||||
false,
|
||||
{ field: `rules.${ruleType}[${index}].description`, pattern: pattern.source },
|
||||
ErrorCode.CONFIG_VALIDATION_FAILED
|
||||
ErrorCode.CONFIG_VALIDATION_FAILED,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -421,7 +412,7 @@ const validateRuleTypeSpecific = (rule: Rule, ruleType: string, index: number):
|
||||
'config',
|
||||
false,
|
||||
{ field, ruleType: rule.type },
|
||||
ErrorCode.CONFIG_VALIDATION_FAILED
|
||||
ErrorCode.CONFIG_VALIDATION_FAILED,
|
||||
);
|
||||
}
|
||||
break;
|
||||
@@ -435,7 +426,7 @@ const validateRuleTypeSpecific = (rule: Rule, ruleType: string, index: number):
|
||||
'config',
|
||||
false,
|
||||
{ field, ruleType: rule.type },
|
||||
ErrorCode.CONFIG_VALIDATION_FAILED
|
||||
ErrorCode.CONFIG_VALIDATION_FAILED,
|
||||
);
|
||||
}
|
||||
// Must contain at least one dot for domains
|
||||
@@ -445,7 +436,7 @@ const validateRuleTypeSpecific = (rule: Rule, ruleType: string, index: number):
|
||||
'config',
|
||||
false,
|
||||
{ field, ruleType: rule.type },
|
||||
ErrorCode.CONFIG_VALIDATION_FAILED
|
||||
ErrorCode.CONFIG_VALIDATION_FAILED,
|
||||
);
|
||||
}
|
||||
break;
|
||||
@@ -458,7 +449,7 @@ const validateRuleTypeSpecific = (rule: Rule, ruleType: string, index: number):
|
||||
'config',
|
||||
false,
|
||||
{ field, ruleType: rule.type, allowedMethods },
|
||||
ErrorCode.CONFIG_VALIDATION_FAILED
|
||||
ErrorCode.CONFIG_VALIDATION_FAILED,
|
||||
);
|
||||
}
|
||||
break;
|
||||
@@ -471,7 +462,7 @@ const validateRuleTypeSpecific = (rule: Rule, ruleType: string, index: number):
|
||||
'config',
|
||||
false,
|
||||
{ field, ruleType: rule.type },
|
||||
ErrorCode.CONFIG_VALIDATION_FAILED
|
||||
ErrorCode.CONFIG_VALIDATION_FAILED,
|
||||
);
|
||||
}
|
||||
break;
|
||||
@@ -483,7 +474,7 @@ const validateRuleTypeSpecific = (rule: Rule, ruleType: string, index: number):
|
||||
'config',
|
||||
false,
|
||||
{ field, ruleType: rule.type },
|
||||
ErrorCode.CONFIG_VALIDATION_FAILED
|
||||
ErrorCode.CONFIG_VALIDATION_FAILED,
|
||||
);
|
||||
}
|
||||
break;
|
||||
@@ -500,7 +491,7 @@ const checkForDuplicates = (rules: Rule[], ruleType: string): void => {
|
||||
'config',
|
||||
false,
|
||||
{ field: `rules.${ruleType}[${index}]`, ruleType: rule.type, urlPath: rule.url_path },
|
||||
ErrorCode.CONFIG_VALIDATION_FAILED
|
||||
ErrorCode.CONFIG_VALIDATION_FAILED,
|
||||
);
|
||||
}
|
||||
seen.add(key);
|
||||
@@ -518,7 +509,7 @@ const checkForConflicts = (avoidRules: Rule[] = [], focusRules: Rule[] = []): vo
|
||||
'config',
|
||||
false,
|
||||
{ field: `rules.focus[${index}]`, urlPath: rule.url_path },
|
||||
ErrorCode.CONFIG_VALIDATION_FAILED
|
||||
ErrorCode.CONFIG_VALIDATION_FAILED,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
/** Centralized path constants for the worker package */
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
/** Worker package root (apps/worker/) resolved from compiled dist/ files */
|
||||
const WORKER_ROOT = path.resolve(import.meta.dirname, '..');
|
||||
|
||||
export const PROMPTS_DIR = path.join(WORKER_ROOT, 'prompts');
|
||||
export const CONFIGS_DIR = path.join(WORKER_ROOT, 'configs');
|
||||
|
||||
/**
|
||||
* Repository root — walk up from WORKER_ROOT looking for pnpm-workspace.yaml.
|
||||
* Falls back to two levels up (apps/worker/ → repo root) if not found.
|
||||
*/
|
||||
function findRepoRoot(): string {
|
||||
let dir = WORKER_ROOT;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
if (fs.existsSync(path.join(dir, 'pnpm-workspace.yaml'))) {
|
||||
return dir;
|
||||
}
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return path.resolve(WORKER_ROOT, '..', '..');
|
||||
}
|
||||
|
||||
const REPO_ROOT = findRepoRoot();
|
||||
export const WORKSPACES_DIR = path.join(REPO_ROOT, 'workspaces');
|
||||
@@ -37,7 +37,7 @@ export class ProgressIndicator {
|
||||
}
|
||||
|
||||
// Clear the spinner line
|
||||
process.stdout.write('\r' + ' '.repeat(this.message.length + 5) + '\r');
|
||||
process.stdout.write(`\r${' '.repeat(this.message.length + 5)}\r`);
|
||||
this.isRunning = false;
|
||||
}
|
||||
|
||||
@@ -21,29 +21,20 @@
|
||||
* No Temporal dependencies - pure domain logic.
|
||||
*/
|
||||
|
||||
import type { ActivityLogger } from '../types/activity-logger.js';
|
||||
import { Result, ok, err, isErr } from '../types/result.js';
|
||||
import { ErrorCode, type PentestErrorType } from '../types/errors.js';
|
||||
import { PentestError } from './error-handling.js';
|
||||
import { isSpendingCapBehavior } from '../utils/billing-detection.js';
|
||||
import { type ClaudePromptResult, runClaudePrompt, validateAgentOutput } from '../ai/claude-executor.js';
|
||||
import type { AuditSession } from '../audit/index.js';
|
||||
import { AGENTS } from '../session-manager.js';
|
||||
import { loadPrompt } from './prompt-manager.js';
|
||||
import {
|
||||
runClaudePrompt,
|
||||
validateAgentOutput,
|
||||
type ClaudePromptResult,
|
||||
} from '../ai/claude-executor.js';
|
||||
import {
|
||||
createGitCheckpoint,
|
||||
commitGitSuccess,
|
||||
rollbackGitWorkspace,
|
||||
getGitCommitHash,
|
||||
} from './git-manager.js';
|
||||
import { AuditSession } from '../audit/index.js';
|
||||
import type { AgentEndResult } from '../types/audit.js';
|
||||
import type { ActivityLogger } from '../types/activity-logger.js';
|
||||
import type { AgentName } from '../types/agents.js';
|
||||
import type { ConfigLoaderService } from './config-loader.js';
|
||||
import type { AgentEndResult } from '../types/audit.js';
|
||||
import { ErrorCode, type PentestErrorType } from '../types/errors.js';
|
||||
import type { AgentMetrics } from '../types/metrics.js';
|
||||
import { err, isErr, ok, type Result } from '../types/result.js';
|
||||
import { isSpendingCapBehavior } from '../utils/billing-detection.js';
|
||||
import type { ConfigLoaderService } from './config-loader.js';
|
||||
import { PentestError } from './error-handling.js';
|
||||
import { commitGitSuccess, createGitCheckpoint, getGitCommitHash, rollbackGitWorkspace } from './git-manager.js';
|
||||
import { loadPrompt } from './prompt-manager.js';
|
||||
|
||||
/**
|
||||
* Input for agent execution.
|
||||
@@ -94,7 +85,7 @@ export class AgentExecutionService {
|
||||
agentName: AgentName,
|
||||
input: AgentExecutionInput,
|
||||
auditSession: AuditSession,
|
||||
logger: ActivityLogger
|
||||
logger: ActivityLogger,
|
||||
): Promise<Result<AgentEndResult, PentestError>> {
|
||||
const { webUrl, repoPath, configPath, pipelineTestingMode = false, attemptNumber } = input;
|
||||
|
||||
@@ -109,13 +100,7 @@ export class AgentExecutionService {
|
||||
const promptTemplate = AGENTS[agentName].promptTemplate;
|
||||
let prompt: string;
|
||||
try {
|
||||
prompt = await loadPrompt(
|
||||
promptTemplate,
|
||||
{ webUrl, repoPath },
|
||||
distributedConfig,
|
||||
pipelineTestingMode,
|
||||
logger
|
||||
);
|
||||
prompt = await loadPrompt(promptTemplate, { webUrl, repoPath }, distributedConfig, pipelineTestingMode, logger);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return err(
|
||||
@@ -124,8 +109,8 @@ export class AgentExecutionService {
|
||||
'prompt',
|
||||
false,
|
||||
{ agentName, promptTemplate, originalError: errorMessage },
|
||||
ErrorCode.PROMPT_LOAD_FAILED
|
||||
)
|
||||
ErrorCode.PROMPT_LOAD_FAILED,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -140,8 +125,8 @@ export class AgentExecutionService {
|
||||
'filesystem',
|
||||
false,
|
||||
{ agentName, repoPath, originalError: errorMessage },
|
||||
ErrorCode.GIT_CHECKPOINT_FAILED
|
||||
)
|
||||
ErrorCode.GIT_CHECKPOINT_FAILED,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -157,7 +142,7 @@ export class AgentExecutionService {
|
||||
agentName,
|
||||
auditSession,
|
||||
logger,
|
||||
AGENTS[agentName].modelTier
|
||||
AGENTS[agentName].modelTier,
|
||||
);
|
||||
|
||||
// 6. Spending cap check - defense-in-depth
|
||||
@@ -165,7 +150,8 @@ export class AgentExecutionService {
|
||||
const resultText = result.result || '';
|
||||
if (isSpendingCapBehavior(result.turns ?? 0, result.cost || 0, resultText)) {
|
||||
return this.failAgent(agentName, repoPath, auditSession, logger, {
|
||||
attemptNumber, result,
|
||||
attemptNumber,
|
||||
result,
|
||||
rollbackReason: 'spending cap detected',
|
||||
errorMessage: `Spending cap likely reached: ${resultText.slice(0, 100)}`,
|
||||
errorCode: ErrorCode.SPENDING_CAP_REACHED,
|
||||
@@ -179,7 +165,8 @@ export class AgentExecutionService {
|
||||
// 7. Handle execution failure
|
||||
if (!result.success) {
|
||||
return this.failAgent(agentName, repoPath, auditSession, logger, {
|
||||
attemptNumber, result,
|
||||
attemptNumber,
|
||||
result,
|
||||
rollbackReason: 'execution failure',
|
||||
errorMessage: result.error || 'Agent execution failed',
|
||||
errorCode: ErrorCode.AGENT_EXECUTION_FAILED,
|
||||
@@ -193,7 +180,8 @@ export class AgentExecutionService {
|
||||
const validationPassed = await validateAgentOutput(result, agentName, repoPath, logger);
|
||||
if (!validationPassed) {
|
||||
return this.failAgent(agentName, repoPath, auditSession, logger, {
|
||||
attemptNumber, result,
|
||||
attemptNumber,
|
||||
result,
|
||||
rollbackReason: 'validation failure',
|
||||
errorMessage: `Agent ${agentName} failed output validation`,
|
||||
errorCode: ErrorCode.OUTPUT_VALIDATION_FAILED,
|
||||
@@ -225,7 +213,7 @@ export class AgentExecutionService {
|
||||
repoPath: string,
|
||||
auditSession: AuditSession,
|
||||
logger: ActivityLogger,
|
||||
opts: FailAgentOpts
|
||||
opts: FailAgentOpts,
|
||||
): Promise<Result<AgentEndResult, PentestError>> {
|
||||
await rollbackGitWorkspace(repoPath, opts.rollbackReason, logger);
|
||||
|
||||
@@ -239,15 +227,7 @@ export class AgentExecutionService {
|
||||
};
|
||||
await auditSession.endAgent(agentName, endResult);
|
||||
|
||||
return err(
|
||||
new PentestError(
|
||||
opts.errorMessage,
|
||||
opts.category,
|
||||
opts.retryable,
|
||||
opts.context,
|
||||
opts.errorCode
|
||||
)
|
||||
);
|
||||
return err(new PentestError(opts.errorMessage, opts.category, opts.retryable, opts.context, opts.errorCode));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -267,7 +247,7 @@ export class AgentExecutionService {
|
||||
agentName: AgentName,
|
||||
input: AgentExecutionInput,
|
||||
auditSession: AuditSession,
|
||||
logger: ActivityLogger
|
||||
logger: ActivityLogger,
|
||||
): Promise<AgentEndResult> {
|
||||
const result = await this.execute(agentName, input, auditSession, logger);
|
||||
if (isErr(result)) {
|
||||
@@ -11,11 +11,11 @@
|
||||
* Pure service with no Temporal dependencies.
|
||||
*/
|
||||
|
||||
import { parseConfig, distributeConfig } from '../config-parser.js';
|
||||
import { PentestError } from './error-handling.js';
|
||||
import { Result, ok, err } from '../types/result.js';
|
||||
import { ErrorCode } from '../types/errors.js';
|
||||
import { distributeConfig, parseConfig } from '../config-parser.js';
|
||||
import type { DistributedConfig } from '../types/config.js';
|
||||
import { ErrorCode } from '../types/errors.js';
|
||||
import { err, ok, type Result } from '../types/result.js';
|
||||
import { PentestError } from './error-handling.js';
|
||||
|
||||
/**
|
||||
* Service for loading and distributing configuration files.
|
||||
@@ -52,8 +52,8 @@ export class ConfigLoaderService {
|
||||
'config',
|
||||
false,
|
||||
{ configPath, originalError: errorMessage },
|
||||
errorCode
|
||||
)
|
||||
errorCode,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -64,9 +64,7 @@ export class ConfigLoaderService {
|
||||
* @param configPath - Optional path to the YAML configuration file
|
||||
* @returns Result containing DistributedConfig (or null) on success, PentestError on failure
|
||||
*/
|
||||
async loadOptional(
|
||||
configPath: string | undefined
|
||||
): Promise<Result<DistributedConfig | null, PentestError>> {
|
||||
async loadOptional(configPath: string | undefined): Promise<Result<DistributedConfig | null, PentestError>> {
|
||||
if (!configPath) {
|
||||
return ok(null);
|
||||
}
|
||||
@@ -75,10 +75,7 @@ const containers = new Map<string, Container>();
|
||||
* @param sessionMetadata - Session metadata for audit paths
|
||||
* @returns Container instance for the workflow
|
||||
*/
|
||||
export function getOrCreateContainer(
|
||||
workflowId: string,
|
||||
sessionMetadata: SessionMetadata
|
||||
): Container {
|
||||
export function getOrCreateContainer(workflowId: string, sessionMetadata: SessionMetadata): Container {
|
||||
let container = containers.get(workflowId);
|
||||
|
||||
if (!container) {
|
||||
@@ -4,16 +4,8 @@
|
||||
// it under the terms of the GNU Affero General Public License version 3
|
||||
// as published by the Free Software Foundation.
|
||||
|
||||
import {
|
||||
ErrorCode,
|
||||
type PentestErrorType,
|
||||
type PentestErrorContext,
|
||||
type PromptErrorResult,
|
||||
} from '../types/errors.js';
|
||||
import {
|
||||
matchesBillingApiPattern,
|
||||
matchesBillingTextPattern,
|
||||
} from '../utils/billing-detection.js';
|
||||
import { ErrorCode, type PentestErrorContext, type PentestErrorType, type PromptErrorResult } from '../types/errors.js';
|
||||
import { matchesBillingApiPattern, matchesBillingTextPattern } from '../utils/billing-detection.js';
|
||||
|
||||
export class PentestError extends Error {
|
||||
override name = 'PentestError' as const;
|
||||
@@ -29,7 +21,7 @@ export class PentestError extends Error {
|
||||
type: PentestErrorType,
|
||||
retryable: boolean = false,
|
||||
context: PentestErrorContext = {},
|
||||
code?: ErrorCode
|
||||
code?: ErrorCode,
|
||||
) {
|
||||
super(message);
|
||||
this.type = type;
|
||||
@@ -42,18 +34,13 @@ export class PentestError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export function handlePromptError(
|
||||
promptName: string,
|
||||
error: Error
|
||||
): PromptErrorResult {
|
||||
export function handlePromptError(promptName: string, error: Error): PromptErrorResult {
|
||||
return {
|
||||
success: false,
|
||||
error: new PentestError(
|
||||
`Failed to load prompt '${promptName}': ${error.message}`,
|
||||
'prompt',
|
||||
false,
|
||||
{ promptName, originalError: error.message }
|
||||
),
|
||||
error: new PentestError(`Failed to load prompt '${promptName}': ${error.message}`, 'prompt', false, {
|
||||
promptName,
|
||||
originalError: error.message,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -111,10 +98,7 @@ export function isRetryableError(error: Error): boolean {
|
||||
* Classifies errors by ErrorCode for reliable, code-based classification.
|
||||
* Used when error is a PentestError with a specific ErrorCode.
|
||||
*/
|
||||
function classifyByErrorCode(
|
||||
code: ErrorCode,
|
||||
retryableFromError: boolean
|
||||
): { type: string; retryable: boolean } {
|
||||
function classifyByErrorCode(code: ErrorCode, retryableFromError: boolean): { type: string; retryable: boolean } {
|
||||
switch (code) {
|
||||
// Billing errors - retryable (wait for cap reset or credits added)
|
||||
case ErrorCode.SPENDING_CAP_REACHED:
|
||||
@@ -206,49 +190,30 @@ export function classifyErrorForTemporal(error: unknown): { type: string; retrya
|
||||
}
|
||||
|
||||
// Permission (403) - access won't be granted
|
||||
if (
|
||||
message.includes('permission') ||
|
||||
message.includes('forbidden') ||
|
||||
message.includes('403')
|
||||
) {
|
||||
if (message.includes('permission') || message.includes('forbidden') || message.includes('403')) {
|
||||
return { type: 'PermissionError', retryable: false };
|
||||
}
|
||||
|
||||
// === OUTPUT VALIDATION ERRORS (Retryable) ===
|
||||
// Agent didn't produce expected deliverables - retry may succeed
|
||||
// IMPORTANT: Must come BEFORE generic 'validation' check below
|
||||
if (
|
||||
message.includes('failed output validation') ||
|
||||
message.includes('output validation failed')
|
||||
) {
|
||||
if (message.includes('failed output validation') || message.includes('output validation failed')) {
|
||||
return { type: 'OutputValidationError', retryable: true };
|
||||
}
|
||||
|
||||
// Invalid Request (400) - malformed request is permanent
|
||||
// Note: Checked AFTER billing and AFTER output validation
|
||||
if (
|
||||
message.includes('invalid_request_error') ||
|
||||
message.includes('malformed') ||
|
||||
message.includes('validation')
|
||||
) {
|
||||
if (message.includes('invalid_request_error') || message.includes('malformed') || message.includes('validation')) {
|
||||
return { type: 'InvalidRequestError', retryable: false };
|
||||
}
|
||||
|
||||
// Request Too Large (413) - won't fit no matter how many retries
|
||||
if (
|
||||
message.includes('request_too_large') ||
|
||||
message.includes('too large') ||
|
||||
message.includes('413')
|
||||
) {
|
||||
if (message.includes('request_too_large') || message.includes('too large') || message.includes('413')) {
|
||||
return { type: 'RequestTooLargeError', retryable: false };
|
||||
}
|
||||
|
||||
// Configuration errors - missing files need manual fix
|
||||
if (
|
||||
message.includes('enoent') ||
|
||||
message.includes('no such file') ||
|
||||
message.includes('cli not installed')
|
||||
) {
|
||||
if (message.includes('enoent') || message.includes('no such file') || message.includes('cli not installed')) {
|
||||
return { type: 'ConfigurationError', retryable: false };
|
||||
}
|
||||
|
||||
+3
-7
@@ -13,13 +13,9 @@
|
||||
* No Temporal dependencies - this is pure business logic.
|
||||
*/
|
||||
|
||||
import {
|
||||
validateQueueSafe,
|
||||
type VulnType,
|
||||
type ExploitationDecision,
|
||||
} from './queue-validation.js';
|
||||
import { isOk } from '../types/result.js';
|
||||
import type { ActivityLogger } from '../types/activity-logger.js';
|
||||
import { isOk } from '../types/result.js';
|
||||
import { type ExploitationDecision, type VulnType, validateQueueSafe } from './queue-validation.js';
|
||||
|
||||
/**
|
||||
* Service for checking exploitation queue decisions.
|
||||
@@ -46,7 +42,7 @@ export class ExploitationCheckerService {
|
||||
if (isOk(result)) {
|
||||
const decision = result.value;
|
||||
logger.info(
|
||||
`${vulnType}: ${decision.shouldExploit ? `${decision.vulnerabilityCount} vulnerabilities found` : 'no vulnerabilities, skipping exploitation'}`
|
||||
`${vulnType}: ${decision.shouldExploit ? `${decision.vulnerabilityCount} vulnerabilities found` : 'no vulnerabilities, skipping exploitation'}`,
|
||||
);
|
||||
return decision;
|
||||
}
|
||||
@@ -5,9 +5,9 @@
|
||||
// as published by the Free Software Foundation.
|
||||
|
||||
import { $ } from 'zx';
|
||||
import { PentestError } from './error-handling.js';
|
||||
import { ErrorCode } from '../types/errors.js';
|
||||
import type { ActivityLogger } from '../types/activity-logger.js';
|
||||
import { ErrorCode } from '../types/errors.js';
|
||||
import { PentestError } from './error-handling.js';
|
||||
|
||||
/**
|
||||
* Check if a directory is a git repository.
|
||||
@@ -31,15 +31,8 @@ interface GitOperationResult {
|
||||
/**
|
||||
* Get list of changed files from git status --porcelain output
|
||||
*/
|
||||
async function getChangedFiles(
|
||||
sourceDir: string,
|
||||
operationDescription: string
|
||||
): Promise<string[]> {
|
||||
const status = await executeGitCommandWithRetry(
|
||||
['git', 'status', '--porcelain'],
|
||||
sourceDir,
|
||||
operationDescription
|
||||
);
|
||||
async function getChangedFiles(sourceDir: string, operationDescription: string): Promise<string[]> {
|
||||
const status = await executeGitCommandWithRetry(['git', 'status', '--porcelain'], sourceDir, operationDescription);
|
||||
return status.stdout
|
||||
.trim()
|
||||
.split('\n')
|
||||
@@ -55,14 +48,15 @@ function logChangeSummary(
|
||||
messageWithoutChanges: string,
|
||||
logger: ActivityLogger,
|
||||
level: 'info' | 'warn' = 'info',
|
||||
maxToShow: number = 5
|
||||
maxToShow: number = 5,
|
||||
): void {
|
||||
if (changes.length > 0) {
|
||||
const msg = messageWithChanges.replace('{count}', String(changes.length));
|
||||
const fileList = changes.slice(0, maxToShow).map((c) => ` ${c}`).join(', ');
|
||||
const suffix = changes.length > maxToShow
|
||||
? ` ... and ${changes.length - maxToShow} more files`
|
||||
: '';
|
||||
const fileList = changes
|
||||
.slice(0, maxToShow)
|
||||
.map((c) => ` ${c}`)
|
||||
.join(', ');
|
||||
const suffix = changes.length > maxToShow ? ` ... and ${changes.length - maxToShow} more files` : '';
|
||||
logger[level](`${msg} ${fileList}${suffix}`);
|
||||
} else {
|
||||
logger[level](messageWithoutChanges);
|
||||
@@ -101,7 +95,7 @@ class GitSemaphore {
|
||||
if (!this.running && this.queue.length > 0) {
|
||||
this.running = true;
|
||||
const resolve = this.queue.shift();
|
||||
resolve!();
|
||||
resolve?.();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -125,7 +119,7 @@ export async function executeGitCommandWithRetry(
|
||||
commandArgs: string[],
|
||||
sourceDir: string,
|
||||
description: string,
|
||||
maxRetries: number = 5
|
||||
maxRetries: number = 5,
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
await gitSemaphore.acquire();
|
||||
|
||||
@@ -139,11 +133,11 @@ export async function executeGitCommandWithRetry(
|
||||
const errMsg = error instanceof Error ? error.message : String(error);
|
||||
|
||||
if (isGitLockError(errMsg) && attempt < maxRetries) {
|
||||
const delay = Math.pow(2, attempt - 1) * 1000;
|
||||
const delay = 2 ** (attempt - 1) * 1000;
|
||||
// executeGitCommandWithRetry is also called outside activity context
|
||||
// (e.g., from resume logic), so we use console.warn as a fallback here
|
||||
console.warn(
|
||||
`Git lock conflict during ${description} (attempt ${attempt}/${maxRetries}). Retrying in ${delay}ms...`
|
||||
`Git lock conflict during ${description} (attempt ${attempt}/${maxRetries}). Retrying in ${delay}ms...`,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
continue;
|
||||
@@ -157,7 +151,7 @@ export async function executeGitCommandWithRetry(
|
||||
'filesystem',
|
||||
true, // Retryable - transient git lock issues
|
||||
{ maxRetries, description },
|
||||
ErrorCode.GIT_CHECKPOINT_FAILED
|
||||
ErrorCode.GIT_CHECKPOINT_FAILED,
|
||||
);
|
||||
} finally {
|
||||
gitSemaphore.release();
|
||||
@@ -168,7 +162,7 @@ export async function executeGitCommandWithRetry(
|
||||
export async function rollbackGitWorkspace(
|
||||
sourceDir: string,
|
||||
reason: string = 'retry preparation',
|
||||
logger: ActivityLogger
|
||||
logger: ActivityLogger,
|
||||
): Promise<GitOperationResult> {
|
||||
// Skip git operations if not a git repository
|
||||
if (!(await isGitRepository(sourceDir))) {
|
||||
@@ -180,16 +174,8 @@ export async function rollbackGitWorkspace(
|
||||
try {
|
||||
const changes = await getChangedFiles(sourceDir, 'status check for rollback');
|
||||
|
||||
await executeGitCommandWithRetry(
|
||||
['git', 'reset', '--hard', 'HEAD'],
|
||||
sourceDir,
|
||||
'hard reset for rollback'
|
||||
);
|
||||
await executeGitCommandWithRetry(
|
||||
['git', 'clean', '-fd'],
|
||||
sourceDir,
|
||||
'cleaning untracked files for rollback'
|
||||
);
|
||||
await executeGitCommandWithRetry(['git', 'reset', '--hard', 'HEAD'], sourceDir, 'hard reset for rollback');
|
||||
await executeGitCommandWithRetry(['git', 'clean', '-fd'], sourceDir, 'cleaning untracked files for rollback');
|
||||
|
||||
logChangeSummary(
|
||||
changes,
|
||||
@@ -197,7 +183,7 @@ export async function rollbackGitWorkspace(
|
||||
'Rollback completed - no changes to remove',
|
||||
logger,
|
||||
'info',
|
||||
3
|
||||
3,
|
||||
);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
@@ -210,7 +196,7 @@ export async function rollbackGitWorkspace(
|
||||
'filesystem',
|
||||
false, // Non-retryable - rollback is best-effort cleanup
|
||||
{ sourceDir, reason },
|
||||
ErrorCode.GIT_ROLLBACK_FAILED
|
||||
ErrorCode.GIT_ROLLBACK_FAILED,
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -221,7 +207,7 @@ export async function createGitCheckpoint(
|
||||
sourceDir: string,
|
||||
description: string,
|
||||
attempt: number,
|
||||
logger: ActivityLogger
|
||||
logger: ActivityLogger,
|
||||
): Promise<GitOperationResult> {
|
||||
// Skip git operations if not a git repository
|
||||
if (!(await isGitRepository(sourceDir))) {
|
||||
@@ -248,7 +234,7 @@ export async function createGitCheckpoint(
|
||||
await executeGitCommandWithRetry(
|
||||
['git', 'commit', '-m', `📍 Checkpoint: ${description} (attempt ${attempt})`, '--allow-empty'],
|
||||
sourceDir,
|
||||
'creating commit'
|
||||
'creating commit',
|
||||
);
|
||||
|
||||
// 4. Log result
|
||||
@@ -268,7 +254,7 @@ export async function createGitCheckpoint(
|
||||
export async function commitGitSuccess(
|
||||
sourceDir: string,
|
||||
description: string,
|
||||
logger: ActivityLogger
|
||||
logger: ActivityLogger,
|
||||
): Promise<GitOperationResult> {
|
||||
// Skip git operations if not a git repository
|
||||
if (!(await isGitRepository(sourceDir))) {
|
||||
@@ -280,22 +266,18 @@ export async function commitGitSuccess(
|
||||
try {
|
||||
const changes = await getChangedFiles(sourceDir, 'status check for success commit');
|
||||
|
||||
await executeGitCommandWithRetry(
|
||||
['git', 'add', '-A'],
|
||||
sourceDir,
|
||||
'staging changes for success commit'
|
||||
);
|
||||
await executeGitCommandWithRetry(['git', 'add', '-A'], sourceDir, 'staging changes for success commit');
|
||||
await executeGitCommandWithRetry(
|
||||
['git', 'commit', '-m', `✅ ${description}: completed successfully`, '--allow-empty'],
|
||||
sourceDir,
|
||||
'creating success commit'
|
||||
'creating success commit',
|
||||
);
|
||||
|
||||
logChangeSummary(
|
||||
changes,
|
||||
'Success commit created with {count} file changes:',
|
||||
'Empty success commit created (agent made no file changes)',
|
||||
logger
|
||||
logger,
|
||||
);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
@@ -11,13 +11,12 @@
|
||||
* Services are pure domain logic with no Temporal dependencies.
|
||||
*/
|
||||
|
||||
export { Container, getOrCreateContainer, removeContainer } from './container.js';
|
||||
export type { ContainerDependencies } from './container.js';
|
||||
export type { AgentExecutionInput } from './agent-execution.js';
|
||||
export { AgentExecutionService } from './agent-execution.js';
|
||||
|
||||
export { ConfigLoaderService } from './config-loader.js';
|
||||
export type { ContainerDependencies } from './container.js';
|
||||
export { Container, getOrCreateContainer, removeContainer } from './container.js';
|
||||
export { ExploitationCheckerService } from './exploitation-checker.js';
|
||||
export { AgentExecutionService } from './agent-execution.js';
|
||||
export type { AgentExecutionInput } from './agent-execution.js';
|
||||
|
||||
export { assembleFinalReport, injectModelIntoReport } from './reporting.js';
|
||||
export { loadPrompt } from './prompt-manager.js';
|
||||
export { assembleFinalReport, injectModelIntoReport } from './reporting.js';
|
||||
@@ -17,22 +17,19 @@
|
||||
* 3. Credentials validate via Claude Agent SDK query (API key, OAuth, Bedrock, Vertex AI, or router mode)
|
||||
*/
|
||||
|
||||
import fs from 'fs/promises';
|
||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
import fs from 'node:fs/promises';
|
||||
import type { SDKAssistantMessageError } from '@anthropic-ai/claude-agent-sdk';
|
||||
import { PentestError, isRetryableError } from './error-handling.js';
|
||||
import { ErrorCode } from '../types/errors.js';
|
||||
import { type Result, ok, err } from '../types/result.js';
|
||||
import { parseConfig } from '../config-parser.js';
|
||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
import { resolveModel } from '../ai/models.js';
|
||||
import { parseConfig } from '../config-parser.js';
|
||||
import type { ActivityLogger } from '../types/activity-logger.js';
|
||||
import { ErrorCode } from '../types/errors.js';
|
||||
import { err, ok, type Result } from '../types/result.js';
|
||||
import { isRetryableError, PentestError } from './error-handling.js';
|
||||
|
||||
// === Repository Validation ===
|
||||
|
||||
async function validateRepo(
|
||||
repoPath: string,
|
||||
logger: ActivityLogger
|
||||
): Promise<Result<void, PentestError>> {
|
||||
async function validateRepo(repoPath: string, logger: ActivityLogger): Promise<Result<void, PentestError>> {
|
||||
logger.info('Checking repository path...', { repoPath });
|
||||
|
||||
// 1. Check repo directory exists
|
||||
@@ -45,8 +42,8 @@ async function validateRepo(
|
||||
'config',
|
||||
false,
|
||||
{ repoPath },
|
||||
ErrorCode.REPO_NOT_FOUND
|
||||
)
|
||||
ErrorCode.REPO_NOT_FOUND,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
@@ -56,8 +53,8 @@ async function validateRepo(
|
||||
'config',
|
||||
false,
|
||||
{ repoPath },
|
||||
ErrorCode.REPO_NOT_FOUND
|
||||
)
|
||||
ErrorCode.REPO_NOT_FOUND,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -71,8 +68,8 @@ async function validateRepo(
|
||||
'config',
|
||||
false,
|
||||
{ repoPath },
|
||||
ErrorCode.REPO_NOT_FOUND
|
||||
)
|
||||
ErrorCode.REPO_NOT_FOUND,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
@@ -82,8 +79,8 @@ async function validateRepo(
|
||||
'config',
|
||||
false,
|
||||
{ repoPath },
|
||||
ErrorCode.REPO_NOT_FOUND
|
||||
)
|
||||
ErrorCode.REPO_NOT_FOUND,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -93,10 +90,7 @@ async function validateRepo(
|
||||
|
||||
// === Config Validation ===
|
||||
|
||||
async function validateConfig(
|
||||
configPath: string,
|
||||
logger: ActivityLogger
|
||||
): Promise<Result<void, PentestError>> {
|
||||
async function validateConfig(configPath: string, logger: ActivityLogger): Promise<Result<void, PentestError>> {
|
||||
logger.info('Validating configuration file...', { configPath });
|
||||
|
||||
try {
|
||||
@@ -114,8 +108,8 @@ async function validateConfig(
|
||||
'config',
|
||||
false,
|
||||
{ configPath },
|
||||
ErrorCode.CONFIG_VALIDATION_FAILED
|
||||
)
|
||||
ErrorCode.CONFIG_VALIDATION_FAILED,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -123,43 +117,60 @@ async function validateConfig(
|
||||
// === Credential Validation ===
|
||||
|
||||
/** Map SDK error type to a human-readable preflight PentestError. */
|
||||
function classifySdkError(
|
||||
sdkError: SDKAssistantMessageError,
|
||||
authType: string
|
||||
): Result<void, PentestError> {
|
||||
function classifySdkError(sdkError: SDKAssistantMessageError, authType: string): Result<void, PentestError> {
|
||||
switch (sdkError) {
|
||||
case 'authentication_failed':
|
||||
return err(new PentestError(
|
||||
`Invalid ${authType}. Check your credentials in .env and try again.`,
|
||||
'config', false, { authType, sdkError }, ErrorCode.AUTH_FAILED
|
||||
));
|
||||
return err(
|
||||
new PentestError(
|
||||
`Invalid ${authType}. Check your credentials in .env and try again.`,
|
||||
'config',
|
||||
false,
|
||||
{ authType, sdkError },
|
||||
ErrorCode.AUTH_FAILED,
|
||||
),
|
||||
);
|
||||
case 'billing_error':
|
||||
return err(new PentestError(
|
||||
`Anthropic account has a billing issue. Add credits or check your billing dashboard.`,
|
||||
'billing', true, { authType, sdkError }, ErrorCode.BILLING_ERROR
|
||||
));
|
||||
return err(
|
||||
new PentestError(
|
||||
`Anthropic account has a billing issue. Add credits or check your billing dashboard.`,
|
||||
'billing',
|
||||
true,
|
||||
{ authType, sdkError },
|
||||
ErrorCode.BILLING_ERROR,
|
||||
),
|
||||
);
|
||||
case 'rate_limit':
|
||||
return err(new PentestError(
|
||||
`Anthropic rate limit or spending cap reached. Wait a few minutes and try again.`,
|
||||
'billing', true, { authType, sdkError }, ErrorCode.BILLING_ERROR
|
||||
));
|
||||
return err(
|
||||
new PentestError(
|
||||
`Anthropic rate limit or spending cap reached. Wait a few minutes and try again.`,
|
||||
'billing',
|
||||
true,
|
||||
{ authType, sdkError },
|
||||
ErrorCode.BILLING_ERROR,
|
||||
),
|
||||
);
|
||||
case 'server_error':
|
||||
return err(new PentestError(
|
||||
`Anthropic API is temporarily unavailable. Try again shortly.`,
|
||||
'network', true, { authType, sdkError }
|
||||
));
|
||||
return err(
|
||||
new PentestError(`Anthropic API is temporarily unavailable. Try again shortly.`, 'network', true, {
|
||||
authType,
|
||||
sdkError,
|
||||
}),
|
||||
);
|
||||
default:
|
||||
return err(new PentestError(
|
||||
`${authType} validation failed unexpectedly. Check your credentials in .env.`,
|
||||
'config', false, { authType, sdkError }, ErrorCode.AUTH_FAILED
|
||||
));
|
||||
return err(
|
||||
new PentestError(
|
||||
`${authType} validation failed unexpectedly. Check your credentials in .env.`,
|
||||
'config',
|
||||
false,
|
||||
{ authType, sdkError },
|
||||
ErrorCode.AUTH_FAILED,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate credentials via a minimal Claude Agent SDK query. */
|
||||
async function validateCredentials(
|
||||
logger: ActivityLogger
|
||||
): Promise<Result<void, PentestError>> {
|
||||
async function validateCredentials(logger: ActivityLogger): Promise<Result<void, PentestError>> {
|
||||
// 1. Custom base URL — validate endpoint is reachable via SDK query
|
||||
if (process.env.ANTHROPIC_BASE_URL) {
|
||||
const baseUrl = process.env.ANTHROPIC_BASE_URL;
|
||||
@@ -193,8 +204,14 @@ async function validateCredentials(
|
||||
|
||||
// 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 missing = required.filter(v => !process.env[v]);
|
||||
const required = [
|
||||
'AWS_REGION',
|
||||
'AWS_BEARER_TOKEN_BEDROCK',
|
||||
'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(
|
||||
@@ -202,8 +219,8 @@ async function validateCredentials(
|
||||
'config',
|
||||
false,
|
||||
{ missing },
|
||||
ErrorCode.AUTH_FAILED
|
||||
)
|
||||
ErrorCode.AUTH_FAILED,
|
||||
),
|
||||
);
|
||||
}
|
||||
logger.info('Bedrock credentials OK');
|
||||
@@ -212,8 +229,14 @@ async function validateCredentials(
|
||||
|
||||
// 3. Vertex AI mode — validate required GCP credentials are present
|
||||
if (process.env.CLAUDE_CODE_USE_VERTEX === '1') {
|
||||
const required = ['CLOUD_ML_REGION', 'ANTHROPIC_VERTEX_PROJECT_ID', 'ANTHROPIC_SMALL_MODEL', 'ANTHROPIC_MEDIUM_MODEL', 'ANTHROPIC_LARGE_MODEL'];
|
||||
const missing = required.filter(v => !process.env[v]);
|
||||
const required = [
|
||||
'CLOUD_ML_REGION',
|
||||
'ANTHROPIC_VERTEX_PROJECT_ID',
|
||||
'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(
|
||||
@@ -221,8 +244,8 @@ async function validateCredentials(
|
||||
'config',
|
||||
false,
|
||||
{ missing },
|
||||
ErrorCode.AUTH_FAILED
|
||||
)
|
||||
ErrorCode.AUTH_FAILED,
|
||||
),
|
||||
);
|
||||
}
|
||||
// Validate service account credentials file is accessible
|
||||
@@ -234,8 +257,8 @@ async function validateCredentials(
|
||||
'config',
|
||||
false,
|
||||
{},
|
||||
ErrorCode.AUTH_FAILED
|
||||
)
|
||||
ErrorCode.AUTH_FAILED,
|
||||
),
|
||||
);
|
||||
}
|
||||
try {
|
||||
@@ -247,8 +270,8 @@ async function validateCredentials(
|
||||
'config',
|
||||
false,
|
||||
{ credPath },
|
||||
ErrorCode.AUTH_FAILED
|
||||
)
|
||||
ErrorCode.AUTH_FAILED,
|
||||
),
|
||||
);
|
||||
}
|
||||
logger.info('Vertex AI credentials OK');
|
||||
@@ -263,8 +286,8 @@ async function validateCredentials(
|
||||
'config',
|
||||
false,
|
||||
{},
|
||||
ErrorCode.AUTH_FAILED
|
||||
)
|
||||
ErrorCode.AUTH_FAILED,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -296,8 +319,8 @@ async function validateCredentials(
|
||||
retryable ? 'network' : 'config',
|
||||
retryable,
|
||||
{ authType },
|
||||
retryable ? undefined : ErrorCode.AUTH_FAILED
|
||||
)
|
||||
retryable ? undefined : ErrorCode.AUTH_FAILED,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -316,7 +339,7 @@ async function validateCredentials(
|
||||
export async function runPreflightChecks(
|
||||
repoPath: string,
|
||||
configPath: string | undefined,
|
||||
logger: ActivityLogger
|
||||
logger: ActivityLogger,
|
||||
): Promise<Result<void, PentestError>> {
|
||||
// 1. Repository check (free — filesystem only)
|
||||
const repoResult = await validateRepo(repoPath, logger);
|
||||
@@ -5,10 +5,11 @@
|
||||
// as published by the Free Software Foundation.
|
||||
|
||||
import { fs, path } from 'zx';
|
||||
import { PentestError, handlePromptError } from './error-handling.js';
|
||||
import { PROMPTS_DIR } from '../paths.js';
|
||||
import { MCP_AGENT_MAPPING } from '../session-manager.js';
|
||||
import type { Authentication, DistributedConfig } from '../types/config.js';
|
||||
import type { ActivityLogger } from '../types/activity-logger.js';
|
||||
import type { Authentication, DistributedConfig } from '../types/config.js';
|
||||
import { handlePromptError, PentestError } from './error-handling.js';
|
||||
|
||||
interface PromptVariables {
|
||||
webUrl: string;
|
||||
@@ -25,15 +26,10 @@ interface IncludeReplacement {
|
||||
async function buildLoginInstructions(authentication: Authentication, logger: ActivityLogger): Promise<string> {
|
||||
try {
|
||||
// 1. Load the login instructions template
|
||||
const loginInstructionsPath = path.join(import.meta.dirname, '..', '..', 'prompts', 'shared', 'login-instructions.txt');
|
||||
const loginInstructionsPath = path.join(PROMPTS_DIR, 'shared', 'login-instructions.txt');
|
||||
|
||||
if (!await fs.pathExists(loginInstructionsPath)) {
|
||||
throw new PentestError(
|
||||
'Login instructions template not found',
|
||||
'filesystem',
|
||||
false,
|
||||
{ loginInstructionsPath }
|
||||
);
|
||||
if (!(await fs.pathExists(loginInstructionsPath))) {
|
||||
throw new PentestError('Login instructions template not found', 'filesystem', false, { loginInstructionsPath });
|
||||
}
|
||||
|
||||
const fullTemplate = await fs.readFile(loginInstructionsPath, 'utf8');
|
||||
@@ -57,9 +53,7 @@ async function buildLoginInstructions(authentication: Authentication, logger: Ac
|
||||
logger.warn('Section markers not found, using full login instructions template');
|
||||
loginInstructions = fullTemplate;
|
||||
} else {
|
||||
loginInstructions = [commonSection, authSection, verificationSection]
|
||||
.filter(section => section)
|
||||
.join('\n\n');
|
||||
loginInstructions = [commonSection, authSection, verificationSection].filter((section) => section).join('\n\n');
|
||||
}
|
||||
|
||||
// 4. Interpolate login flow and credential placeholders
|
||||
@@ -73,7 +67,10 @@ async function buildLoginInstructions(authentication: Authentication, logger: Ac
|
||||
userInstructions = userInstructions.replace(/\$password/g, authentication.credentials.password);
|
||||
}
|
||||
if (authentication.credentials.totp_secret) {
|
||||
userInstructions = userInstructions.replace(/\$totp/g, `generated TOTP code using secret "${authentication.credentials.totp_secret}"`);
|
||||
userInstructions = userInstructions.replace(
|
||||
/\$totp/g,
|
||||
`generated TOTP code using secret "${authentication.credentials.totp_secret}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,12 +87,10 @@ async function buildLoginInstructions(authentication: Authentication, logger: Ac
|
||||
throw error;
|
||||
}
|
||||
const errMsg = error instanceof Error ? error.message : String(error);
|
||||
throw new PentestError(
|
||||
`Failed to build login instructions: ${errMsg}`,
|
||||
'config',
|
||||
false,
|
||||
{ authentication, originalError: errMsg }
|
||||
);
|
||||
throw new PentestError(`Failed to build login instructions: ${errMsg}`, 'config', false, {
|
||||
authentication,
|
||||
originalError: errMsg,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,7 +107,7 @@ async function processIncludes(content: string, baseDir: string): Promise<string
|
||||
`Path traversal detected in @include(): ${match[1]}`,
|
||||
'prompt',
|
||||
false,
|
||||
{ includePath, baseDir: resolvedBase }
|
||||
{ includePath, baseDir: resolvedBase },
|
||||
);
|
||||
}
|
||||
const sharedContent = await fs.readFile(includePath, 'utf8');
|
||||
@@ -120,7 +115,7 @@ async function processIncludes(content: string, baseDir: string): Promise<string
|
||||
placeholder: match[0],
|
||||
content: sharedContent,
|
||||
};
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
for (const replacement of replacements) {
|
||||
@@ -134,25 +129,20 @@ async function interpolateVariables(
|
||||
template: string,
|
||||
variables: PromptVariables,
|
||||
config: DistributedConfig | null = null,
|
||||
logger: ActivityLogger
|
||||
logger: ActivityLogger,
|
||||
): Promise<string> {
|
||||
try {
|
||||
if (!template || typeof template !== 'string') {
|
||||
throw new PentestError(
|
||||
'Template must be a non-empty string',
|
||||
'validation',
|
||||
false,
|
||||
{ templateType: typeof template, templateLength: template?.length }
|
||||
);
|
||||
throw new PentestError('Template must be a non-empty string', 'validation', false, {
|
||||
templateType: typeof template,
|
||||
templateLength: template?.length,
|
||||
});
|
||||
}
|
||||
|
||||
if (!variables || !variables.webUrl || !variables.repoPath) {
|
||||
throw new PentestError(
|
||||
'Variables must include webUrl and repoPath',
|
||||
'validation',
|
||||
false,
|
||||
{ variables: Object.keys(variables || {}) }
|
||||
);
|
||||
throw new PentestError('Variables must include webUrl and repoPath', 'validation', false, {
|
||||
variables: Object.keys(variables || {}),
|
||||
});
|
||||
}
|
||||
|
||||
let result = template
|
||||
@@ -170,12 +160,10 @@ async function interpolateVariables(
|
||||
const cleanRulesSection = '<rules>\nNo specific rules or focus areas provided for this test.\n</rules>';
|
||||
result = result.replace(/<rules>[\s\S]*?<\/rules>/g, cleanRulesSection);
|
||||
} else {
|
||||
const avoidRules = hasAvoidRules ? config.avoid!.map(r => `- ${r.description}`).join('\n') : 'None';
|
||||
const focusRules = hasFocusRules ? config.focus!.map(r => `- ${r.description}`).join('\n') : 'None';
|
||||
const avoidRules = hasAvoidRules ? config.avoid?.map((r) => `- ${r.description}`).join('\n') : 'None';
|
||||
const focusRules = hasFocusRules ? config.focus?.map((r) => `- ${r.description}`).join('\n') : 'None';
|
||||
|
||||
result = result
|
||||
.replace(/{{RULES_AVOID}}/g, avoidRules)
|
||||
.replace(/{{RULES_FOCUS}}/g, focusRules);
|
||||
result = result.replace(/{{RULES_AVOID}}/g, avoidRules).replace(/{{RULES_FOCUS}}/g, focusRules);
|
||||
}
|
||||
|
||||
// Extract and inject login instructions from config
|
||||
@@ -204,12 +192,7 @@ async function interpolateVariables(
|
||||
throw error;
|
||||
}
|
||||
const errMsg = error instanceof Error ? error.message : String(error);
|
||||
throw new PentestError(
|
||||
`Variable interpolation failed: ${errMsg}`,
|
||||
'prompt',
|
||||
false,
|
||||
{ originalError: errMsg }
|
||||
);
|
||||
throw new PentestError(`Variable interpolation failed: ${errMsg}`, 'prompt', false, { originalError: errMsg });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,25 +202,19 @@ export async function loadPrompt(
|
||||
variables: PromptVariables,
|
||||
config: DistributedConfig | null = null,
|
||||
pipelineTestingMode: boolean = false,
|
||||
logger: ActivityLogger
|
||||
logger: ActivityLogger,
|
||||
): Promise<string> {
|
||||
try {
|
||||
// 1. Resolve prompt file path
|
||||
const baseDir = pipelineTestingMode ? 'prompts/pipeline-testing' : 'prompts';
|
||||
const promptsDir = path.join(import.meta.dirname, '..', '..', baseDir);
|
||||
const promptsDir = pipelineTestingMode ? path.join(PROMPTS_DIR, 'pipeline-testing') : PROMPTS_DIR;
|
||||
const promptPath = path.join(promptsDir, `${promptName}.txt`);
|
||||
|
||||
if (pipelineTestingMode) {
|
||||
logger.info(`Using pipeline testing prompt: ${promptPath}`);
|
||||
}
|
||||
|
||||
if (!await fs.pathExists(promptPath)) {
|
||||
throw new PentestError(
|
||||
`Prompt file not found: ${promptPath}`,
|
||||
'prompt',
|
||||
false,
|
||||
{ promptName, promptPath }
|
||||
);
|
||||
if (!(await fs.pathExists(promptPath))) {
|
||||
throw new PentestError(`Prompt file not found: ${promptPath}`, 'prompt', false, { promptName, promptPath });
|
||||
}
|
||||
|
||||
// 2. Assign MCP server based on agent name
|
||||
@@ -5,13 +5,13 @@
|
||||
// as published by the Free Software Foundation.
|
||||
|
||||
import { fs, path } from 'zx';
|
||||
import { PentestError } from './error-handling.js';
|
||||
import type { ExploitationDecision, VulnType } from '../types/agents.js';
|
||||
import { ErrorCode } from '../types/errors.js';
|
||||
import { type Result, ok, err } from '../types/result.js';
|
||||
import { err, ok, type Result } from '../types/result.js';
|
||||
import { asyncPipe } from '../utils/functional.js';
|
||||
import type { VulnType, ExploitationDecision } from '../types/agents.js';
|
||||
import { PentestError } from './error-handling.js';
|
||||
|
||||
export type { VulnType, ExploitationDecision } from '../types/agents.js';
|
||||
export type { ExploitationDecision, VulnType } from '../types/agents.js';
|
||||
|
||||
interface VulnTypeConfigItem {
|
||||
deliverable: string;
|
||||
@@ -63,7 +63,6 @@ interface QueueValidationResult {
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Result type for safe validation - explicit error handling.
|
||||
*/
|
||||
@@ -97,7 +96,7 @@ const VULN_TYPE_CONFIG: VulnTypeConfig = Object.freeze({
|
||||
function createValidationRule(
|
||||
predicate: (existence: FileExistence) => boolean,
|
||||
errorMessage: ErrorMessageResolver,
|
||||
retryable: boolean = true
|
||||
retryable: boolean = true,
|
||||
): ValidationRule {
|
||||
return Object.freeze({ predicate, errorMessage, retryable });
|
||||
}
|
||||
@@ -106,7 +105,7 @@ function createValidationRule(
|
||||
const fileExistenceRules: readonly ValidationRule[] = Object.freeze([
|
||||
createValidationRule(
|
||||
({ deliverableExists, queueExists }) => deliverableExists && queueExists,
|
||||
getExistenceErrorMessage
|
||||
getExistenceErrorMessage,
|
||||
),
|
||||
]);
|
||||
|
||||
@@ -124,19 +123,11 @@ function getExistenceErrorMessage(existence: FileExistence): string {
|
||||
}
|
||||
|
||||
// Pure function to create file paths
|
||||
const createPaths = (
|
||||
vulnType: VulnType,
|
||||
sourceDir: string
|
||||
): PathsBase | PathsWithError => {
|
||||
const createPaths = (vulnType: VulnType, sourceDir: string): PathsBase | PathsWithError => {
|
||||
const config = VULN_TYPE_CONFIG[vulnType];
|
||||
if (!config) {
|
||||
return {
|
||||
error: new PentestError(
|
||||
`Unknown vulnerability type: ${vulnType}`,
|
||||
'validation',
|
||||
false,
|
||||
{ vulnType }
|
||||
),
|
||||
error: new PentestError(`Unknown vulnerability type: ${vulnType}`, 'validation', false, { vulnType }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -149,9 +140,7 @@ const createPaths = (
|
||||
};
|
||||
|
||||
// Pure function to check file existence
|
||||
const checkFileExistence = async (
|
||||
paths: PathsBase | PathsWithError
|
||||
): Promise<PathsWithExistence | PathsWithError> => {
|
||||
const checkFileExistence = async (paths: PathsBase | PathsWithError): Promise<PathsWithExistence | PathsWithError> => {
|
||||
if ('error' in paths) return paths;
|
||||
|
||||
const [deliverableExists, queueExists] = await Promise.all([
|
||||
@@ -167,7 +156,7 @@ const checkFileExistence = async (
|
||||
|
||||
// Validates deliverable/queue symmetry - both must exist or neither
|
||||
const validateExistenceRules = (
|
||||
pathsWithExistence: PathsWithExistence | PathsWithError
|
||||
pathsWithExistence: PathsWithExistence | PathsWithError,
|
||||
): PathsWithExistence | PathsWithError => {
|
||||
if ('error' in pathsWithExistence) return pathsWithExistence;
|
||||
|
||||
@@ -178,9 +167,7 @@ const validateExistenceRules = (
|
||||
|
||||
if (failedRule) {
|
||||
const message =
|
||||
typeof failedRule.errorMessage === 'function'
|
||||
? failedRule.errorMessage(existence)
|
||||
: failedRule.errorMessage;
|
||||
typeof failedRule.errorMessage === 'function' ? failedRule.errorMessage(existence) : failedRule.errorMessage;
|
||||
|
||||
return {
|
||||
error: new PentestError(
|
||||
@@ -193,7 +180,7 @@ const validateExistenceRules = (
|
||||
queuePath: pathsWithExistence.queue,
|
||||
existence,
|
||||
},
|
||||
ErrorCode.DELIVERABLE_NOT_FOUND
|
||||
ErrorCode.DELIVERABLE_NOT_FOUND,
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -227,7 +214,7 @@ const validateQueueStructure = (content: string): QueueValidationResult => {
|
||||
|
||||
// Queue parse failures are retryable - agent can fix malformed JSON on retry
|
||||
const validateQueueContent = async (
|
||||
pathsWithExistence: PathsWithExistence | PathsWithError
|
||||
pathsWithExistence: PathsWithExistence | PathsWithError,
|
||||
): Promise<PathsWithQueue | PathsWithError> => {
|
||||
if ('error' in pathsWithExistence) return pathsWithExistence;
|
||||
|
||||
@@ -249,7 +236,7 @@ const validateQueueContent = async (
|
||||
queuePath: pathsWithExistence.queue,
|
||||
originalError: queueValidation.error,
|
||||
queueStructure: queueValidation.data ? Object.keys(queueValidation.data) : [],
|
||||
}
|
||||
},
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -268,16 +255,14 @@ const validateQueueContent = async (
|
||||
vulnType: pathsWithExistence.vulnType,
|
||||
queuePath: pathsWithExistence.queue,
|
||||
originalError: readError instanceof Error ? readError.message : String(readError),
|
||||
}
|
||||
},
|
||||
),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Final decision: skip if queue says no vulns, proceed if vulns found, error otherwise
|
||||
const determineExploitationDecision = (
|
||||
validatedData: PathsWithQueue | PathsWithError
|
||||
): ExploitationDecision => {
|
||||
const determineExploitationDecision = (validatedData: PathsWithQueue | PathsWithError): ExploitationDecision => {
|
||||
if ('error' in validatedData) {
|
||||
throw validatedData.error;
|
||||
}
|
||||
@@ -297,14 +282,14 @@ const determineExploitationDecision = (
|
||||
// Main functional validation pipeline
|
||||
export async function validateQueueAndDeliverable(
|
||||
vulnType: VulnType,
|
||||
sourceDir: string
|
||||
sourceDir: string,
|
||||
): Promise<ExploitationDecision> {
|
||||
return asyncPipe<ExploitationDecision>(
|
||||
createPaths(vulnType, sourceDir),
|
||||
checkFileExistence,
|
||||
validateExistenceRules,
|
||||
validateQueueContent,
|
||||
determineExploitationDecision
|
||||
determineExploitationDecision,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -312,10 +297,7 @@ export async function validateQueueAndDeliverable(
|
||||
* Safely validate queue and deliverable files.
|
||||
* Returns Result<ExploitationDecision, PentestError> for explicit error handling.
|
||||
*/
|
||||
export async function validateQueueSafe(
|
||||
vulnType: VulnType,
|
||||
sourceDir: string
|
||||
): Promise<SafeValidationResult> {
|
||||
export async function validateQueueSafe(vulnType: VulnType, sourceDir: string): Promise<SafeValidationResult> {
|
||||
try {
|
||||
const result = await validateQueueAndDeliverable(vulnType, sourceDir);
|
||||
return ok(result);
|
||||
@@ -5,9 +5,9 @@
|
||||
// as published by the Free Software Foundation.
|
||||
|
||||
import { fs, path } from 'zx';
|
||||
import { PentestError } from './error-handling.js';
|
||||
import { ErrorCode } from '../types/errors.js';
|
||||
import type { ActivityLogger } from '../types/activity-logger.js';
|
||||
import { ErrorCode } from '../types/errors.js';
|
||||
import { PentestError } from './error-handling.js';
|
||||
|
||||
interface DeliverableFile {
|
||||
name: string;
|
||||
@@ -22,7 +22,7 @@ export async function assembleFinalReport(sourceDir: string, logger: ActivityLog
|
||||
{ name: 'XSS', path: 'xss_exploitation_evidence.md', required: false },
|
||||
{ name: 'Authentication', path: 'auth_exploitation_evidence.md', required: false },
|
||||
{ name: 'SSRF', path: 'ssrf_exploitation_evidence.md', required: false },
|
||||
{ name: 'Authorization', path: 'authz_exploitation_evidence.md', required: false }
|
||||
{ name: 'Authorization', path: 'authz_exploitation_evidence.md', required: false },
|
||||
];
|
||||
|
||||
const sections: string[] = [];
|
||||
@@ -40,7 +40,7 @@ export async function assembleFinalReport(sourceDir: string, logger: ActivityLog
|
||||
'filesystem',
|
||||
false,
|
||||
{ deliverableFile: file.path, sourceDir },
|
||||
ErrorCode.DELIVERABLE_NOT_FOUND
|
||||
ErrorCode.DELIVERABLE_NOT_FOUND,
|
||||
);
|
||||
} else {
|
||||
logger.info(`No ${file.name} deliverable found`);
|
||||
@@ -65,12 +65,10 @@ export async function assembleFinalReport(sourceDir: string, logger: ActivityLog
|
||||
logger.info(`Final report assembled at ${finalReportPath}`);
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
throw new PentestError(
|
||||
`Failed to write final report: ${err.message}`,
|
||||
'filesystem',
|
||||
false,
|
||||
{ finalReportPath, originalError: err.message }
|
||||
);
|
||||
throw new PentestError(`Failed to write final report: ${err.message}`, 'filesystem', false, {
|
||||
finalReportPath,
|
||||
originalError: err.message,
|
||||
});
|
||||
}
|
||||
|
||||
return finalContent;
|
||||
@@ -84,7 +82,7 @@ export async function assembleFinalReport(sourceDir: string, logger: ActivityLog
|
||||
export async function injectModelIntoReport(
|
||||
repoPath: string,
|
||||
outputPath: string,
|
||||
logger: ActivityLogger
|
||||
logger: ActivityLogger,
|
||||
): Promise<void> {
|
||||
// 1. Read session.json to get model information
|
||||
const sessionJsonPath = path.join(outputPath, 'session.json');
|
||||
@@ -136,20 +134,14 @@ export async function injectModelIntoReport(
|
||||
if (match) {
|
||||
// Inject model line after Assessment Date
|
||||
const modelLine = `- Model: ${modelStr}`;
|
||||
reportContent = reportContent.replace(
|
||||
assessmentDatePattern,
|
||||
`$1\n${modelLine}`
|
||||
);
|
||||
reportContent = reportContent.replace(assessmentDatePattern, `$1\n${modelLine}`);
|
||||
logger.info('Model info injected into Executive Summary');
|
||||
} else {
|
||||
// If no Assessment Date line found, try to add after Executive Summary header
|
||||
const execSummaryPattern = /^## Executive Summary$/m;
|
||||
if (reportContent.match(execSummaryPattern)) {
|
||||
// Add model as first item in Executive Summary
|
||||
reportContent = reportContent.replace(
|
||||
execSummaryPattern,
|
||||
`## Executive Summary\n- Model: ${modelStr}`
|
||||
);
|
||||
reportContent = reportContent.replace(execSummaryPattern, `## Executive Summary\n- Model: ${modelStr}`);
|
||||
logger.info('Model info added to Executive Summary header');
|
||||
} else {
|
||||
logger.warn('Could not find Executive Summary section');
|
||||
@@ -4,10 +4,10 @@
|
||||
// it under the terms of the GNU Affero General Public License version 3
|
||||
// as published by the Free Software Foundation.
|
||||
|
||||
import { path, fs } from 'zx';
|
||||
import { fs, path } from 'zx';
|
||||
import { validateQueueAndDeliverable } from './services/queue-validation.js';
|
||||
import type { AgentName, AgentDefinition, PlaywrightAgent, AgentValidator, VulnType } from './types/index.js';
|
||||
import type { ActivityLogger } from './types/activity-logger.js';
|
||||
import type { AgentDefinition, AgentName, AgentValidator, PlaywrightAgent, VulnType } from './types/index.js';
|
||||
|
||||
// Agent definitions according to PRD
|
||||
// NOTE: deliverableFilename values must match mcp-server/src/types/deliverables.ts:DELIVERABLE_FILENAMES
|
||||
@@ -20,7 +20,7 @@ export const AGENTS: Readonly<Record<AgentName, AgentDefinition>> = Object.freez
|
||||
deliverableFilename: 'code_analysis_deliverable.md',
|
||||
modelTier: 'large',
|
||||
},
|
||||
'recon': {
|
||||
recon: {
|
||||
name: 'recon',
|
||||
displayName: 'Recon agent',
|
||||
prerequisites: ['pre-recon'],
|
||||
@@ -97,7 +97,7 @@ export const AGENTS: Readonly<Record<AgentName, AgentDefinition>> = Object.freez
|
||||
promptTemplate: 'exploit-authz',
|
||||
deliverableFilename: 'authz_exploitation_evidence.md',
|
||||
},
|
||||
'report': {
|
||||
report: {
|
||||
name: 'report',
|
||||
displayName: 'Report agent',
|
||||
prerequisites: ['injection-exploit', 'xss-exploit', 'auth-exploit', 'ssrf-exploit', 'authz-exploit'],
|
||||
@@ -113,7 +113,7 @@ export type PhaseName = 'pre-recon' | 'recon' | 'vulnerability-analysis' | 'expl
|
||||
// Map agents to their corresponding phases (single source of truth)
|
||||
export const AGENT_PHASE_MAP: Readonly<Record<AgentName, PhaseName>> = Object.freeze({
|
||||
'pre-recon': 'pre-recon',
|
||||
'recon': 'recon',
|
||||
recon: 'recon',
|
||||
'injection-vuln': 'vulnerability-analysis',
|
||||
'xss-vuln': 'vulnerability-analysis',
|
||||
'auth-vuln': 'vulnerability-analysis',
|
||||
@@ -124,7 +124,7 @@ export const AGENT_PHASE_MAP: Readonly<Record<AgentName, PhaseName>> = Object.fr
|
||||
'auth-exploit': 'exploitation',
|
||||
'authz-exploit': 'exploitation',
|
||||
'ssrf-exploit': 'exploitation',
|
||||
'report': 'reporting',
|
||||
report: 'reporting',
|
||||
});
|
||||
|
||||
// Factory function for vulnerability queue validators
|
||||
@@ -210,11 +210,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, 'deliverables', 'comprehensive_security_assessment_report.md');
|
||||
|
||||
const reportExists = await fs.pathExists(reportFile);
|
||||
|
||||
@@ -15,29 +15,28 @@
|
||||
* Business logic is delegated to services in src/services/.
|
||||
*/
|
||||
|
||||
import { heartbeat, ApplicationFailure, Context } from '@temporalio/activity';
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
|
||||
import { classifyErrorForTemporal, PentestError } from '../services/error-handling.js';
|
||||
import { ErrorCode } from '../types/errors.js';
|
||||
import { getOrCreateContainer, getContainer, removeContainer } from '../services/container.js';
|
||||
import { ExploitationCheckerService } from '../services/exploitation-checker.js';
|
||||
import type { VulnType, ExploitationDecision } from '../services/queue-validation.js';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { ApplicationFailure, Context, heartbeat } from '@temporalio/activity';
|
||||
import { AuditSession } from '../audit/index.js';
|
||||
import type { WorkflowSummary } from '../audit/workflow-logger.js';
|
||||
import type { AgentName } from '../types/agents.js';
|
||||
import { ALL_AGENTS } from '../types/agents.js';
|
||||
import type { AgentMetrics, ResumeState } from './shared.js';
|
||||
import type { ResumeAttempt } from '../audit/metrics-tracker.js';
|
||||
import { copyDeliverablesToAudit, type SessionMetadata } from '../audit/utils.js';
|
||||
import { readJson, fileExists } from '../utils/file-io.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';
|
||||
import { ExploitationCheckerService } from '../services/exploitation-checker.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 { AGENTS } from '../session-manager.js';
|
||||
import { executeGitCommandWithRetry } from '../services/git-manager.js';
|
||||
import type { ResumeAttempt } from '../audit/metrics-tracker.js';
|
||||
import { createActivityLogger } from './activity-logger.js';
|
||||
import { runPreflightChecks } from '../services/preflight.js';
|
||||
import type { AgentName } from '../types/agents.js';
|
||||
import { ALL_AGENTS } from '../types/agents.js';
|
||||
import { ErrorCode } from '../types/errors.js';
|
||||
import { isErr } from '../types/result.js';
|
||||
import { fileExists, readJson } from '../utils/file-io.js';
|
||||
import { createActivityLogger } from './activity-logger.js';
|
||||
import type { AgentMetrics, ResumeState } from './shared.js';
|
||||
|
||||
// Max lengths to prevent Temporal protobuf buffer overflow
|
||||
const MAX_ERROR_MESSAGE_LENGTH = 2000;
|
||||
@@ -68,7 +67,7 @@ function truncateErrorMessage(message: string): string {
|
||||
if (message.length <= MAX_ERROR_MESSAGE_LENGTH) {
|
||||
return message;
|
||||
}
|
||||
return message.slice(0, MAX_ERROR_MESSAGE_LENGTH - 20) + '\n[truncated]';
|
||||
return `${message.slice(0, MAX_ERROR_MESSAGE_LENGTH - 20)}\n[truncated]`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,7 +75,7 @@ function truncateErrorMessage(message: string): string {
|
||||
*/
|
||||
function truncateStackTrace(failure: ApplicationFailure): void {
|
||||
if (failure.stack && failure.stack.length > MAX_STACK_TRACE_LENGTH) {
|
||||
failure.stack = failure.stack.slice(0, MAX_STACK_TRACE_LENGTH) + '\n[stack truncated]';
|
||||
failure.stack = `${failure.stack.slice(0, MAX_STACK_TRACE_LENGTH)}\n[stack truncated]`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,10 +101,7 @@ function buildSessionMetadata(input: ActivityInput): SessionMetadata {
|
||||
* 3. Service-based agent execution
|
||||
* 4. Error classification for Temporal retry
|
||||
*/
|
||||
async function runAgentActivity(
|
||||
agentName: AgentName,
|
||||
input: ActivityInput
|
||||
): Promise<AgentMetrics> {
|
||||
async function runAgentActivity(agentName: AgentName, input: ActivityInput): Promise<AgentMetrics> {
|
||||
const { repoPath, configPath, pipelineTestingMode = false, workflowId, webUrl } = input;
|
||||
const startTime = Date.now();
|
||||
const attemptNumber = Context.current().info.attempt;
|
||||
@@ -140,7 +136,7 @@ async function runAgentActivity(
|
||||
attemptNumber,
|
||||
},
|
||||
auditSession,
|
||||
logger
|
||||
logger,
|
||||
);
|
||||
|
||||
// 4. Return metrics
|
||||
@@ -167,7 +163,7 @@ async function runAgentActivity(
|
||||
throw ApplicationFailure.nonRetryable(
|
||||
`Agent ${agentName} failed output validation after ${attemptNumber} attempts`,
|
||||
'OutputValidationError',
|
||||
[{ agentName, attemptNumber, elapsed: Date.now() - startTime }]
|
||||
[{ agentName, attemptNumber, elapsed: Date.now() - startTime }],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -335,9 +331,7 @@ export async function assembleReportActivity(input: ActivityInput): Promise<void
|
||||
export async function injectReportMetadataActivity(input: ActivityInput): Promise<void> {
|
||||
const { repoPath, sessionId, outputPath } = input;
|
||||
const logger = createActivityLogger();
|
||||
const effectiveOutputPath = outputPath
|
||||
? path.join(outputPath, sessionId)
|
||||
: path.join('./workspaces', sessionId);
|
||||
const effectiveOutputPath = outputPath ? path.join(outputPath, sessionId) : path.join('./workspaces', sessionId);
|
||||
try {
|
||||
await injectModelIntoReport(repoPath, effectiveOutputPath, logger);
|
||||
} catch (error) {
|
||||
@@ -352,10 +346,7 @@ export async function injectReportMetadataActivity(input: ActivityInput): Promis
|
||||
* Uses existing container if available (from prior agent runs),
|
||||
* otherwise creates service directly (stateless, no dependencies).
|
||||
*/
|
||||
export async function checkExploitationQueue(
|
||||
input: ActivityInput,
|
||||
vulnType: VulnType
|
||||
): Promise<ExploitationDecision> {
|
||||
export async function checkExploitationQueue(input: ActivityInput, vulnType: VulnType): Promise<ExploitationDecision> {
|
||||
const { repoPath, workflowId } = input;
|
||||
const logger = createActivityLogger();
|
||||
|
||||
@@ -391,7 +382,7 @@ interface SessionJson {
|
||||
export async function loadResumeState(
|
||||
workspaceName: string,
|
||||
expectedUrl: string,
|
||||
expectedRepoPath: string
|
||||
expectedRepoPath: string,
|
||||
): Promise<ResumeState> {
|
||||
// 1. Validate workspace exists
|
||||
const sessionPath = path.join('./workspaces', workspaceName, 'session.json');
|
||||
@@ -400,7 +391,7 @@ export async function loadResumeState(
|
||||
if (!exists) {
|
||||
throw ApplicationFailure.nonRetryable(
|
||||
`Workspace not found: ${workspaceName}\nExpected path: ${sessionPath}`,
|
||||
'WorkspaceNotFoundError'
|
||||
'WorkspaceNotFoundError',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -412,14 +403,14 @@ export async function loadResumeState(
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
throw ApplicationFailure.nonRetryable(
|
||||
`Corrupted session.json in workspace ${workspaceName}: ${errorMsg}`,
|
||||
'CorruptedSessionError'
|
||||
'CorruptedSessionError',
|
||||
);
|
||||
}
|
||||
|
||||
if (session.session.webUrl !== expectedUrl) {
|
||||
throw ApplicationFailure.nonRetryable(
|
||||
`URL mismatch with workspace\n Workspace URL: ${session.session.webUrl}\n Provided URL: ${expectedUrl}`,
|
||||
'URLMismatchError'
|
||||
'URLMismatchError',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -463,7 +454,7 @@ export async function loadResumeState(
|
||||
`but their deliverable files are missing from disk. ` +
|
||||
`Start a fresh run instead.`
|
||||
: `No agents completed successfully. Start a fresh run instead.`),
|
||||
'NoCheckpointsError'
|
||||
'NoCheckpointsError',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -497,7 +488,7 @@ async function findLatestCommit(repoPath: string, commitHashes: string[]): Promi
|
||||
'filesystem',
|
||||
false, // Non-retryable - corrupt workspace state
|
||||
{ phase: 'resume' },
|
||||
ErrorCode.GIT_CHECKPOINT_FAILED
|
||||
ErrorCode.GIT_CHECKPOINT_FAILED,
|
||||
);
|
||||
}
|
||||
return hash;
|
||||
@@ -506,7 +497,7 @@ async function findLatestCommit(repoPath: string, commitHashes: string[]): Promi
|
||||
const result = await executeGitCommandWithRetry(
|
||||
['git', 'rev-list', '--max-count=1', ...commitHashes],
|
||||
repoPath,
|
||||
'find latest commit'
|
||||
'find latest commit',
|
||||
);
|
||||
|
||||
return result.stdout.trim();
|
||||
@@ -518,7 +509,7 @@ async function findLatestCommit(repoPath: string, commitHashes: string[]): Promi
|
||||
export async function restoreGitCheckpoint(
|
||||
repoPath: string,
|
||||
checkpointHash: string,
|
||||
incompleteAgents: AgentName[]
|
||||
incompleteAgents: AgentName[],
|
||||
): Promise<void> {
|
||||
const logger = createActivityLogger();
|
||||
logger.info(`Restoring git workspace to ${checkpointHash}...`);
|
||||
@@ -526,13 +517,9 @@ export async function restoreGitCheckpoint(
|
||||
await executeGitCommandWithRetry(
|
||||
['git', 'reset', '--hard', checkpointHash],
|
||||
repoPath,
|
||||
'reset to checkpoint for resume'
|
||||
);
|
||||
await executeGitCommandWithRetry(
|
||||
['git', 'clean', '-fd'],
|
||||
repoPath,
|
||||
'clean untracked files for resume'
|
||||
'reset to checkpoint for resume',
|
||||
);
|
||||
await executeGitCommandWithRetry(['git', 'clean', '-fd'], repoPath, 'clean untracked files for resume');
|
||||
|
||||
for (const agentName of incompleteAgents) {
|
||||
const deliverableFilename = AGENTS[agentName].deliverableFilename;
|
||||
@@ -559,7 +546,7 @@ export async function recordResumeAttempt(
|
||||
terminatedWorkflows: string[],
|
||||
checkpointHash: string,
|
||||
previousWorkflowId: string,
|
||||
completedAgents: string[]
|
||||
completedAgents: string[],
|
||||
): Promise<void> {
|
||||
const sessionMetadata = buildSessionMetadata(input);
|
||||
const auditSession = new AuditSession(sessionMetadata);
|
||||
@@ -583,7 +570,7 @@ export async function recordResumeAttempt(
|
||||
export async function logPhaseTransition(
|
||||
input: ActivityInput,
|
||||
phase: string,
|
||||
event: 'start' | 'complete'
|
||||
event: 'start' | 'complete',
|
||||
): Promise<void> {
|
||||
const sessionMetadata = buildSessionMetadata(input);
|
||||
const auditSession = new AuditSession(sessionMetadata);
|
||||
@@ -600,10 +587,7 @@ export async function logPhaseTransition(
|
||||
* Log workflow completion with full summary.
|
||||
* Cleans up container when done.
|
||||
*/
|
||||
export async function logWorkflowComplete(
|
||||
input: ActivityInput,
|
||||
summary: WorkflowSummary
|
||||
): Promise<void> {
|
||||
export async function logWorkflowComplete(input: ActivityInput, summary: WorkflowSummary): Promise<void> {
|
||||
const { repoPath, workflowId } = input;
|
||||
const sessionMetadata = buildSessionMetadata(input);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { defineQuery } from '@temporalio/workflow';
|
||||
|
||||
export type { AgentMetrics } from '../types/metrics.js';
|
||||
import type { AgentMetrics } from '../types/metrics.js';
|
||||
|
||||
import type { PipelineConfig } from '../types/config.js';
|
||||
import type { AgentMetrics } from '../types/metrics.js';
|
||||
|
||||
export interface PipelineInput {
|
||||
webUrl: string;
|
||||
@@ -9,8 +9,8 @@
|
||||
* Pure function with no side effects.
|
||||
*/
|
||||
|
||||
import type { PipelineState } from './shared.js';
|
||||
import type { WorkflowSummary } from '../audit/workflow-logger.js';
|
||||
import type { PipelineState } from './shared.js';
|
||||
|
||||
/**
|
||||
* Maps PipelineState to WorkflowSummary.
|
||||
@@ -19,10 +19,7 @@ import type { WorkflowSummary } from '../audit/workflow-logger.js';
|
||||
* safely imported into Temporal workflows. The caller must ensure
|
||||
* state.summary is set before calling (via computeSummary).
|
||||
*/
|
||||
export function toWorkflowSummary(
|
||||
state: PipelineState,
|
||||
status: 'completed' | 'failed'
|
||||
): WorkflowSummary {
|
||||
export function toWorkflowSummary(state: PipelineState, status: 'completed' | 'failed'): WorkflowSummary {
|
||||
// state.summary must be computed before calling this mapper
|
||||
const summary = state.summary;
|
||||
if (!summary) {
|
||||
@@ -35,10 +32,7 @@ export function toWorkflowSummary(
|
||||
totalCostUsd: summary.totalCostUsd,
|
||||
completedAgents: state.completedAgents,
|
||||
agentMetrics: Object.fromEntries(
|
||||
Object.entries(state.agentMetrics).map(([name, m]) => [
|
||||
name,
|
||||
{ durationMs: m.durationMs, costUsd: m.costUsd },
|
||||
])
|
||||
Object.entries(state.agentMetrics).map(([name, m]) => [name, { durationMs: m.durationMs, costUsd: m.costUsd }]),
|
||||
),
|
||||
...(state.error && { error: state.error }),
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// Copyright (C) 2025 Keygraph, Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
@@ -26,18 +27,18 @@
|
||||
* TEMPORAL_ADDRESS - Temporal server address (default: localhost:7233)
|
||||
*/
|
||||
|
||||
import { NativeConnection, Worker, bundleWorkflowCode } from '@temporalio/worker';
|
||||
import { Connection, Client, WorkflowNotFoundError, type WorkflowHandle } from '@temporalio/client';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { Client, Connection, type WorkflowHandle, WorkflowNotFoundError } from '@temporalio/client';
|
||||
import { bundleWorkflowCode, NativeConnection, Worker } from '@temporalio/worker';
|
||||
import dotenv from 'dotenv';
|
||||
import * as activities from './activities.js';
|
||||
import { sanitizeHostname } from '../audit/utils.js';
|
||||
import { readJson, fileExists } from '../utils/file-io.js';
|
||||
import { parseConfig } from '../config-parser.js';
|
||||
import type { PipelineConfig } from '../types/config.js';
|
||||
import type { PipelineInput, PipelineState, PipelineProgress } from './shared.js';
|
||||
import { fileExists, readJson } from '../utils/file-io.js';
|
||||
import * as activities from './activities.js';
|
||||
import type { PipelineInput, PipelineProgress, PipelineState } from './shared.js';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
@@ -61,9 +62,7 @@ function showUsage(): void {
|
||||
console.log('\nShannon Worker');
|
||||
console.log('Combined worker + client for pentest pipeline\n');
|
||||
console.log('Usage:');
|
||||
console.log(
|
||||
' node dist/temporal/worker.js <webUrl> <repoPath> --task-queue <name> [options]\n'
|
||||
);
|
||||
console.log(' node dist/temporal/worker.js <webUrl> <repoPath> --task-queue <name> [options]\n');
|
||||
console.log('Options:');
|
||||
console.log(' --task-queue <name> Task queue name (required)');
|
||||
console.log(' --config <path> Configuration file path');
|
||||
@@ -135,7 +134,10 @@ function parseCliArgs(argv: string[]): CliArgs {
|
||||
}
|
||||
|
||||
return {
|
||||
webUrl, repoPath, taskQueue, pipelineTestingMode,
|
||||
webUrl,
|
||||
repoPath,
|
||||
taskQueue,
|
||||
pipelineTestingMode,
|
||||
...(configPath && { configPath }),
|
||||
...(outputPath && { outputPath }),
|
||||
...(resumeFromWorkspace && { resumeFromWorkspace }),
|
||||
@@ -167,17 +169,11 @@ interface WorkspaceResolution {
|
||||
terminatedWorkflows: string[];
|
||||
}
|
||||
|
||||
async function terminateExistingWorkflows(
|
||||
client: Client,
|
||||
workspaceName: string
|
||||
): Promise<string[]> {
|
||||
async function terminateExistingWorkflows(client: Client, workspaceName: string): Promise<string[]> {
|
||||
const sessionPath = path.join('./workspaces', workspaceName, 'session.json');
|
||||
|
||||
if (!(await fileExists(sessionPath))) {
|
||||
throw new Error(
|
||||
`Workspace not found: ${workspaceName}\n` +
|
||||
`Expected path: ${sessionPath}`
|
||||
);
|
||||
throw new Error(`Workspace not found: ${workspaceName}\n` + `Expected path: ${sessionPath}`);
|
||||
}
|
||||
|
||||
const session = await readJson<SessionJson>(sessionPath);
|
||||
@@ -214,10 +210,7 @@ async function terminateExistingWorkflows(
|
||||
return terminated;
|
||||
}
|
||||
|
||||
async function resolveWorkspace(
|
||||
client: Client,
|
||||
args: CliArgs
|
||||
): Promise<WorkspaceResolution> {
|
||||
async function resolveWorkspace(client: Client, args: CliArgs): Promise<WorkspaceResolution> {
|
||||
if (!args.resumeFromWorkspace) {
|
||||
const hostname = sanitizeHostname(args.webUrl);
|
||||
const workflowId = `${hostname}_shannon-${Date.now()}`;
|
||||
@@ -269,9 +262,7 @@ async function resolveWorkspace(
|
||||
|
||||
// If the workspace name already looks like a CLI-generated ID
|
||||
// (ends with _shannon-<digits>), use it directly to avoid double _shannon- suffixes
|
||||
const workflowId = /_shannon-\d+$/.test(workspace)
|
||||
? workspace
|
||||
: `${workspace}_shannon-${Date.now()}`;
|
||||
const workflowId = /_shannon-\d+$/.test(workspace) ? workspace : `${workspace}_shannon-${Date.now()}`;
|
||||
|
||||
return {
|
||||
workflowId,
|
||||
@@ -304,7 +295,9 @@ async function loadPipelineConfig(configPath: string | undefined): Promise<Pipel
|
||||
}
|
||||
|
||||
function buildPipelineInput(
|
||||
args: CliArgs, workspace: WorkspaceResolution, pipelineConfig: PipelineConfig
|
||||
args: CliArgs,
|
||||
workspace: WorkspaceResolution,
|
||||
pipelineConfig: PipelineConfig,
|
||||
): PipelineInput {
|
||||
return {
|
||||
webUrl: args.webUrl,
|
||||
@@ -323,14 +316,14 @@ function buildPipelineInput(
|
||||
|
||||
async function waitForWorkflowResult(
|
||||
handle: WorkflowHandle<(input: PipelineInput) => Promise<PipelineState>>,
|
||||
workspace: WorkspaceResolution
|
||||
workspace: WorkspaceResolution,
|
||||
): Promise<void> {
|
||||
const progressInterval = setInterval(async () => {
|
||||
try {
|
||||
const progress = await handle.query<PipelineProgress>(PROGRESS_QUERY);
|
||||
const elapsed = Math.floor(progress.elapsedMs / 1000);
|
||||
console.log(
|
||||
`[${elapsed}s] Phase: ${progress.currentPhase || 'unknown'} | Agent: ${progress.currentAgent || 'none'} | Completed: ${progress.completedAgents.length}/13`
|
||||
`[${elapsed}s] Phase: ${progress.currentPhase || 'unknown'} | Agent: ${progress.currentAgent || 'none'} | Completed: ${progress.completedAgents.length}/13`,
|
||||
);
|
||||
} catch {
|
||||
// Workflow may have completed
|
||||
@@ -350,9 +343,7 @@ async function waitForWorkflowResult(
|
||||
|
||||
if (workspace.isResume) {
|
||||
try {
|
||||
const session = await readJson<SessionJson>(
|
||||
path.join('./workspaces', workspace.sessionId, 'session.json')
|
||||
);
|
||||
const session = await readJson<SessionJson>(path.join('./workspaces', workspace.sessionId, 'session.json'));
|
||||
console.log(`Cumulative cost: $${session.metrics.total_cost_usd.toFixed(4)}`);
|
||||
} catch {
|
||||
// Non-fatal
|
||||
@@ -437,7 +428,7 @@ async function run(): Promise<void> {
|
||||
taskQueue: args.taskQueue,
|
||||
workflowId: workspace.workflowId,
|
||||
args: [input],
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// 7. Wait for workflow result
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user