mirror of
https://github.com/KeygraphHQ/shannon.git
synced 2026-10-03 06:46:49 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dfb7c69d3b | ||
|
|
d41ae9c20d | ||
|
|
1ae0a142f8 | ||
|
|
d4cc2ab974 | ||
|
|
760a140228 | ||
|
|
a1675f8390 | ||
|
|
86effd5240 | ||
|
|
d26f3b668e | ||
|
|
af8cd12b5f | ||
|
|
b2668afc2a | ||
|
|
40660febfa | ||
|
|
5ca456e4e2 | ||
|
|
1ce250d6a5 | ||
|
|
30a12114ae | ||
|
|
c7ff91db7f | ||
|
|
878abf0100 | ||
|
|
ab1d2fb72b | ||
|
|
09c2553245 | ||
|
|
5ff40f8c6f | ||
|
|
00e56455df | ||
|
|
5a2f78c5d9 | ||
|
|
7abcc1d3e1 | ||
|
|
4be4853fd3 | ||
|
|
cb6cbf101d | ||
|
|
63ca5604a1 | ||
|
|
8fb62a59d6 | ||
|
|
c259a34ed9 | ||
|
|
10b26355be | ||
|
|
82b5278541 | ||
|
|
8b956c9972 | ||
|
|
3d1a3c75f8 | ||
|
|
ac6db3b52e | ||
|
|
0a1a2eb1c1 | ||
|
|
a6f004cd25 | ||
|
|
4a12918448 | ||
|
|
35f59f30f6 | ||
|
|
7813baf16a | ||
|
|
8f5d639f0d | ||
|
|
32c01a39b1 | ||
|
|
72c424f687 | ||
|
|
1af42339b9 | ||
|
|
ca86c839cc | ||
|
|
0a57b062fd | ||
|
|
46be49c175 | ||
|
|
95998d1a44 | ||
|
|
6c8135d031 | ||
|
|
03a3d764af | ||
|
|
79caada539 |
No files matched your search
@@ -135,7 +135,6 @@ shannon <URL> <REPO> --pipeline-testing
|
||||
|-------------------|---------|------------|
|
||||
| `config` | Configuration file issues | No |
|
||||
| `network` | Connection/timeout issues | Yes |
|
||||
| `tool` | External tool (nmap, etc.) failed | Yes |
|
||||
| `prompt` | Claude SDK/API issues | Sometimes |
|
||||
| `filesystem` | File read/write errors | Sometimes |
|
||||
| `validation` | Deliverable validation failed | Yes (via retry) |
|
||||
|
||||
+46
-51
@@ -1,60 +1,55 @@
|
||||
# Shannon Environment Configuration
|
||||
# Copy this file to .env and fill in your credentials
|
||||
# Copy to .env and uncomment one provider block.
|
||||
# SHANNON_AI_MODEL is <provider>:<model-id>, split on the first colon.
|
||||
# Defaults to anthropic:claude-sonnet-4-6.
|
||||
|
||||
# Recommended output token configuration for larger tool outputs
|
||||
CLAUDE_CODE_MAX_OUTPUT_TOKENS=64000
|
||||
|
||||
# =============================================================================
|
||||
# OPTION 1: Direct Anthropic
|
||||
# =============================================================================
|
||||
ANTHROPIC_API_KEY=your-api-key-here
|
||||
|
||||
# OR use OAuth token instead
|
||||
# --- Anthropic ---------------------------------------------------------------
|
||||
SHANNON_AI_API_KEY=your-api-key-here
|
||||
SHANNON_AI_MODEL=anthropic:claude-sonnet-4-6
|
||||
# CLAUDE_CODE_OAUTH_TOKEN=your-oauth-token-here
|
||||
|
||||
# =============================================================================
|
||||
# OPTION 2: Custom Base URL (compatible proxies, gateways, etc.)
|
||||
# =============================================================================
|
||||
# Point the SDK at an alternative Anthropic-compatible endpoint.
|
||||
# ANTHROPIC_BASE_URL=https://your-proxy.example.com
|
||||
# ANTHROPIC_AUTH_TOKEN=your-auth-token # Auth token for the custom endpoint
|
||||
# --- OpenAI ------------------------------------------------------------------
|
||||
# SHANNON_AI_API_KEY=your-api-key-here
|
||||
# SHANNON_AI_MODEL=openai:gpt-5.5
|
||||
|
||||
# =============================================================================
|
||||
# Model Tier Overrides (Anthropic API / OAuth / Custom Base URL / Bedrock)
|
||||
# =============================================================================
|
||||
# Override which model is used for each tier. Defaults are used if not set.
|
||||
# Optional for direct Anthropic and custom base URL modes. Required for Bedrock/Vertex.
|
||||
# ANTHROPIC_SMALL_MODEL=... # Small tier (default: claude-haiku-4-5-20251001)
|
||||
# ANTHROPIC_MEDIUM_MODEL=... # Medium tier (default: claude-sonnet-4-6)
|
||||
# ANTHROPIC_LARGE_MODEL=... # Large tier (default: claude-opus-4-6)
|
||||
# --- xAI ---------------------------------------------------------------------
|
||||
# SHANNON_AI_API_KEY=your-api-key-here
|
||||
# SHANNON_AI_MODEL=xai:grok-4.5
|
||||
|
||||
# =============================================================================
|
||||
# OPTION 3: AWS Bedrock
|
||||
# =============================================================================
|
||||
# https://aws.amazon.com/blogs/machine-learning/accelerate-ai-development-with-amazon-bedrock-api-keys/
|
||||
# Requires the model tier overrides above to be set with Bedrock-specific model IDs.
|
||||
# Example Bedrock model IDs for us-east-1:
|
||||
# ANTHROPIC_SMALL_MODEL=us.anthropic.claude-haiku-4-5-20251001-v1:0
|
||||
# ANTHROPIC_MEDIUM_MODEL=us.anthropic.claude-sonnet-4-6
|
||||
# ANTHROPIC_LARGE_MODEL=us.anthropic.claude-opus-4-6
|
||||
|
||||
# CLAUDE_CODE_USE_BEDROCK=1
|
||||
# --- AWS Bedrock -------------------------------------------------------------
|
||||
# Bearer token only; model must be enabled in your region.
|
||||
# AWS_REGION=us-east-1
|
||||
# AWS_BEARER_TOKEN_BEDROCK=your-bearer-token
|
||||
# SHANNON_AI_MODEL=amazon-bedrock:us.anthropic.claude-opus-4-8
|
||||
|
||||
# =============================================================================
|
||||
# OPTION 4: Google Vertex AI
|
||||
# =============================================================================
|
||||
# https://cloud.google.com/vertex-ai/generative-ai/docs/partner-models/use-partner-models
|
||||
# Requires a GCP service account with roles/aiplatform.user.
|
||||
# Download the SA key JSON from GCP Console (IAM > Service Accounts > Keys).
|
||||
# Requires the model tier overrides above to be set with Vertex AI model IDs.
|
||||
# Example Vertex AI model IDs:
|
||||
# ANTHROPIC_SMALL_MODEL=claude-haiku-4-5@20251001
|
||||
# ANTHROPIC_MEDIUM_MODEL=claude-sonnet-4-6
|
||||
# ANTHROPIC_LARGE_MODEL=claude-opus-4-6
|
||||
# --- Custom Base URL ---------------------------------------------------------
|
||||
# Route through a proxy or gateway (LiteLLM, an internal endpoint).
|
||||
# Pick the block matching the API dialect your gateway speaks, and uncomment all
|
||||
# three lines. The provider prefix picks the dialect; the model id is whatever
|
||||
# name your gateway serves it under.
|
||||
|
||||
# CLAUDE_CODE_USE_VERTEX=1
|
||||
# CLOUD_ML_REGION=us-east5
|
||||
# ANTHROPIC_VERTEX_PROJECT_ID=your-gcp-project-id
|
||||
# GOOGLE_APPLICATION_CREDENTIALS=./credentials/google-sa-key.json
|
||||
# Anthropic compatible - Anthropic Messages:
|
||||
# SHANNON_AI_API_KEY=your-gateway-key-here
|
||||
# SHANNON_AI_BASE_URL=https://llm-gateway.example.com
|
||||
# SHANNON_AI_MODEL=anthropic:claude-sonnet-4-6
|
||||
|
||||
# OpenAI compatible - Chat Completions (default) or Responses:
|
||||
# SHANNON_AI_API_KEY=your-gateway-key-here
|
||||
# SHANNON_AI_BASE_URL=https://llm-gateway.example.com/v1
|
||||
# SHANNON_AI_MODEL=openai:gpt-5.5
|
||||
# SHANNON_AI_OPENAI_FORMAT=responses
|
||||
|
||||
# --- Other provider ----------------------------------------------------------
|
||||
# Any other provider the Pi harness supports. Name it in SHANNON_AI_MODEL and
|
||||
# supply the key via the generic SHANNON_AI_API_KEY. Pi validates the provider
|
||||
# and model at preflight.
|
||||
# SHANNON_AI_MODEL=openrouter:moonshotai/kimi-k3
|
||||
# SHANNON_AI_API_KEY=your-api-key-here
|
||||
|
||||
# --- Misc --------------------------------------------------------------------
|
||||
# Forward /etc/hosts entries into the worker container.
|
||||
# SHANNON_FORWARD_HOSTS=false
|
||||
|
||||
# See the guide below to use an OpenAI subscription
|
||||
# https://github.com/KeygraphHQ/shannon/blob/main/docs/ai-providers.md#openai-codex-chatgpt-pluspro-subscription
|
||||
# SHANNON_USE_PI_AUTH=1
|
||||
# SHANNON_AI_MODEL=openai-codex:gpt-5.5
|
||||
@@ -69,9 +69,9 @@ body:
|
||||
|
||||
Issues without this information may be difficult to triage.
|
||||
|
||||
- Check the workflow log:
|
||||
- **npx mode:** `~/.shannon/workspaces/<workspace>/workflow.log`
|
||||
- **Local mode:** `./workspaces/<workspace>/workflow.log`
|
||||
- Check the scan log:
|
||||
- **npx mode:** `~/.shannon/workspaces/<workspace>/.shannon/workflow.log`
|
||||
- **Local mode:** `./workspaces/<workspace>/.shannon/workflow.log`
|
||||
Use `grep` or search to identify errors.
|
||||
Paste the relevant error output below.
|
||||
- Temporal:
|
||||
@@ -117,9 +117,12 @@ body:
|
||||
options:
|
||||
- "Anthropic (API key)"
|
||||
- "Anthropic (OAuth token)"
|
||||
- "Custom base URL (proxy/gateway)"
|
||||
- "OpenAI"
|
||||
- "xAI"
|
||||
- "AWS Bedrock"
|
||||
- "Google Vertex AI"
|
||||
- "Custom base URL - Anthropic Messages"
|
||||
- "Custom base URL - OpenAI Chat Completions"
|
||||
- "Custom base URL - OpenAI Responses"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
||||
@@ -30,15 +30,17 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
BASE="2.0.0"
|
||||
LATEST=$(npm view "@keygraph/shannon" dist-tags.beta 2>/dev/null || echo "")
|
||||
|
||||
if [[ -z "$LATEST" ]]; then
|
||||
echo "version=1.0.0-beta.1" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
# Extract N from 1.0.0-beta.N and increment
|
||||
if [[ "$LATEST" == "$BASE-beta."* ]]; then
|
||||
# Same base version — increment the beta counter (e.g. 2.0.0-beta.2 -> 2.0.0-beta.3)
|
||||
N=$(echo "$LATEST" | grep -oE 'beta\.([0-9]+)' | grep -oE '[0-9]+')
|
||||
NEXT=$((N + 1))
|
||||
echo "version=1.0.0-beta.$NEXT" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$BASE-beta.$NEXT" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
# No prior beta, or a different base (e.g. last beta was 1.0.0-beta.N) — start over.
|
||||
echo "version=$BASE-beta.1" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Print version
|
||||
|
||||
@@ -4,7 +4,7 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Beta version to roll back to (example: 1.0.0-beta.2)"
|
||||
description: "Beta version to roll back to (example: 2.0.0-beta.2)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
VERSION="${RAW_VERSION#v}"
|
||||
|
||||
if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+-beta\.[0-9]+$ ]]; then
|
||||
echo "Version must be in format X.Y.Z-beta.N (e.g. 1.0.0-beta.2)"
|
||||
echo "Version must be in format X.Y.Z-beta.N (e.g. 2.0.0-beta.2)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ AI-powered penetration testing agent for defensive security analysis. Automates
|
||||
|
||||
## Commands
|
||||
|
||||
**Prerequisites:** Docker, AI provider credentials (`.env` for local, `shn setup` or env vars for npx)
|
||||
**Prerequisites:** Docker, AI provider credentials (`.env` for local, `npx @keygraph/shannon setup` or env vars for npx)
|
||||
|
||||
### Dual CLI
|
||||
|
||||
@@ -15,8 +15,8 @@ Shannon supports two CLI modes, auto-detected based on the current working direc
|
||||
| **Install** | Zero-install via npm | Clone the repo |
|
||||
| **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 |
|
||||
| **Credentials** | `~/.shannon/config.toml` (via `npx @keygraph/shannon setup`) or env vars | `./.env` |
|
||||
| **Config** | `~/.shannon/config.toml` (via `npx @keygraph/shannon setup`) | N/A |
|
||||
| **Prompts** | Bundled in Docker image | Mounted from `./apps/worker/prompts/` (live-editable) |
|
||||
|
||||
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.
|
||||
@@ -44,8 +44,8 @@ echo "ANTHROPIC_API_KEY=your-key" > .env
|
||||
./shannon build
|
||||
|
||||
# Run
|
||||
./shannon start -u <url> -r my-repo
|
||||
./shannon start -u <url> -r my-repo -c ./apps/worker/configs/my-config.yaml
|
||||
./shannon start -u <url> -r ./my-repo
|
||||
./shannon start -u <url> -r ./my-repo -c ./apps/worker/configs/my-config.yaml
|
||||
./shannon start -u <url> -r /any/path/to/repo
|
||||
```
|
||||
|
||||
@@ -56,22 +56,24 @@ echo "ANTHROPIC_API_KEY=your-key" > .env
|
||||
npx @keygraph/shannon setup
|
||||
|
||||
# Workspaces & Resume
|
||||
./shannon start -u <url> -r my-repo -w my-audit # New named workspace
|
||||
./shannon start -u <url> -r my-repo -w my-audit # Resume (same command)
|
||||
./shannon workspaces # List all workspaces
|
||||
./shannon start -u <url> -r ./my-repo -w my-audit # New named workspace
|
||||
./shannon start -u <url> -r ./my-repo -w my-audit # Resume (same command)
|
||||
|
||||
# Monitor
|
||||
./shannon logs <workspace> # Tail workflow log
|
||||
./shannon status # Show running workers
|
||||
# Temporal Web UI: http://localhost:8233
|
||||
./shannon logs <workspace> # Show a scan's live log
|
||||
./shannon status <workspace> # Live phase/agent progress of one scan, read from Temporal (redraws, then exits)
|
||||
# Dashboard: http://localhost:8233
|
||||
|
||||
# Stop
|
||||
./shannon stop # Preserves workflow data
|
||||
./shannon stop --clean # Full cleanup including volumes (confirms first)
|
||||
./shannon stop <workspace> # Stop one scan (confirms first; --yes/-y to skip)
|
||||
./shannon stop --all # Stop all running scans (Temporal stays up; confirms first)
|
||||
./shannon reset # Stop everything and wipe all Temporal data + volumes (type 'confirm' to proceed; cannot be skipped)
|
||||
|
||||
# Version
|
||||
./shannon version # npx: package version; local: git SHA
|
||||
|
||||
# Image management
|
||||
./shannon build [--no-cache] # Local mode: build worker image
|
||||
npx @keygraph/shannon uninstall # npx mode: remove ~/.shannon/ (confirms first)
|
||||
|
||||
# Build TypeScript (development)
|
||||
pnpm run build # Build all packages via Turborepo
|
||||
@@ -82,7 +84,7 @@ pnpm biome:fix # Auto-fix lint, format, and import sorting
|
||||
|
||||
**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), `--debug` (preserve worker container after exit for log inspection)
|
||||
**Options:** `-c <file>` (YAML config), `-o <path>` (output directory), `-w <name>` (named workspace; auto-resumes if exists), `--pipeline-testing` (minimal prompts, 10s retries), `--keep-container` (preserve worker container after exit for log inspection), `--yes`/`-y` (skip the confirmation prompt on `stop`; required for non-interactive use; `reset` requires a typed `confirm` and cannot be skipped)
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -94,17 +96,22 @@ apps/worker/ — @shannon/worker (private, Temporal worker + pipeline logic)
|
||||
```
|
||||
|
||||
### 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.
|
||||
Published as `@keygraph/shannon` on npm. Contains Docker orchestration logic plus a read-only `@temporalio/client` reader (for `status`); no worker/pipeline business logic or prompts. Bundled with tsdown for single-file ESM output (deps stay external).
|
||||
|
||||
- `apps/cli/src/index.ts` — CLI dispatcher (`setup`, `start`, `stop`, `logs`, `workspaces`, `status`, `build`, `uninstall`, `info`)
|
||||
- `apps/cli/src/index.ts` — CLI dispatcher (`setup`, `start`, `stop`, `reset`, `logs`, `status`, `build`, `version`)
|
||||
- `apps/cli/src/temporal-client.ts` — `@temporalio/client` reader for `status`: connects to the frontend on `127.0.0.1:7233` (published by compose), `describeScan` (status + `pendingActivities` → running agents), `queryProgress` (live `getProgress` query → `PipelineState`), `getTerminalOutcome` (workflow `result()`). No worker of its own; scans are visible only within Temporal's ~24h retention (namespace default, unset in compose)
|
||||
- `apps/cli/src/scan/` — `status` rendering: `pipeline.ts` (static phase/agent plan + `run*Agent` activity-type→agent map + mirrored `PipelineState`/`AgentMetrics` types; keep in sync with the worker), `render.ts` (one renderer for both the live query state and the terminal result)
|
||||
- `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/env.ts` — `.env` loading, TOML fallback (npx only) via `apps/cli/src/config/resolver.ts`, credential validation, provider-scoped env flag building
|
||||
- `apps/cli/src/model-spec.ts` — `SHANNON_AI_MODEL` (`<provider>:<model-id>`) parsing; mirrors `apps/worker/src/ai/models.ts`
|
||||
- `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/paths.ts` — Repo/config path resolution (any absolute or relative path)
|
||||
- `apps/cli/src/version.ts` — Version reporting (npx: `package.json` version; local: `git-<sha>`)
|
||||
- `apps/cli/src/tty.ts` — Terminal capability detection: `requireInteractive` guard (fails fast off-TTY instead of hanging on a prompt), `supportsColor` color gating (`NO_COLOR`/`FORCE_COLOR`), and `stdoutIsTerminal` for spinner/cursor output
|
||||
- `apps/cli/src/commands/` — Command handlers
|
||||
- `apps/cli/infra/compose.yml` — Bundled Temporal compose file for npx mode
|
||||
- `apps/cli/tsdown.config.ts` — tsdown bundler config
|
||||
@@ -116,12 +123,13 @@ Infra (Temporal) runs via `docker-compose.yml`. Workers are ephemeral `docker ru
|
||||
- `docker-compose.yml` — Infra only: `shannon-temporal` (port 7233/8233). Network: `shannon-net`
|
||||
- `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
|
||||
- `/etc/hosts` forwarding — at worker spawn, `forwardEtcHostsFlags` in `apps/cli/src/docker.ts` reads the host's `/etc/hosts` and emits one `--add-host` flag per valid user-added entry. Loopback IPs (`127.x`, `::1`) are rewritten to `host-gateway`; IPv6 addresses are bracketed. Disable per-scan via `SHANNON_FORWARD_HOSTS=false`. No-op on Windows native (WSL2 reads its own `/etc/hosts` via the Linux path).
|
||||
|
||||
### 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/ai/pi/pi-executor.ts` — pi harness integration (agent-level retry disabled so Temporal owns restarts; provider-level retry on, see `apps/worker/src/ai/pi/retry-settings.ts`)
|
||||
- `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)
|
||||
@@ -137,19 +145,20 @@ Durable workflow orchestration with crash recovery, queryable progress, intellig
|
||||
- `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
|
||||
1. **Pre-Recon** (`pre-recon`) — Source code analysis to build the architectural baseline
|
||||
2. **Recon** (`recon`) — Attack surface mapping from initial findings
|
||||
3. **Vulnerability Analysis** (5 parallel agents) — injection, xss, auth, authz, ssrf
|
||||
4. **Exploitation** (5 parallel agents, conditional) — Exploits confirmed vulnerabilities
|
||||
5. **Reporting** (`report`) — Executive-level security report
|
||||
|
||||
### Supporting Systems
|
||||
- **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. Browser automation via `playwright-cli` with session isolation (`-s=<session>`). TOTP generation via `generate-totp` CLI 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` CLI script (`apps/worker/src/scripts/save-deliverable.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`
|
||||
- **Configuration** — YAML configs in `apps/worker/configs/` with JSON Schema validation (`config-schema.json`). Supports auth settings (MFA/TOTP), URL/code rule scoping (`rules.avoid`/`rules.focus`), run-scope steering (`vuln_classes`, `exploit`), free-form `rules_of_engagement`, and post-hoc `report` options (`min_severity`, `min_confidence`, `guidance`, and `sarif` to emit a SARIF 2.1.0 log via `apps/worker/src/services/sarif-renderer.ts`; exploit-only). `code_path` avoid rules are enforced via the `@gotgenes/pi-permission-system` extension: `apps/worker/src/temporal/activities.ts:syncCodePathDenyRules` writes a global `path` deny config once per workflow (`apps/worker/src/ai/pi/permission-system.ts:syncPermissionSystemConfig`), and the executor loads the extension when that config is present (`apps/worker/src/ai/pi/pi-executor.ts`), so denies fire across every tool and child `task` session. `vuln_classes`/`exploit` scope is locked into `session.json` on first run; resumes with a different scope fail fast (`persistOrValidateRunScope`). Credential resolution — local mode: env vars → `./.env`; npx mode: env vars → `~/.shannon/config.toml` (via `npx @keygraph/shannon 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`, including `_code-path-rules.txt` (focus/avoid `[FILE]`/`[GLOB]` routing) and `_rules-of-engagement.txt` (free-text engagement rules). When `exploit: false`, `apps/worker/src/services/findings-renderer.ts` deterministically converts each `*_exploitation_queue.json` into a `*_findings.md` for report assembly — no LLM in the loop
|
||||
- **Agent Harness (pi)** — Uses the **pi harness** (`@earendil-works/pi-coding-agent`, requires Node ≥ 22.19) via `apps/worker/src/ai/pi/pi-executor.ts` (`runPiPrompt` → `createAgentSession`). Retry is split in `apps/worker/src/ai/pi/retry-settings.ts`: pi's agent-level loop is off so Temporal owns agent restarts, while `provider.maxRetries` stays on — pi reads the `provider` block independently of the `enabled` flag — so transport faults are absorbed in-session rather than costing a full agent re-run. `maxRetryDelayMs` is left at pi's 60s default. One model runs every phase, named by `SHANNON_AI_MODEL=<provider>:<model-id>` (default `anthropic:claude-sonnet-4-6`). `apps/worker/src/ai/models.ts` parses the spec — splitting on the **first** colon only, so Bedrock IDs keep theirs — and resolves it through pi's `ModelRuntime`. pi ships the `CredentialStore` interface but no in-memory implementation (its own reads `auth.json` from disk), so `RuntimeCredentialStore` in that file supplies one: credentials arrive as env vars in an ephemeral container and must never touch disk. `createModelRuntime(providerId, apiKey)` builds the runtime; `allowModelNetwork` stays at its default `false` so a scan never blocks on a catalog refresh. `resolveModelSelection()` is **async** because `ModelRuntime.create()` is. Any pi-ai provider id is accepted — `parseModelSpec` no longer rejects against a hardcoded list, so pi's registry is the authority (an unknown provider/model surfaces as a clear "not found in pi registry" error at preflight, which points to the browsable catalogue at `pi.dev/models` — `PI_CATALOG_URL` in `apps/worker/src/ai/models.ts`, appended to the not-found errors and shown in the setup wizard's "Other provider" hint). Four providers are **curated** (`CURATED_PROVIDERS`: `anthropic`, `openai`, `xai`, `amazon-bedrock`) with their own credential variables, config sections, and setup flows; each provider's API key env var is declared once in `PROVIDER_API_KEY_ENV` — Shannon uses each vendor's own variable name (`OPENAI_API_KEY`, `XAI_API_KEY`, …), never an invented one; Bedrock's entry is `AWS_BEARER_TOKEN_BEDROCK`, paired with `AWS_REGION`, which preflight requires separately as provider config rather than a credential. Any other provider uses the **generic** credential path: `SHANNON_AI_API_KEY` (`GENERIC_API_KEY_ENV`) supplies the key for any provider whose credential is a plain API key. Curated providers' own variables take precedence over it, and it also works as a fallback for them — Bedrock is the sole exception (it authenticates through its AWS_ variables, so the generic key never stands in for it). The CLI forwards `SHANNON_AI_API_KEY` in `COMMON_FORWARD_VARS` (it is provider-neutral, binding to whatever `SHANNON_AI_MODEL` names, so the "only one provider configured" guard counts only named credentials), and stores it under a generic `[provider]` config.toml section (`provider.api_key`). `npx @keygraph/shannon setup` exposes this as the "Other provider" option: free-text provider id + model id + key (a curated provider id is rejected there, since it has its own option). `SHANNON_AI_BASE_URL` overrides the endpoint for any provider (proxies/gateways); the credential is unchanged. `pointAtGateway` (`apps/worker/src/ai/models.ts`) applies the one dialect change: behind a base URL, `openai` follows `SHANNON_AI_OPENAI_FORMAT` (`chat-completions` default, or `responses`). On `chat-completions` it switches the API to `openai-completions` and drops the catalogue's Responses-shaped `compat` block so pi's `detectCompat` derives completions settings; on `responses` the descriptor is unchanged but for the endpoint. `resolveGatewayFormat` rejects the variable when the provider is not `openai` or no base URL is set, since it cannot take effect there. All other providers keep their API. The CLI mirrors the accepted values in `apps/cli/src/model-spec.ts`, forwards the variable in `COMMON_FORWARD_VARS`, and maps it to `openai.format` in config.toml. `buildEnvFlags` forwards only the selected provider's credential into the worker container. The CLI mirrors the parse rule and the provider/credential tables in `apps/cli/src/model-spec.ts` (it cannot import from the worker package); the two must stay in sync. pi ships no JSON-schema output or `Task`/`TodoWrite` built-ins, so structured queues are captured via a `submit_exploitation_queue` custom tool (`apps/worker/src/ai/queue-schemas.ts`), and `task` (child sessions scoped to `read`, `grep`, `find`, `ls`, `write`, and `bash` — no nested `task` or collector tools; `CHILD_TOOLS` in `apps/worker/src/ai/pi/task-tool.ts`) + `todo_write` (`apps/worker/src/ai/pi/session-tools.ts`) are provided as custom tools; the per-phase collectors are pi custom tools (TypeBox `defineTool` in `apps/worker/src/collectors/`). Shannon sets no thinking configuration at all — no `thinkingLevel` is passed to any `createAgentSession` call, so pi's own default applies. There Line truncated
|
||||
- **Pi Credential Reuse** — `SHANNON_USE_PI_AUTH=1` opts into reusing the host's Pi login, including an `openai-codex` ChatGPT Plus/Pro subscription selected with `SHANNON_AI_MODEL=openai-codex:<model-id>`. `apps/cli/src/env.ts` requires `~/.pi/agent/auth.json`; `start.ts` passes its path to `spawnWorker`, which mounts only that file read-write at `/tmp/.pi/agent/auth.json`. The flag itself is not forwarded: the worker detects the file with `piAuthPresent()` and passes its path to `ModelRuntime.create`. CLI and worker API-key presence checks are skipped on this path, but the normal preflight model probe still validates the credential. The image and UID-remapping entrypoint keep `/tmp/.pi/agent` owned by `pentest` so adjacent Pi/Shannon configuration remains writable. Refreshed OAuth state is persisted to the host for subsequent scans.
|
||||
- **Audit System** — Crash-safe append-only logging in `workspaces/{hostname}_{sessionId}/`. The run directory's top level holds the human-facing report in both formats (`Security-Assessment-Report.pdf` and `Security-Assessment-Report.md`, `FINAL_REPORT_PDF_FILENAME`/`FINAL_REPORT_MD_FILENAME` in `apps/worker/src/paths.ts`); everything else — deliverables, per-agent logs, prompts, `session.json`, `workflow.log`, and browser artifacts — is nested under a hidden `.shannon/` internals dir (`INTERNAL_DIR`) so a customer sees only the report. Audit path helpers route through `generateInternalPath` (`apps/worker/src/audit/utils.ts`); the CLI nests the overlay backing dirs under the same `.shannon/` (`apps/cli/src/docker.ts`, `start.ts`). `session.json`/`workflow.log` reads use dual-read resolvers (`resolveSessionJsonPath`, `resolveRunFile`) that prefer `.shannon/` and fall back to the legacy run-root layout, so pre-restructure workspaces stay listable (`workspaces`/`logs`) without migration. Resuming a pre-restructure workspace upgrades it in place first: `migrateLegacyWorkspaceLayout` (`apps/cli/src/commands/start.ts`) renames the flat deliverables/logs/session entries into `.shannon/` (carrying the deliverables `.git` along) before the overlay dirs are mounted, so resume finds the old checkpoints instead of re-running every agent. The report agent writes structured findings to `report.json`, from which `report-renderer.ts` renders the assembled markdown and `report-json-adapter.ts` produces the Typst-shaped JSON that `pdf-renderer.ts` compiles into `comprehensive_security_assessment_report.pdf` using the bundled `apps/worker/templates/typst/report.typ` template (the `typst` binary is installed in the worker image). `copyReportToRunRoot` (`apps/worker/src/services/reporting.ts`) surfaces both the PDF and the markdown to the run root as `Security-Assessment-Report.pdf` and `Security-Assessment-Report.md`; the deliverables-dir copies remain as the git-checkpointed sources. PDF compilation is best-effort — a failure is logged and the run still completes. 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 `.shannon/deliverables/` in the target repo via the `save-deliverable` CLI script (`apps/worker/src/scripts/save-deliverable.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
|
||||
|
||||
## Development Notes
|
||||
|
||||
@@ -167,7 +176,7 @@ Durable workflow orchestration with crash recovery, queryable progress, intellig
|
||||
### Key Design Patterns
|
||||
- **Configuration-Driven** — YAML configs with JSON Schema validation
|
||||
- **Progressive Analysis** — Each phase builds on previous results
|
||||
- **SDK-First** — Claude Agent SDK handles autonomous analysis
|
||||
- **Harness-First** — the pi harness (`@earendil-works/pi-coding-agent`) 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; `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)
|
||||
@@ -227,7 +236,7 @@ Comments must be **timeless** — no references to this conversation, refactorin
|
||||
|
||||
**Entry Points:** `apps/worker/src/temporal/workflows.ts`, `apps/worker/src/temporal/activities.ts`, `apps/worker/src/temporal/worker.ts`
|
||||
|
||||
**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/`
|
||||
**Core Logic:** `apps/worker/src/session-manager.ts`, `apps/worker/src/ai/pi/pi-executor.ts`, `apps/worker/src/ai/pi/permission-system.ts` (writes `code_path` deny rules to the `@gotgenes/pi-permission-system` global config), `apps/worker/src/config-parser.ts`, `apps/worker/src/services/` (incl. `preflight.ts`, `findings-renderer.ts`, `reporting.ts`), `apps/worker/src/audit/`
|
||||
|
||||
**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`
|
||||
|
||||
@@ -239,10 +248,9 @@ Package managers are configured with a minimum release age (7 days). Requires pn
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **"Repository not found"** — Pass a bare name (`-r my-repo`) for `./repos/my-repo`, or a path (`-r /path/to/repo`) for any directory
|
||||
- **"Repository not found"** — Pass a path to the target repo (`-r /path/to/repo` or `-r ./my-repo`)
|
||||
- **"Temporal not ready"** — Wait for health check or `docker compose logs temporal`
|
||||
- **Worker not processing** — Check `docker ps --filter "name=shannon-worker-"`
|
||||
- **Reset state** — `./shannon stop --clean`
|
||||
- **Reset state** — `./shannon reset`
|
||||
- **Local apps unreachable** — Use `host.docker.internal` instead of `localhost`
|
||||
- **Missing tools** — Use `--pipeline-testing` to skip nmap/subfinder/whatweb (graceful degradation)
|
||||
- **Container permissions** — On Linux, may need `sudo` for docker commands
|
||||
+24
-55
@@ -13,46 +13,14 @@ RUN apk update && apk add --no-cache \
|
||||
curl \
|
||||
wget \
|
||||
ca-certificates \
|
||||
# Network libraries for Go tools
|
||||
libpcap-dev \
|
||||
linux-headers \
|
||||
# Language runtimes
|
||||
go \
|
||||
nodejs-22 \
|
||||
npm \
|
||||
python3 \
|
||||
py3-pip \
|
||||
ruby \
|
||||
ruby-dev \
|
||||
# Security tools available in Wolfi
|
||||
nmap \
|
||||
# Additional utilities
|
||||
bash
|
||||
|
||||
# Set environment variables for Go
|
||||
ENV GOPATH=/go
|
||||
ENV PATH=$GOPATH/bin:/usr/local/go/bin:$PATH
|
||||
ENV CGO_ENABLED=1
|
||||
|
||||
# Create directories
|
||||
RUN mkdir -p $GOPATH/bin
|
||||
|
||||
# Install Go-based security tools
|
||||
RUN go install -v github.com/projectdiscovery/subfinder/v2/cmd/subfinder@v2.13.0
|
||||
# Install WhatWeb from release tarball (Ruby-based tool)
|
||||
RUN curl -sL https://github.com/urbanadventurer/WhatWeb/archive/refs/tags/v0.6.3.tar.gz | tar xz -C /opt && \
|
||||
mv /opt/WhatWeb-0.6.3 /opt/whatweb && \
|
||||
chmod +x /opt/whatweb/whatweb && \
|
||||
gem install addressable -v 2.8.9 && \
|
||||
echo '#!/bin/bash' > /usr/local/bin/whatweb && \
|
||||
echo 'cd /opt/whatweb && exec ./whatweb "$@"' >> /usr/local/bin/whatweb && \
|
||||
chmod +x /usr/local/bin/whatweb
|
||||
|
||||
# Install Python-based tools
|
||||
RUN pip3 install --no-cache-dir schemathesis==4.13.0
|
||||
|
||||
# Install pnpm
|
||||
RUN npm install -g pnpm@10.33.0
|
||||
RUN npm install -g --ignore-scripts pnpm@10.33.0
|
||||
|
||||
# Build Node.js application in builder to avoid QEMU emulation failures in CI
|
||||
WORKDIR /app
|
||||
@@ -84,15 +52,12 @@ RUN apk update && apk add --no-cache \
|
||||
curl \
|
||||
ca-certificates \
|
||||
shadow \
|
||||
# Network libraries (runtime)
|
||||
libpcap \
|
||||
# Security tools
|
||||
nmap \
|
||||
# Typst tarball decompression
|
||||
xz \
|
||||
# Language runtimes (minimal)
|
||||
nodejs-22 \
|
||||
npm \
|
||||
python3 \
|
||||
ruby \
|
||||
# Chromium browser and dependencies for Playwright
|
||||
chromium \
|
||||
# Additional libraries Chromium needs
|
||||
@@ -110,19 +75,21 @@ RUN apk update && apk add --no-cache \
|
||||
# Font rendering
|
||||
fontconfig
|
||||
|
||||
# Copy Go binaries from builder
|
||||
COPY --from=builder /go/bin/subfinder /usr/local/bin/
|
||||
|
||||
# Copy WhatWeb from builder
|
||||
COPY --from=builder /opt/whatweb /opt/whatweb
|
||||
COPY --from=builder /usr/local/bin/whatweb /usr/local/bin/whatweb
|
||||
|
||||
# Install WhatWeb Ruby dependencies in runtime stage
|
||||
RUN gem install addressable -v 2.8.9
|
||||
|
||||
# Copy Python packages from builder
|
||||
COPY --from=builder /usr/lib/python3.*/site-packages /usr/lib/python3.12/site-packages
|
||||
COPY --from=builder /usr/bin/schemathesis /usr/bin/
|
||||
# Install Typst (report PDF compilation)
|
||||
ARG TYPST_VERSION=0.14.2
|
||||
RUN case "$(uname -m)" in \
|
||||
x86_64) TYPST_ARCH=x86_64-unknown-linux-musl ;; \
|
||||
aarch64) TYPST_ARCH=aarch64-unknown-linux-musl ;; \
|
||||
*) echo "unsupported arch $(uname -m)" && exit 1 ;; \
|
||||
esac && \
|
||||
mkdir -p /tmp/typst-dl /usr/local/bin && cd /tmp/typst-dl && \
|
||||
curl -fsSL "https://github.com/typst/typst/releases/download/v${TYPST_VERSION}/typst-${TYPST_ARCH}.tar.xz" -o typst.tar.xz && \
|
||||
xz -d typst.tar.xz && \
|
||||
tar -xf typst.tar && \
|
||||
mv "typst-${TYPST_ARCH}/typst" /usr/local/bin/typst && \
|
||||
chmod +x /usr/local/bin/typst && \
|
||||
cd / && rm -rf /tmp/typst-dl && \
|
||||
typst --version
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup -g 1001 pentest && \
|
||||
@@ -142,7 +109,7 @@ 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
|
||||
|
||||
RUN npm install -g @anthropic-ai/claude-code@2.1.84 @playwright/cli@0.1.1
|
||||
RUN npm install -g --ignore-scripts @playwright/cli@0.1.1
|
||||
RUN mkdir -p /tmp/.claude/skills && \
|
||||
playwright-cli install --skills && \
|
||||
cp -r .claude/skills/playwright-cli /tmp/.claude/skills/ && \
|
||||
@@ -152,16 +119,18 @@ RUN mkdir -p /tmp/.claude/skills && \
|
||||
RUN ln -s /app/apps/worker/dist/scripts/save-deliverable.js /usr/local/bin/save-deliverable && \
|
||||
chmod +x /app/apps/worker/dist/scripts/save-deliverable.js && \
|
||||
ln -s /app/apps/worker/dist/scripts/generate-totp.js /usr/local/bin/generate-totp && \
|
||||
chmod +x /app/apps/worker/dist/scripts/generate-totp.js
|
||||
chmod +x /app/apps/worker/dist/scripts/generate-totp.js && \
|
||||
ln -s /app/apps/worker/dist/scripts/set-report-meta.js /usr/local/bin/set-report-meta && \
|
||||
chmod +x /app/apps/worker/dist/scripts/set-report-meta.js
|
||||
|
||||
# Create directories for session data and ensure proper permissions
|
||||
RUN mkdir -p /app/sessions /app/repos /app/workspaces && \
|
||||
mkdir -p /tmp/.cache /tmp/.config /tmp/.npm && \
|
||||
mkdir -p /tmp/.cache /tmp/.config /tmp/.npm /tmp/.pi/agent && \
|
||||
chmod 777 /app && \
|
||||
chmod 777 /tmp/.cache && \
|
||||
chmod 777 /tmp/.config && \
|
||||
chmod 777 /tmp/.npm && \
|
||||
chown -R pentest:pentest /app /tmp/.claude
|
||||
chown -R pentest:pentest /app /tmp/.claude /tmp/.pi
|
||||
|
||||
COPY entrypoint.sh /app/entrypoint.sh
|
||||
RUN chmod +x /app/entrypoint.sh
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
>[!NOTE]
|
||||
> **[📢 Sunsetting Router Mode (claude-code-router)`. →](https://github.com/KeygraphHQ/shannon/discussions/301)**
|
||||
> [!NOTE]
|
||||
> **[Shannon 2.0 is officially here](https://github.com/KeygraphHQ/shannon/discussions/405)**
|
||||
|
||||
<div align="center">
|
||||
|
||||
<img src="./assets/github-banner.png" alt="Shannon — AI Pentester for Web Applications and APIs" width="100%">
|
||||
<img src="./assets/github-banner.png" alt="Shannon - AI Pentester by Keygraph" width="100%">
|
||||
|
||||
# Shannon — AI Pentester by Keygraph
|
||||
# Shannon - AI Pentester by Keygraph
|
||||
|
||||
<a href="https://trendshift.io/repositories/15604" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15604" alt="KeygraphHQ%2Fshannon | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
|
||||
Shannon is an autonomous, white-box AI pentester for web applications and APIs. <br />
|
||||
It analyzes your source code, identifies attack vectors, and executes real exploits to prove vulnerabilities before they reach production.
|
||||
Shannon is an autonomous, AI pentester for web applications and APIs. <br />
|
||||
It analyzes your source code, identifies attack paths, and executes real exploits to prove vulnerabilities before they reach production.
|
||||
|
||||
**This repository is Shannon Open Source: the full agent, run locally from your command line.**
|
||||
|
||||
---
|
||||
|
||||
@@ -20,700 +22,127 @@ It analyzes your source code, identifies attack vectors, and executes real explo
|
||||
---
|
||||
</div>
|
||||
|
||||
## What is Shannon?
|
||||
|
||||
Shannon is an AI pentester developed by [Keygraph](https://keygraph.io). It performs white-box security testing of web applications and their underlying APIs by combining source code analysis with live exploitation.
|
||||
|
||||
Shannon analyzes your web application's source code to identify potential attack vectors, then uses browser automation and command-line tools to execute real exploits (injection attacks, authentication bypass, SSRF, XSS) against the running application and its APIs. Only vulnerabilities with a working proof-of-concept are included in the final report.
|
||||
|
||||
**Why Shannon Exists**
|
||||
|
||||
Thanks to tools like Claude Code and Cursor, your team ships code non-stop. But your penetration test? That happens once a year. This creates a *massive* security gap. For the other 364 days, you could be unknowingly shipping vulnerabilities to production.
|
||||
|
||||
Shannon closes that gap by providing on-demand, automated penetration testing that can run against every build or release.
|
||||
|
||||
## Shannon in Action
|
||||
|
||||
Shannon identified 20+ vulnerabilities in OWASP Juice Shop, including authentication bypass and database exfiltration. [Full report →](sample-reports/shannon-report-juice-shop.md)
|
||||
|
||||

|
||||
|
||||
## Features
|
||||
|
||||
- **Fully Autonomous Operation**: A single command launches the full pentest. Shannon handles 2FA/TOTP logins (including SSO), browser navigation, exploitation, and report generation without manual intervention.
|
||||
- **Reproducible Proof-of-Concept Exploits**: The final report contains only proven, exploitable findings with copy-and-paste PoCs. Vulnerabilities that cannot be exploited are not reported.
|
||||
- **OWASP Vulnerability Coverage**: Identifies and validates Injection, XSS, SSRF, and Broken Authentication/Authorization, with additional categories in development.
|
||||
- **Code-Aware Dynamic Testing**: Analyzes source code to guide attack strategy, then validates findings with live browser and CLI-based exploits against the running application.
|
||||
- **Integrated Security Tooling**: Leverages Nmap, Subfinder, WhatWeb, and Schemathesis during reconnaissance and discovery phases.
|
||||
- **Parallel Processing**: Vulnerability analysis and exploitation phases run concurrently across all attack categories.
|
||||
|
||||
## Product Line
|
||||
|
||||
Shannon is developed by [Keygraph](https://keygraph.io) and available in two editions:
|
||||
|
||||
| Edition | License | Best For |
|
||||
|---------|---------|----------|
|
||||
| **Shannon Lite** | AGPL-3.0 | Local testing of your own applications. |
|
||||
| **Shannon Pro** | Commercial | Organizations needing a single AppSec platform (SAST, SCA, secrets, business logic testing, autonomous pentesting) with CI/CD integration and self-hosted deployment. |
|
||||
|
||||
> **This repository contains Shannon Lite,** the core autonomous AI pentesting framework. **Shannon Pro** is Keygraph's all-in-one AppSec platform, combining SAST, SCA, secrets scanning, business logic security testing, and autonomous AI pentesting in a single correlated workflow. Every finding is validated with a working proof-of-concept exploit.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **White-box only.** Shannon Lite is designed for **white-box (source-available)** application security testing.
|
||||
> It expects access to your application's source code and repository layout.
|
||||
|
||||
### Shannon Pro: Architecture Overview
|
||||
|
||||
Shannon Pro is an all-in-one application security platform that replaces the need to stitch together separate SAST, SCA, secrets scanning, and pentesting tools. It operates as a two-stage pipeline: agentic static analysis of the codebase, followed by autonomous AI penetration testing. Findings from both stages are cross-referenced and correlated, so every reported vulnerability has a working proof-of-concept exploit and a precise source code location.
|
||||
|
||||
**Stage 1: Agentic Static Analysis**
|
||||
|
||||
Shannon Pro transforms the codebase into a Code Property Graph (CPG) combining the AST, control flow graph, and program dependence graph. It then runs five analysis capabilities:
|
||||
|
||||
- **Data Flow Analysis (SAST)**: Identifies sources (user input, API requests) and sinks (SQL queries, command execution), then traces paths between them. At each node, an LLM evaluates whether the specific sanitization applied is sufficient for the specific vulnerability in context, rather than relying on a hard-coded allowlist of safe functions.
|
||||
- **Point Issue Detection (SAST)**: LLM-based detection of single-location vulnerabilities: weak cryptography, hardcoded credentials, insecure configuration, missing security headers, weak RNG, disabled certificate validation, and overly permissive CORS.
|
||||
- **Business Logic Security Testing (SAST)**: LLM agents analyze the codebase to discover application-specific invariants (e.g., "document access must verify organizational ownership"), generate targeted fuzzers to violate those invariants, and synthesize full PoC exploits. This catches authorization failures and domain-specific logic errors that pattern-based scanners cannot detect.
|
||||
- **SCA with Reachability Analysis**: Goes beyond flagging CVEs by tracing whether the vulnerable function is actually reachable from application entry points via the CPG. Unreachable vulnerabilities are deprioritized.
|
||||
- **Secrets Detection**: Combines regex pattern matching with LLM-based detection (for dynamically constructed credentials, custom formats, obfuscated tokens) and performs liveness validation against the corresponding service using read-only API calls.
|
||||
|
||||
**Stage 2: Autonomous Dynamic Penetration Testing**
|
||||
|
||||
The same multi-agent pentest pipeline as Shannon Lite (reconnaissance, parallel vulnerability analysis, parallel exploitation, reporting), enhanced with static findings injected into the exploitation queue. Static findings are mapped to Shannon's five attack domains (Injection, XSS, SSRF, Auth, Authz), and exploit agents attempt real proof-of-concept attacks against the running application for each finding.
|
||||
|
||||
**Static-Dynamic Correlation**
|
||||
|
||||
This is the core differentiator. A data flow vulnerability identified in static analysis (e.g., unsanitized input reaching a SQL query) is not reported as a theoretical risk. It is fed to the corresponding exploit agent, which attempts to exploit it against the live application. Confirmed exploits are traced back to the exact source code location, giving developers both proof of exploitability and the line of code to fix.
|
||||
|
||||
**Deployment Model**
|
||||
|
||||
Shannon Pro supports a self-hosted runner model (similar to GitHub Actions self-hosted runners). The data plane, which handles code access and all LLM API calls, runs entirely within the customer's infrastructure using the customer's own API keys. Source code never leaves the customer's network. The Keygraph control plane handles job orchestration, scan scheduling, and the reporting UI, receiving only aggregate findings.
|
||||
|
||||
| Capability | Shannon Lite | Shannon Pro (All-in-One AppSec) |
|
||||
| --- | --- | --- |
|
||||
| **Licensing** | AGPL-3.0 | Commercial |
|
||||
| **Static Analysis** | Code review prompting | Full agentic SAST, SCA, secrets, business logic testing |
|
||||
| **Dynamic Testing** | Autonomous AI pentesting | Autonomous AI pentesting with static-dynamic correlation |
|
||||
| **Analysis Engine** | Code review prompting | CPG-based data flow with LLM reasoning at every node |
|
||||
| **Business Logic** | None | Automated invariant discovery, fuzzer generation, exploit synthesis |
|
||||
| **CI/CD Integration** | Manual / CLI | Native CI/CD, GitHub PR scanning |
|
||||
| **Deployment** | CLI | Managed cloud or self-hosted runner |
|
||||
| **Boundary Analysis** | None | Automatic service boundary detection with team routing |
|
||||
|
||||
[Full technical details →](./SHANNON-PRO.md)
|
||||
> [!TIP]
|
||||
> **AI agents and LLMs:** start with [llms.txt](llms.txt) for a concise map of this repository, or use [llms-full.txt](llms-full.txt) for the README and docs combined into one file.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [What is Shannon?](#what-is-shannon)
|
||||
- [Shannon in Action](#shannon-in-action)
|
||||
- [Features](#features)
|
||||
- [Product Line](#product-line)
|
||||
- [Setup & Usage Instructions](#setup--usage-instructions)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Quick Start (Recommended: npx)](#quick-start-recommended-npx)
|
||||
- [Clone and Build](#clone-and-build)
|
||||
- [Prepare Your Repository](#prepare-your-repository)
|
||||
- [Common Commands](#common-commands)
|
||||
- [Workspaces and Resuming](#workspaces-and-resuming)
|
||||
- [Credentials and Configuration](#credentials-and-configuration)
|
||||
- [AWS Bedrock](#aws-bedrock)
|
||||
- [Google Vertex AI](#google-vertex-ai)
|
||||
- [Custom Base URL](#custom-base-url)
|
||||
- [Platform-Specific Instructions](#platform-specific-instructions)
|
||||
- [Output and Results](#output-and-results)
|
||||
- [Sample Reports](#sample-reports)
|
||||
- [Benchmark](#benchmark)
|
||||
- [Quick Start](#quick-start)
|
||||
- [Key Capabilities](#key-capabilities)
|
||||
- [Editions](#editions)
|
||||
- [Architecture](#architecture)
|
||||
- [Coverage and Roadmap](#coverage-and-roadmap)
|
||||
- [Disclaimers](#disclaimers)
|
||||
- [Documentation](#documentation)
|
||||
- [Safety, Scope, and Limitations](#safety-scope-and-limitations)
|
||||
- [License](#license)
|
||||
- [Community & Support](#community--support)
|
||||
- [Get in Touch](#get-in-touch)
|
||||
- [About Keygraph](#about-keygraph)
|
||||
- [Community and Support](#community-and-support)
|
||||
|
||||
---
|
||||
## What is Shannon?
|
||||
|
||||
## Setup & Usage Instructions
|
||||
Shannon is an autonomous AI pentester developed by [Keygraph](https://keygraph.io). It performs security testing of web applications and their underlying APIs by combining source-code analysis with live exploitation.
|
||||
|
||||
Shannon analyzes your web application's source code to identify potential attack vectors, then uses browser automation and command-line tools to execute real exploits against the running application and its APIs. Only vulnerabilities with a working proof-of-concept are included in the final report.
|
||||
|
||||
Shannon is the agent. This repository is Shannon Open Source, the standalone pentester you run yourself. The same Shannon also powers the [Keygraph platform](https://keygraph.io), Keygraph's commercial pentesting product. See [Editions](#editions) for how the two compare.
|
||||
|
||||
### Why Shannon Exists
|
||||
|
||||
Thanks to tools like Claude Code and Cursor, your team ships code non-stop. But your penetration test? That happens once a year. This creates a massive security gap. For the other 364 days, you could be unknowingly shipping vulnerabilities to production.
|
||||
|
||||
Shannon closes that gap by providing on-demand, automated penetration testing that can run against every build or release.
|
||||
|
||||
## Shannon in Action
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/shannon-action.gif" alt="Shannon running an autonomous pentest" width="100%">
|
||||
</p>
|
||||
|
||||
Sample penetration test reports from intentionally vulnerable applications, produced by Shannon Open Source:
|
||||
|
||||
| Target | Summary | Report |
|
||||
| --- | --- | --- |
|
||||
| OWASP Juice Shop | 20+ vulnerabilities, including authentication bypass, SQL injection, IDOR, and SSRF. | [View report](sample-reports/shannon-report-juice-shop.md) |
|
||||
| c{api}tal API | Approximately 15 critical and high-severity API findings, including command injection, auth bypass, and mass assignment. | [View report](sample-reports/shannon-report-capital-api.md) |
|
||||
| OWASP crAPI | 15+ critical and high-severity findings across JWT, injection, SSRF, and API authorization paths. | [View report](sample-reports/shannon-report-crapi.md) |
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Docker** - Container runtime ([Install Docker](https://docs.docker.com/get-docker/))
|
||||
- **Node.js 18+** - Required for `npx` usage ([Install Node.js](https://nodejs.org/))
|
||||
- **pnpm** - Required for Clone and Build mode ([Install pnpm](https://pnpm.io/installation))
|
||||
- **AI Provider Credentials** (choose one):
|
||||
- **Anthropic API key** (recommended) - Get from [Anthropic Console](https://console.anthropic.com)
|
||||
- **Claude Code OAuth token**
|
||||
- **AWS Bedrock** - Route through Amazon Bedrock with AWS credentials (see [AWS Bedrock](#aws-bedrock))
|
||||
- **Google Vertex AI** - Route through Google Cloud Vertex AI (see [Google Vertex AI](#google-vertex-ai))
|
||||
- **Docker**: required for the worker container.
|
||||
- **Node.js 18+**: required for the recommended `npx` workflow.
|
||||
- **AI provider credentials**: Anthropic, OpenAI, xAI, or AWS Bedrock - or [any other provider](docs/ai-providers.md#any-other-provider). Claude models are recommended. For suggested model IDs per provider, plus gateways and custom base URLs, see [AI providers](docs/ai-providers.md#suggested-models).
|
||||
- **Cyber safeguards cleared with your provider**: Anthropic and OpenAI apply real-time safeguards to cyber-security workloads, which can interrupt a scan mid-run. Complete their guidance for legitimate security testers before your first run - see [AI providers](docs/ai-providers.md#cyber-safeguards-do-this-before-your-first-scan).
|
||||
|
||||
> [!NOTE]
|
||||
> Docker is still required to use the `npx` workflow. Under the hood, the CLI pulls and runs a prebuilt Shannon worker image from Docker Hub, which is approximately 1 GB and contains Shannon plus all required dependencies. Shannon mounts the target repository as read-only inside the worker container to protect against accidental modifications during analysis. Run Shannon via `npx @keygraph/shannon` for the latest released version, or pull the latest `main` if building from source.
|
||||
|
||||
### Quick Start (Recommended: npx)
|
||||
### Run Shannon
|
||||
|
||||
> [!WARNING]
|
||||
> **Please read the [Disclaimers](#disclaimers) before running Shannon.** Shannon is **not** a passive scanner — it actively executes exploits against the target. You must have **explicit, written authorization** from the system owner.
|
||||
> Shannon actively executes exploits. Run it only against applications and environments you own or have explicit written authorization to test. Do not run Shannon against production systems.
|
||||
|
||||
```bash
|
||||
# 1. Configure credentials (interactive wizard — one-time setup)
|
||||
# Configure credentials with the interactive wizard.
|
||||
npx @keygraph/shannon setup
|
||||
|
||||
# Or export env vars directly
|
||||
export ANTHROPIC_API_KEY=your-api-key
|
||||
|
||||
# 2. Run a pentest
|
||||
# Run a pentest against a source-available target.
|
||||
npx @keygraph/shannon start -u https://your-app.com -r /path/to/your-repo
|
||||
```
|
||||
|
||||
Shannon will pull the worker image from Docker Hub, start the infrastructure, and launch an ephemeral worker container for the scan.
|
||||
Shannon pulls the worker image from Docker Hub, starts the required local infrastructure, mounts the target repository read-only inside an ephemeral worker container, and writes results to a local workspace.
|
||||
|
||||
### Clone and Build
|
||||
For source builds, authenticated scans, provider-specific setup, and platform notes, see [Documentation](#documentation).
|
||||
|
||||
Use this if you want to run Shannon from a local clone, modify Shannon itself, or keep the worker image built locally.
|
||||
|
||||
```bash
|
||||
# 1. Clone Shannon
|
||||
git clone https://github.com/KeygraphHQ/shannon.git
|
||||
cd shannon
|
||||
|
||||
# 2. Configure credentials (choose one method)
|
||||
|
||||
# Option A: Create a .env file
|
||||
cat > .env << 'EOF'
|
||||
ANTHROPIC_API_KEY=your-api-key
|
||||
CLAUDE_CODE_MAX_OUTPUT_TOKENS=64000
|
||||
EOF
|
||||
|
||||
# Option B: Export environment variables
|
||||
export ANTHROPIC_API_KEY="your-api-key" # or CLAUDE_CODE_OAUTH_TOKEN
|
||||
export CLAUDE_CODE_MAX_OUTPUT_TOKENS=64000 # recommended
|
||||
|
||||
# 3. Install dependencies and build
|
||||
pnpm install
|
||||
pnpm build
|
||||
|
||||
# 4. Run a pentest
|
||||
./shannon start -u https://your-app.com -r /path/to/your-repo
|
||||
```
|
||||
|
||||
Shannon will build the worker image locally, start the infrastructure, and launch an ephemeral worker container for the scan.
|
||||
|
||||
### Prepare Your Repository
|
||||
|
||||
Shannon can scan any repository on your machine. Pass an absolute or relative path with `-r`.
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
npx @keygraph/shannon start -u https://example.com -r /path/to/repo
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>Clone and Build command equivalents</summary>
|
||||
|
||||
```bash
|
||||
./shannon start -u https://example.com -r ./relative/path
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### Common Commands
|
||||
|
||||
#### Monitoring Progress
|
||||
|
||||
```bash
|
||||
npx @keygraph/shannon logs <workspace>
|
||||
npx @keygraph/shannon status
|
||||
```
|
||||
|
||||
Open the Temporal Web UI for detailed monitoring:
|
||||
|
||||
```bash
|
||||
open http://localhost:8233
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>Clone and Build command equivalents</summary>
|
||||
|
||||
```bash
|
||||
./shannon logs <workspace>
|
||||
./shannon status
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
#### Stopping Shannon
|
||||
|
||||
```bash
|
||||
npx @keygraph/shannon stop
|
||||
npx @keygraph/shannon stop --clean
|
||||
npx @keygraph/shannon uninstall
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>Clone and Build command equivalents</summary>
|
||||
|
||||
```bash
|
||||
./shannon stop
|
||||
./shannon stop --clean
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
#### Usage Examples
|
||||
|
||||
```bash
|
||||
# Basic pentest
|
||||
npx @keygraph/shannon start -u https://example.com -r /path/to/repo
|
||||
|
||||
# With a configuration file
|
||||
npx @keygraph/shannon start -u https://example.com -r /path/to/repo -c /path/to/my-config.yaml
|
||||
|
||||
# Custom output directory
|
||||
npx @keygraph/shannon start -u https://example.com -r /path/to/repo -o ./my-reports
|
||||
|
||||
# Named workspace
|
||||
npx @keygraph/shannon start -u https://example.com -r /path/to/repo -w q1-audit
|
||||
|
||||
# List all workspaces
|
||||
npx @keygraph/shannon workspaces
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>Clone and Build command equivalents</summary>
|
||||
|
||||
```bash
|
||||
# Basic pentest
|
||||
./shannon start -u https://example.com -r /path/to/repo
|
||||
|
||||
# With a configuration file
|
||||
./shannon start -u https://example.com -r /path/to/repo -c /path/to/my-config.yaml
|
||||
|
||||
# Custom output directory
|
||||
./shannon start -u https://example.com -r /path/to/repo -o ./my-reports
|
||||
|
||||
# Named workspace
|
||||
./shannon start -u https://example.com -r /path/to/repo -w q1-audit
|
||||
|
||||
# List all workspaces
|
||||
./shannon workspaces
|
||||
|
||||
# Rebuild worker image
|
||||
./shannon build --no-cache
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### Workspaces and Resuming
|
||||
|
||||
Shannon supports **workspaces** that allow you to resume interrupted or failed runs without re-running completed agents.
|
||||
|
||||
**How it works:**
|
||||
|
||||
- Every run creates a workspace (auto-named by default, for example `example-com_shannon-1771007534808`)
|
||||
- Workspaces are stored in `./workspaces/` (local mode) or `~/.shannon/workspaces/` (npx mode)
|
||||
- Use `-w <name>` to give your run a custom name for easier reference
|
||||
- To resume any run, pass its workspace name via `-w` — Shannon detects which agents completed successfully and picks up where it left off
|
||||
- Each agent's progress is checkpointed via git commits, so resumed runs start from a clean, validated state
|
||||
|
||||
```bash
|
||||
# Start with a named workspace
|
||||
npx @keygraph/shannon start -u https://example.com -r /path/to/repo -w my-audit
|
||||
|
||||
# Resume the same workspace (skips completed agents)
|
||||
npx @keygraph/shannon start -u https://example.com -r /path/to/repo -w my-audit
|
||||
|
||||
# Resume an auto-named workspace from a previous run
|
||||
npx @keygraph/shannon start -u https://example.com -r /path/to/repo -w example-com_shannon-1771007534808
|
||||
|
||||
# List all workspaces and their status
|
||||
npx @keygraph/shannon workspaces
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>Clone and Build command equivalents</summary>
|
||||
|
||||
```bash
|
||||
./shannon start -u https://example.com -r /path/to/repo -w my-audit
|
||||
./shannon start -u https://example.com -r /path/to/repo -w my-audit
|
||||
./shannon start -u https://example.com -r /path/to/repo -w example-com_shannon-1771007534808
|
||||
./shannon workspaces
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
> [!NOTE]
|
||||
> The `URL` must match the original workspace URL when resuming. Shannon will reject mismatched URLs to prevent cross-target contamination.
|
||||
|
||||
### Credentials and Configuration
|
||||
|
||||
#### Credential Precedence
|
||||
|
||||
**Local mode** resolves credentials from:
|
||||
|
||||
1. **Environment variables** - `export ANTHROPIC_API_KEY=...`
|
||||
2. **`.env` file** - `./.env`
|
||||
|
||||
**npx mode** uses TOML instead of `.env`:
|
||||
|
||||
1. **Environment variables** - `export ANTHROPIC_API_KEY=...`
|
||||
2. **`~/.shannon/config.toml`** - created by `npx @keygraph/shannon setup`
|
||||
|
||||
Environment variables always win, so you can override saved config for a single session without editing files.
|
||||
|
||||
#### Configuration (Optional)
|
||||
|
||||
While you can run without a config file, creating one enables authenticated testing and customized analysis. Pass any configuration file path with `-c`.
|
||||
|
||||
##### Create Configuration File
|
||||
|
||||
Copy and modify the example configuration:
|
||||
|
||||
```bash
|
||||
cp configs/example-config.yaml ./my-app-config.yaml
|
||||
```
|
||||
|
||||
##### Basic Configuration Structure
|
||||
|
||||
```yaml
|
||||
# Optional: describe your target environment (max 500 chars)
|
||||
description: "Next.js e-commerce app on PostgreSQL. Local dev environment — .env files contain local-only credentials, not deployed to production."
|
||||
|
||||
authentication:
|
||||
login_type: form
|
||||
login_url: "https://your-app.com/login"
|
||||
credentials:
|
||||
username: "test@example.com"
|
||||
password: "yourpassword"
|
||||
totp_secret: "LB2E2RX7XFHSTGCK" # Optional for 2FA
|
||||
|
||||
login_flow:
|
||||
- "Type $username into the email field"
|
||||
- "Type $password into the password field"
|
||||
- "Click the 'Sign In' button"
|
||||
|
||||
success_condition:
|
||||
type: url_contains
|
||||
value: "/dashboard"
|
||||
|
||||
rules:
|
||||
avoid:
|
||||
- description: "AI should avoid testing logout functionality"
|
||||
type: path
|
||||
url_path: "/logout"
|
||||
|
||||
focus:
|
||||
- description: "AI should emphasize testing API endpoints"
|
||||
type: path
|
||||
url_path: "/api"
|
||||
```
|
||||
|
||||
Run with:
|
||||
|
||||
```bash
|
||||
npx @keygraph/shannon start -u https://example.com -r /path/to/repo -c ./my-app-config.yaml
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>Clone and Build command equivalents</summary>
|
||||
|
||||
```bash
|
||||
./shannon start -u https://example.com -r /path/to/repo -c ./my-app-config.yaml
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
#### TOTP Setup for 2FA
|
||||
|
||||
If your application uses two-factor authentication, simply add the TOTP secret to your config file. The AI will automatically generate the required codes during testing.
|
||||
|
||||
#### Subscription Plan Rate Limits
|
||||
|
||||
Anthropic subscription plans reset usage on a **rolling 5-hour window**. The default retry strategy (30-min max backoff) will exhaust retries before the window resets. Add this to your config:
|
||||
|
||||
```yaml
|
||||
pipeline:
|
||||
retry_preset: subscription # Extends max backoff to 6h, 100 retries
|
||||
max_concurrent_pipelines: 2 # Run 2 of 5 pipelines at a time (reduces burst API usage)
|
||||
```
|
||||
|
||||
`max_concurrent_pipelines` controls how many vulnerability pipelines run simultaneously (1-5, default: 5). Lower values reduce the chance of hitting rate limits but increase wall-clock time.
|
||||
|
||||
### AWS Bedrock
|
||||
|
||||
Shannon also supports [Amazon Bedrock](https://aws.amazon.com/bedrock/) instead of using an Anthropic API key.
|
||||
|
||||
#### Quick Setup
|
||||
|
||||
Run `npx @keygraph/shannon setup` and select **AWS Bedrock**. The wizard will prompt for your region, bearer token, and model IDs.
|
||||
|
||||
Or export env vars directly:
|
||||
|
||||
```bash
|
||||
export CLAUDE_CODE_USE_BEDROCK=1
|
||||
export AWS_REGION=us-east-1
|
||||
export AWS_BEARER_TOKEN_BEDROCK=your-bearer-token
|
||||
export ANTHROPIC_SMALL_MODEL=us.anthropic.claude-haiku-4-5-20251001-v1:0
|
||||
export ANTHROPIC_MEDIUM_MODEL=us.anthropic.claude-sonnet-4-6
|
||||
export ANTHROPIC_LARGE_MODEL=us.anthropic.claude-opus-4-6
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>Clone and Build: add to .env instead</summary>
|
||||
|
||||
```bash
|
||||
CLAUDE_CODE_USE_BEDROCK=1
|
||||
AWS_REGION=us-east-1
|
||||
AWS_BEARER_TOKEN_BEDROCK=your-bearer-token
|
||||
ANTHROPIC_SMALL_MODEL=us.anthropic.claude-haiku-4-5-20251001-v1:0
|
||||
ANTHROPIC_MEDIUM_MODEL=us.anthropic.claude-sonnet-4-6
|
||||
ANTHROPIC_LARGE_MODEL=us.anthropic.claude-opus-4-6
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
Shannon uses three model tiers: **small** (`claude-haiku-4-5-20251001`) for summarization, **medium** (`claude-sonnet-4-6`) for security analysis, and **large** (`claude-opus-4-6`) for deep reasoning. Set `ANTHROPIC_SMALL_MODEL`, `ANTHROPIC_MEDIUM_MODEL`, and `ANTHROPIC_LARGE_MODEL` to the Bedrock model IDs for your region.
|
||||
|
||||
### Google Vertex AI
|
||||
|
||||
Shannon also supports [Google Vertex AI](https://cloud.google.com/vertex-ai) instead of using an Anthropic API key.
|
||||
|
||||
Create a service account with the `roles/aiplatform.user` role in the [GCP Console](https://console.cloud.google.com/iam-admin/serviceaccounts), then download a JSON key file.
|
||||
|
||||
#### Quick Setup
|
||||
|
||||
Run `npx @keygraph/shannon setup` and select **Google Vertex AI**. The wizard will prompt for your region, project ID, service account key file path, and model IDs. The key file is securely copied to `~/.shannon/google-sa-key.json`.
|
||||
|
||||
Or export env vars directly:
|
||||
|
||||
```bash
|
||||
export CLAUDE_CODE_USE_VERTEX=1
|
||||
export CLOUD_ML_REGION=us-east5
|
||||
export ANTHROPIC_VERTEX_PROJECT_ID=your-gcp-project-id
|
||||
export GOOGLE_APPLICATION_CREDENTIALS=/path/to/your-sa-key.json
|
||||
export ANTHROPIC_SMALL_MODEL=claude-haiku-4-5@20251001
|
||||
export ANTHROPIC_MEDIUM_MODEL=claude-sonnet-4-6
|
||||
export ANTHROPIC_LARGE_MODEL=claude-opus-4-6
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>Clone and Build: add to .env instead</summary>
|
||||
|
||||
```bash
|
||||
CLAUDE_CODE_USE_VERTEX=1
|
||||
CLOUD_ML_REGION=us-east5
|
||||
ANTHROPIC_VERTEX_PROJECT_ID=your-gcp-project-id
|
||||
GOOGLE_APPLICATION_CREDENTIALS=./credentials/google-sa-key.json
|
||||
ANTHROPIC_SMALL_MODEL=claude-haiku-4-5@20251001
|
||||
ANTHROPIC_MEDIUM_MODEL=claude-sonnet-4-6
|
||||
ANTHROPIC_LARGE_MODEL=claude-opus-4-6
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
Set `CLOUD_ML_REGION=global` for global endpoints, or a specific region like `us-east5`. Some models may not be available on global endpoints — see the [Vertex AI Model Garden](https://console.cloud.google.com/vertex-ai/model-garden) for region availability.
|
||||
|
||||
### Custom Base URL
|
||||
|
||||
Shannon supports pointing the SDK at any Anthropic-compatible endpoint via `ANTHROPIC_BASE_URL`. For users who need proxy-based routing, the supported path is to use an LLM proxy such as [LiteLLM](https://github.com/BerriAI/litellm) configured to expose an Anthropic-compatible endpoint.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Only Claude models are officially supported.** Shannon's evaluations, internal testing, and agent harness are all optimized for Claude. Smaller or alternative models — including non-Claude models routed through a proxy — may not reliably follow Shannon's instructions or tool-use constraints, and are not officially supported. Use them at your own risk; results may be incomplete, inaccurate, or unstable.
|
||||
> [!TIP]
|
||||
> **Prefer to use a subscription instead of API credits?**
|
||||
>
|
||||
> The previously experimental `claude-code-router` integration is being removed in an upcoming release. If you currently rely on it, migrate to an Anthropic-compatible proxy such as LiteLLM before upgrading.
|
||||
> - **OpenAI Codex:** The latest version of Shannon supports ChatGPT Plus and Pro subscriptions. Follow the [OpenAI Codex subscription setup guide](docs/ai-providers.md#openai-codex-chatgpt-pluspro-subscription) to get started.
|
||||
> - **Claude Code:** The latest version of Shannon does not support Claude Code subscriptions. Follow the [Claude Code subscription setup guide](docs/ai-providers.md#claude-code-subscription) to use version `1.9.0`, which is the final release built on the Claude Agent SDK.
|
||||
|
||||
Run `npx @keygraph/shannon setup` and select **Custom Base URL**. The wizard will prompt for your endpoint URL, auth token, and optionally let you override the default model tiers.
|
||||
## Key Capabilities
|
||||
|
||||
Or export env vars directly:
|
||||
- **Proof-by-exploitation reports**: Shannon reports validated findings with reproducible proof-of-concept steps instead of speculative warnings.
|
||||
- **White-box attack planning**: Shannon uses source-code analysis to guide dynamic testing and focus on realistic attack paths.
|
||||
- **Autonomous execution**: Shannon launches reconnaissance, vulnerability analysis, exploitation, and report generation from a single command.
|
||||
- **Authenticated testing**: configuration files can describe login flows, test credentials, TOTP, email-based login flows, focus areas, and rules of engagement.
|
||||
- **OWASP-focused coverage**: Shannon targets exploitable Injection, XSS, SSRF, Broken Authentication, and Broken Authorization issues.
|
||||
- **Resumable workspaces**: Shannon can resume interrupted runs without re-running completed agents.
|
||||
|
||||
```bash
|
||||
export ANTHROPIC_BASE_URL=https://your-proxy.example.com
|
||||
export ANTHROPIC_AUTH_TOKEN=your-auth-token
|
||||
## Editions
|
||||
|
||||
# Optionally override model tiers (defaults are used if not set)
|
||||
export ANTHROPIC_SMALL_MODEL=claude-haiku-4-5-20251001
|
||||
export ANTHROPIC_MEDIUM_MODEL=claude-sonnet-4-6
|
||||
export ANTHROPIC_LARGE_MODEL=claude-opus-4-6
|
||||
```
|
||||
Shannon ships in two ways: **Shannon Open Source**, the pentester you run yourself, and the **Keygraph platform**, the commercial pentesting product that runs Shannon continuously and closes the full AppSec lifecycle around it.
|
||||
|
||||
<details>
|
||||
<summary>Clone and Build: add to .env instead</summary>
|
||||
**Shannon Open Source** (this repository) is the standalone pentester: a CLI agent for white-box, proof-by-exploitation testing of web applications and APIs you own or are authorized to test. It reads your source, plans attacks, executes real exploits, and reports only what it can prove. It runs on demand and is complete in that lane. You point it at a target, it pentests, it reports.
|
||||
|
||||
```bash
|
||||
ANTHROPIC_BASE_URL=https://your-proxy.example.com
|
||||
ANTHROPIC_AUTH_TOKEN=your-auth-token
|
||||
ANTHROPIC_SMALL_MODEL=claude-haiku-4-5-20251001
|
||||
ANTHROPIC_MEDIUM_MODEL=claude-sonnet-4-6
|
||||
ANTHROPIC_LARGE_MODEL=claude-opus-4-6
|
||||
```
|
||||
The **Keygraph platform** is the enterprise-ready, continuous pentesting product powered by Shannon. In the Keygraph platform, an enhanced build of Shannon runs continuously in a hardened, orchestrated environment fed by Keygraph's full code-analysis stack. Around that engine, the platform closes the entire vulnerability lifecycle, from analysis to a verified fix:
|
||||
|
||||
</details>
|
||||
- **Analyze**: Code Property Graph SAST, SCA with reachability, secrets, IaC, and container scanning. First-class detection in their own right, and context that sharpens Shannon's attacks.
|
||||
- **Prove**: autonomous black-box and source-aware white-box pentests turn candidate findings into proven, exploited vulnerabilities rather than speculative alerts.
|
||||
- **Manage**: one canonical record per vulnerability per repository, deduplicated across every source, with ownership, status, SLA tracking, dashboards, and bidirectional Jira sync.
|
||||
- **Remediate and verify**: patches written automatically and re-tested against the patched code before delivery, landing in your existing review workflow rather than auto-applied.
|
||||
- **Deploy**: self-hosted and air-gapped environments, strict bring-your-own-key model access, and customer-controlled LLM gateway patterns, so source, results, and model traffic stay inside your perimeter.
|
||||
|
||||
### Platform-Specific Instructions
|
||||
Shannon is the proof engine at the center of the Keygraph platform. Shannon Open Source gives you that engine to run yourself. The Keygraph platform surrounds Shannon with continuous analysis, finding management, remediation, verification, and enterprise deployment.
|
||||
|
||||
**For Windows:**
|
||||
| AppSec lifecycle stage | Shannon Open Source | Keygraph platform |
|
||||
| --- | --- | --- |
|
||||
| Analyze | Basic LLM pass-through of source to plan attacks | Actual code-base parsing, plus Code Property Graph, SAST, SCA with reachability, secrets, IaC, and containers |
|
||||
| Pentest and prove | White-box only, proof by exploitation | Enhanced white-box, plus black-box and grey-box modes, run continuously |
|
||||
| Manage findings | Local Markdown report | Canonical findings system: deduplication across sources, ownership, SLA, dashboards, Jira sync, and professional pentest-grade PDF reports |
|
||||
| Remediate and verify | Fix manually from the report, then re-run the full scan to verify | Automated remediation: opens a PR with the fix, verified by point re-test without re-running the full scan |
|
||||
| Deploy and operate | Local CLI and Docker worker | Self-hosted, air-gapped, BYOK, continuous, enterprise integrations |
|
||||
| License and support | AGPL-3.0, community | Commercial, supported |
|
||||
|
||||
Shannon on Windows is only supported via **WSL2**. Native Windows (including Git Bash) is not supported.
|
||||
|
||||
**Step 1: Ensure WSL 2**
|
||||
|
||||
```powershell
|
||||
wsl --install
|
||||
wsl --set-default-version 2
|
||||
|
||||
# Check installed distros
|
||||
wsl --list --verbose
|
||||
|
||||
# If you don't have a distro, install one (Ubuntu 24.04 recommended)
|
||||
wsl --list --online
|
||||
wsl --install Ubuntu-24.04
|
||||
|
||||
# If your distro shows VERSION 1, convert it to WSL 2:
|
||||
wsl --set-version <distro-name> 2
|
||||
```
|
||||
|
||||
See [WSL basic commands](https://learn.microsoft.com/en-us/windows/wsl/basic-commands) for reference.
|
||||
|
||||
**Step 2: Install Docker Desktop on Windows** and enable **WSL2 backend** under *Settings > General > Use the WSL 2 based engine*.
|
||||
|
||||
**Step 3: Run Shannon inside WSL** using either flow.
|
||||
|
||||
**npx inside WSL:**
|
||||
|
||||
```bash
|
||||
npx @keygraph/shannon setup
|
||||
npx @keygraph/shannon start -u https://your-app.com -r /path/to/your-repo
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>Clone and Build command equivalents</summary>
|
||||
|
||||
```bash
|
||||
git clone https://github.com/KeygraphHQ/shannon.git
|
||||
cd shannon
|
||||
cp .env.example .env # Edit with your API key
|
||||
./shannon start -u https://your-app.com -r /path/to/your-repo
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
To access the Temporal Web UI, run `ip addr` inside WSL to find your WSL IP address, then navigate to `http://<wsl-ip>:8233` in your Windows browser.
|
||||
|
||||
Windows Defender may flag exploit code in reports as false positives; see [Antivirus False Positives](#6-windows-antivirus-false-positives) below.
|
||||
|
||||
**For Linux (Native Docker):**
|
||||
|
||||
You may need to run commands with `sudo` depending on your Docker setup. If you encounter permission issues with output files, ensure your user has access to the Docker socket.
|
||||
|
||||
**For macOS:**
|
||||
|
||||
Works out of the box with Docker Desktop installed.
|
||||
|
||||
**Testing Local Applications:**
|
||||
|
||||
Docker containers cannot reach `localhost` on your host machine. Use `host.docker.internal` in place of `localhost`:
|
||||
|
||||
```bash
|
||||
npx @keygraph/shannon start -u http://host.docker.internal:3000 -r /path/to/repo
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>Clone and Build command equivalents</summary>
|
||||
|
||||
```bash
|
||||
./shannon start -u http://host.docker.internal:3000 -r /path/to/repo
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### Output and Results
|
||||
|
||||
All results are saved to the workspaces directory: `./workspaces/` (local mode) or `~/.shannon/workspaces/` (npx mode). Use `-o <path>` to copy deliverables to a custom output directory after the run completes.
|
||||
|
||||
Output structure:
|
||||
|
||||
```text
|
||||
workspaces/{hostname}_{sessionId}/
|
||||
├── session.json # Metrics and session data
|
||||
├── workflow.log # Human-readable workflow log
|
||||
├── agents/ # Per-agent execution logs
|
||||
├── prompts/ # Prompt snapshots for reproducibility
|
||||
└── deliverables/
|
||||
└── comprehensive_security_assessment_report.md # Final comprehensive security report
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sample Reports
|
||||
|
||||
Sample penetration test reports from industry-standard vulnerable applications:
|
||||
|
||||
#### **OWASP Juice Shop** • [GitHub](https://github.com/juice-shop/juice-shop)
|
||||
|
||||
*A notoriously insecure web application maintained by OWASP, designed to test a tool's ability to uncover a wide range of modern vulnerabilities.*
|
||||
|
||||
**Results**: Identified over 20 vulnerabilities across targeted OWASP categories in a single automated run.
|
||||
|
||||
**Notable findings**:
|
||||
|
||||
- Authentication bypass and full user database exfiltration via SQL injection
|
||||
- Privilege escalation to administrator through registration workflow bypass
|
||||
- IDOR vulnerabilities enabling access to other users' data and shopping carts
|
||||
- SSRF enabling internal network reconnaissance
|
||||
|
||||
[View Complete Report →](sample-reports/shannon-report-juice-shop.md)
|
||||
|
||||
---
|
||||
|
||||
#### **c{api}tal API** • [GitHub](https://github.com/Checkmarx/capital)
|
||||
|
||||
*An intentionally vulnerable API from Checkmarx, designed to test a tool's ability to uncover the OWASP API Security Top 10.*
|
||||
|
||||
**Results**: Identified approximately 15 critical and high-severity vulnerabilities.
|
||||
|
||||
**Notable findings**:
|
||||
|
||||
- Root-level command injection via denylist bypass in a hidden debug endpoint
|
||||
- Authentication bypass through a legacy, unpatched v1 API endpoint
|
||||
- Privilege escalation via Mass Assignment in the user profile update function
|
||||
- Zero false positives for XSS (correctly confirmed robust XSS defenses)
|
||||
|
||||
[View Complete Report →](sample-reports/shannon-report-capital-api.md)
|
||||
|
||||
---
|
||||
|
||||
#### **OWASP crAPI** • [GitHub](https://github.com/OWASP/crAPI)
|
||||
|
||||
*A modern, intentionally vulnerable API from OWASP, designed to benchmark a tool's effectiveness against the OWASP API Security Top 10.*
|
||||
|
||||
**Results**: Identified over 15 critical and high-severity vulnerabilities.
|
||||
|
||||
**Notable findings**:
|
||||
|
||||
- Authentication bypass via multiple JWT attacks (Algorithm Confusion, alg:none, weak key injection)
|
||||
- Full PostgreSQL database compromise via injection, exfiltrating user credentials
|
||||
- SSRF attack forwarding internal authentication tokens to an external service
|
||||
- Zero false positives for XSS (correctly identified robust XSS defenses)
|
||||
|
||||
[View Complete Report →](sample-reports/shannon-report-crapi.md)
|
||||
|
||||
---
|
||||
|
||||
## Benchmark
|
||||
|
||||
Shannon Lite scored **96.15% (100/104 exploits)** on a hint-free, source-aware variant of the XBOW security benchmark.
|
||||
|
||||
**[Full results with detailed agent logs and per-challenge pentest reports →](https://github.com/KeygraphHQ/xbow-validation-benchmarks/blob/main/xben-benchmark-results/)**
|
||||
|
||||
---
|
||||
Learn more on the [Keygraph website](https://keygraph.io), read the [Keygraph platform technical overview](docs/keygraph-platform.md), start a free trial or book a [demo](https://cal.com/team/keygraph/shannon-pro), or contact [shannon@keygraph.io](mailto:shannon@keygraph.io).
|
||||
|
||||
## Architecture
|
||||
|
||||
Shannon uses a multi-agent architecture that combines white-box source code analysis with dynamic exploitation across five phases:
|
||||
Shannon uses a multi-agent workflow that combines source-code analysis with live exploitation:
|
||||
|
||||
```
|
||||
```text
|
||||
┌──────────────────────┐
|
||||
│ Pre-Reconnaissance │
|
||||
│ (nmap, subfinder, │
|
||||
│ whatweb, code scan) │
|
||||
│ (source code scan) │
|
||||
└──────────┬───────────┘
|
||||
│
|
||||
▼
|
||||
@@ -746,145 +175,79 @@ Shannon uses a multi-agent architecture that combines white-box source code anal
|
||||
└──────────────────────┘
|
||||
```
|
||||
|
||||
### Architectural Overview
|
||||
At a high level:
|
||||
|
||||
Shannon uses Anthropic's Claude Agent SDK as its reasoning engine within a multi-agent architecture. The system combines white-box source code analysis with black-box dynamic exploitation, managed by an orchestrator across five phases. The architecture is designed for minimal false positives through a "no exploit, no report" policy.
|
||||
- **Pre-reconnaissance** identifies frameworks, entry points, data flows, and likely attack surfaces from the repository.
|
||||
- **Reconnaissance** explores the live application and correlates runtime behavior with code-level context.
|
||||
- **Vulnerability analysis** runs specialized agents for Injection, XSS, SSRF, Authentication, and Authorization.
|
||||
- **Exploitation** attempts real proof-of-concept attacks and discards hypotheses that cannot be proven.
|
||||
- **Reporting** compiles validated findings, evidence, and remediation guidance into a final Markdown report.
|
||||
|
||||
Each scan runs in its own ephemeral Docker container (`docker run --rm`) with a per-invocation Temporal task queue, enabling concurrent scans with different target repositories.
|
||||
Each scan runs in an ephemeral Docker container with an isolated workspace and per-invocation orchestration.
|
||||
|
||||
---
|
||||
## Documentation
|
||||
|
||||
#### **Phase 1: Pre-Reconnaissance**
|
||||
Use these guides for operational detail:
|
||||
|
||||
External scanning using nmap, subfinder, and whatweb to fingerprint the target's infrastructure and tech stack. Simultaneously performs source code analysis to identify the application framework, entry points, and potential attack surface from the codebase.
|
||||
| Guide | Use it for |
|
||||
| --- | --- |
|
||||
| [Source build and CLI commands](docs/development.md) | Cloning, building, common commands, output paths, and local development. |
|
||||
| [Configuration](docs/configuration.md) | Authenticated testing, login flows, rules of engagement, and report filters. |
|
||||
| [AI providers](docs/ai-providers.md) | Selecting the model, the supported providers (Anthropic, OpenAI, xAI, AWS Bedrock, and any other Pi-supported provider), and custom gateways. |
|
||||
| [Platforms and networking](docs/platforms.md) | Windows/WSL2, Linux, macOS, Docker networking, local apps, and custom hostnames. |
|
||||
| [Workspaces and resuming](docs/workspaces.md) | Naming workspaces, resuming interrupted scans, and workspace storage. |
|
||||
| [Safety and limitations](docs/safety.md) | Authorized-use requirements, non-production guidance, mutative effects, cost, and model caveats. |
|
||||
| [Coverage and roadmap](docs/coverage-roadmap.md) | Current vulnerability coverage and planned work. |
|
||||
| [Keygraph platform](docs/keygraph-platform.md) | The continuous, agentic pentesting platform: code analysis, black-box and white-box testing, finding management, remediation, verification, and enterprise deployment. |
|
||||
|
||||
#### **Phase 2: Reconnaissance**
|
||||
## Safety, Scope, and Limitations
|
||||
|
||||
Builds a comprehensive attack surface map from the pre-recon findings. Shannon performs live application exploration via browser automation to correlate code-level insights with real-world behavior, producing a detailed map of all entry points, API endpoints, and authentication mechanisms.
|
||||
Shannon is not a passive scanner. Its exploitation agents can create users, submit forms, mutate application state, trigger outbound requests, and otherwise affect the target system. Use sandboxed, staging, or local development environments with disposable data.
|
||||
|
||||
#### **Phase 3: Vulnerability Analysis**
|
||||
You are responsible for using Shannon legally and ethically. Do not point Shannon at systems, repositories, or applications you do not own or do not have explicit authorization to test.
|
||||
|
||||
To maximize efficiency, this phase operates in parallel with 5 concurrent agents. Using the reconnaissance data, specialized agents for each OWASP category (injection, XSS, auth, authz, SSRF) hunt for potential flaws in parallel. For vulnerabilities like Injection and SSRF, agents perform a structured data flow analysis, tracing user input to dangerous sinks. This phase produces a key deliverable: a list of **hypothesized exploitable paths** that are passed on for validation.
|
||||
Important limitations:
|
||||
|
||||
#### **Phase 4: Exploitation**
|
||||
|
||||
Continuing the parallel workflow to maintain speed, this phase is dedicated entirely to turning hypotheses into proof. Dedicated exploit agents receive the hypothesized paths and attempt to execute real-world attacks using browser automation, command-line tools, and custom scripts. This phase enforces a strict **"No Exploit, No Report"** policy: if a hypothesis cannot be successfully exploited to demonstrate impact, it is discarded as a false positive.
|
||||
|
||||
#### **Phase 5: Reporting**
|
||||
|
||||
The final phase compiles all validated findings into a professional, actionable report. An agent consolidates the reconnaissance data and the successful exploit evidence, cleaning up any noise or hallucinated artifacts. Only verified vulnerabilities are included, complete with **reproducible, copy-and-paste Proof-of-Concepts**, delivering a final pentest-grade report focused exclusively on proven risks.
|
||||
|
||||
|
||||
## Coverage and Roadmap
|
||||
|
||||
For detailed information about Shannon's security testing coverage and development roadmap, see our [Coverage and Roadmap](./COVERAGE.md) documentation.
|
||||
|
||||
## Disclaimers
|
||||
|
||||
### Important Usage Guidelines & Disclaimers
|
||||
|
||||
Please review the following guidelines carefully before using Shannon (Lite). As a user, you are responsible for your actions and assume all liability.
|
||||
|
||||
#### **1. Potential for Mutative Effects & Environment Selection**
|
||||
|
||||
This is not a passive scanner. The exploitation agents are designed to **actively execute attacks** to confirm vulnerabilities. This process can have mutative effects on the target application and its data.
|
||||
|
||||
> [!WARNING]
|
||||
> **DO NOT run Shannon on production environments.**
|
||||
>
|
||||
> - It is intended exclusively for use on sandboxed, staging, or local development environments where data integrity is not a concern.
|
||||
> - Potential mutative effects include, but are not limited to: creating new users, modifying or deleting data, compromising test accounts, and triggering unintended side effects from injection attacks.
|
||||
> - **For maximum security and isolation, run Shannon inside a virtual machine (VM).** This confines any side effects from exploitation — including unexpected outbound traffic, file writes from agent tooling, or interactions with local services — to a disposable environment.
|
||||
|
||||
#### **2. Legal & Ethical Use**
|
||||
|
||||
Shannon is designed for legitimate security auditing purposes only.
|
||||
|
||||
> [!CAUTION]
|
||||
> **You must have explicit, written authorization** from the owner of the target system before running Shannon.
|
||||
>
|
||||
> Unauthorized scanning and exploitation of systems you do not own is illegal and can be prosecuted under laws such as the Computer Fraud and Abuse Act (CFAA). Keygraph is not responsible for any misuse of Shannon.
|
||||
|
||||
#### **3. LLM & Automation Caveats**
|
||||
|
||||
- **Verification is Required**: While significant engineering has gone into our "proof-by-exploitation" methodology to eliminate false positives, the underlying LLMs can still generate hallucinated or weakly-supported content in the final report. **Human oversight is essential** to validate the legitimacy and severity of all reported findings.
|
||||
- **Model Support**: Shannon is officially supported only with **Claude models**. Our evaluations, internal testing, and agent harness are all optimized for Claude. Smaller or alternative models — including non-Claude models routed through a proxy — may not reliably follow Shannon's instructions or tool-use constraints, and are not officially supported.
|
||||
- **Comprehensiveness**: The analysis in Shannon Lite may not be exhaustive due to the inherent limitations of LLM context windows. For a more comprehensive, graph-based analysis of your entire codebase, **Shannon Pro** leverages its advanced data flow analysis engine to ensure deeper and more thorough coverage.
|
||||
|
||||
#### **4. Scope of Analysis**
|
||||
|
||||
- **Targeted Vulnerabilities**: The current version of Shannon Lite specifically targets the following classes of *exploitable* vulnerabilities:
|
||||
- Broken Authentication & Authorization
|
||||
- Injection
|
||||
- Cross-Site Scripting (XSS)
|
||||
- Server-Side Request Forgery (SSRF)
|
||||
- **What Shannon Lite Does Not Cover**: This list is not exhaustive of all potential security risks. Shannon Lite's "proof-by-exploitation" model means it will not report on issues it cannot actively exploit, such as vulnerable third-party libraries or insecure configurations. These types of deep static-analysis findings are a core focus of the advanced analysis engine in **Shannon Pro**.
|
||||
|
||||
#### **5. Cost & Performance**
|
||||
|
||||
- **Time**: As of the current version, a full test run typically takes **1 to 1.5 hours** to complete.
|
||||
- **Cost**: Running the full test using Anthropic's Claude 4.5 Sonnet model may incur costs of approximately **$50 USD**. Costs vary based on model pricing and application complexity.
|
||||
|
||||
#### **6. Windows Antivirus False Positives**
|
||||
|
||||
Windows Defender may flag files in `xben-benchmark-results/` or `deliverables/` as malware. These are false positives caused by exploit code in the reports. Add an exclusion for the Shannon directory in Windows Defender, or use Docker/WSL2.
|
||||
|
||||
#### **7. Security Considerations**
|
||||
|
||||
Shannon Lite is designed for scanning repositories and applications you own or have explicit permission to test. Do not point it at untrusted or adversarial codebases. Like any AI-powered tool that reads source code, Shannon Lite is susceptible to prompt injection from content in the scanned repository.
|
||||
- Shannon Open Source focuses on actively exploitable issues such as Injection, XSS, SSRF, Broken Authentication, and Broken Authorization. Broader static-analysis coverage, including vulnerable dependencies and insecure configurations, is delivered through the Keygraph platform.
|
||||
- Findings still require human review. LLM-generated reports can contain weakly supported or incorrect details.
|
||||
- Shannon is officially supported with Claude models. Smaller, alternative, or proxied non-Claude models may be incomplete or unstable.
|
||||
- A full run can take roughly 1 to 1.5 hours and may incur LLM API costs depending on model pricing and application complexity.
|
||||
- Do not scan untrusted or adversarial codebases. AI-powered tools that read source code can be exposed to prompt injection.
|
||||
|
||||
Read the full [Safety and limitations](docs/safety.md) guide before running Shannon in a new environment.
|
||||
|
||||
## License
|
||||
|
||||
Shannon Lite is released under the [GNU Affero General Public License v3.0 (AGPL-3.0)](LICENSE).
|
||||
Shannon Open Source is licensed under the [GNU Affero General Public License v3.0](LICENSE).
|
||||
|
||||
Shannon is open source (AGPL v3). This license allows you to:
|
||||
- Use it freely for all internal security testing.
|
||||
- Modify the code privately for internal use without sharing your changes.
|
||||
Commercial and enterprise licensing is available for organizations that need different license terms, commercial support, private redistribution, managed-service use, or broader deployment options, including the Keygraph platform.
|
||||
|
||||
The AGPL's sharing requirements primarily apply to organizations offering Shannon as a public or managed service (such as a SaaS platform). In those specific cases, any modifications made to the core software must be open-sourced.
|
||||
For commercial licensing, contact [shannon@keygraph.io](mailto:shannon@keygraph.io).
|
||||
|
||||
## About Keygraph
|
||||
|
||||
## Community & Support
|
||||
**Keygraph** is the company behind Shannon. It also builds the **Keygraph platform**, the commercial agentic pentesting product that closes the full AppSec lifecycle and runs an enhanced build of Shannon as its pentesting engine.
|
||||
|
||||
### Community Resources
|
||||
## Community and Support
|
||||
|
||||
**1:1 Office Hours** — Thursdays, two time zones
|
||||
Book a free 15-min session for hands-on help with bugs, deployments, or config questions.
|
||||
→ US/EU: 10:00 AM PT | Asia: 2:00 PM IST
|
||||
→ [Book a slot](https://cal.com/george-flores-keygraph/shannon-community-office-hours)
|
||||
**Community office hours** are available for hands-on help with bugs, deployments, and configuration questions.
|
||||
|
||||
[Join our Discord](https://discord.gg/cmctpMBXwE) to ask questions, share feedback, and connect with other Shannon users.
|
||||
- US/EU: Thursday, 10:00 AM PT
|
||||
- Asia: Thursday, 2:00 PM IST
|
||||
- [Book a slot](https://cal.com/george-flores-keygraph/shannon-community-office-hours)
|
||||
|
||||
**Contributing:** At this time, we're not accepting external code contributions (PRs).
|
||||
Issues are welcome for bug reports and feature requests.
|
||||
[Join Discord](https://discord.gg/cmctpMBXwE) to ask questions, share feedback, and connect with other Shannon users.
|
||||
|
||||
- **Report bugs** via [GitHub Issues](https://github.com/KeygraphHQ/shannon/issues)
|
||||
- **Suggest features** in [Discussions](https://github.com/KeygraphHQ/shannon/discussions)
|
||||
At this time, Keygraph is not accepting external code contributions. Issues are welcome for bug reports and feature requests:
|
||||
|
||||
### Stay Connected
|
||||
- [Report bugs](https://github.com/KeygraphHQ/shannon/issues)
|
||||
- [Suggest features](https://github.com/KeygraphHQ/shannon/discussions)
|
||||
|
||||
- **Twitter**: [@KeygraphHQ](https://twitter.com/KeygraphHQ)
|
||||
- **LinkedIn**: [Keygraph](https://linkedin.com/company/keygraph)
|
||||
- **Website**: [keygraph.io](https://keygraph.io)
|
||||
Stay connected:
|
||||
|
||||
|
||||
|
||||
## Get in Touch
|
||||
|
||||
### Shannon Pro
|
||||
|
||||
Shannon Pro is Keygraph's all-in-one AppSec platform. For organizations that need unified SAST, SCA, and autonomous pentesting with static-dynamic correlation, CI/CD integration, or self-hosted deployment, see the [Shannon Pro technical overview](./SHANNON-PRO.md).
|
||||
|
||||
<p align="center">
|
||||
<a href="https://cal.com/team/keygraph/shannon-pro" target="_blank">
|
||||
<img src="./assets/Demo_Button.png" height="40" alt="Shannon Pro Inquiry">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
**Email**: [shannon@keygraph.io](mailto:shannon@keygraph.io)
|
||||
|
||||
---
|
||||
- [Keygraph website](https://keygraph.io)
|
||||
- [Twitter/X: @KeygraphHQ](https://twitter.com/KeygraphHQ)
|
||||
- [LinkedIn: Keygraph](https://linkedin.com/company/keygraph)
|
||||
|
||||
<p align="center">
|
||||
<b>Built by <a href="https://keygraph.io">Keygraph</a></b>
|
||||
|
||||
-256
@@ -1,256 +0,0 @@
|
||||
# Shannon Pro
|
||||
|
||||
Shannon Pro is Keygraph's comprehensive AppSec platform, combining SAST, SCA, secrets scanning, business logic security testing, and autonomous pentesting in a single correlated workflow:
|
||||
|
||||
- **Agentic static analysis:** CPG-based data flow, SCA with reachability, secrets detection, business logic security testing
|
||||
- **Static-dynamic correlation:** static findings are fed into the dynamic pipeline and exploited against the running application, so every reported vulnerability has a working proof-of-concept
|
||||
- **Enterprise deployment:** self-hosted runner (code and LLM calls never leave customer infrastructure), CI/CD integration, GitHub PR scanning, service boundary detection
|
||||
|
||||
The platform cross-references static and dynamic results to eliminate false positives, prioritize by proven exploitability, and produce pentest-grade reports with reproducible proof-of-concept exploits for every finding.
|
||||
|
||||
---
|
||||
|
||||
## The Problem: Fragmented AppSec and Alert Fatigue
|
||||
|
||||
Modern engineering teams face two compounding security challenges. First, traditional static analysis tools (SCA, SAST, and secrets scanners) operate without context, producing high volumes of false positives that erode developer trust. Second, penetration testing remains an expensive, periodic exercise that cannot keep pace with continuous deployment. The result is a fragmented security posture where static tools cry wolf, dynamic assessments arrive too late, and engineering teams treat security as compliance theater rather than a source of genuine protection.
|
||||
|
||||
Shannon Pro addresses both problems in a single platform by replacing pattern-based static analysis with LLM-powered reasoning and augmenting it with a fully autonomous AI pentester that validates findings at runtime. The platform supports a self-hosted runner model where source code and LLM interactions never leave the customer's infrastructure.
|
||||
|
||||
---
|
||||
|
||||
## Platform Architecture Overview
|
||||
|
||||
Shannon Pro operates as a two-stage pipeline: agentic static analysis of the codebase, followed by autonomous dynamic penetration testing against the running application. Findings from both stages are correlated to produce a unified, high-confidence result set.
|
||||
|
||||
---
|
||||
|
||||
# Stage 1: Agentic Static Analysis (AppSec)
|
||||
|
||||
The static analysis stage performs comprehensive code-level security assessment using LLM-powered agents. It comprises five core capabilities: SAST (data flow analysis, point issue detection, and business logic security testing), SCA with reachability analysis, and secrets detection.
|
||||
|
||||
## SAST: Data Flow Analysis
|
||||
|
||||
Shannon Pro transforms the target codebase into a Code Property Graph (CPG) that combines the abstract syntax tree, control flow graph, and program dependence graph into a unified structure. Nodes represent program constructs (such as expressions, statements, and declarations), and edges capture syntactic, control-flow, and data-dependence relationships. The analysis proceeds in three phases.
|
||||
|
||||
### Phase 1: Source and Sink Extraction
|
||||
|
||||
For each vulnerability type, the system identifies sources (where untrusted data enters, such as user input, API requests, and file reads) and sinks (where that data could cause harm, such as SQL queries, command execution, and file writes). Deterministic pattern matching establishes a baseline, then an AI agent analyzes the codebase to discover sources and sinks that generic patterns miss, including custom input handlers and framework-specific patterns unique to the target codebase. A filtering agent removes irrelevant results such as test fixtures and mock data.
|
||||
|
||||
### Phase 2: Path Tracing with Contextual Reasoning
|
||||
|
||||
This is where Shannon Pro's approach differs fundamentally from traditional SAST. The system traces backward from each sink toward potential sources. At every node along the path, an LLM analyzes whether sanitization is applied at that exact point and whether that sanitization is sufficient for this specific vulnerability in this specific context.
|
||||
|
||||
The key insight is that security fixes are context-dependent. A function that makes data safe for one SQL query might not protect a different query. A custom sanitizer that a team wrote will not be recognized by pattern-based tools. Traditional tools rely on a hard-coded list of safe functions; Shannon Pro reasons about what the code is actually doing, validating whether the specific sanitization at each node actually addresses the specific risk at the specific sink.
|
||||
|
||||
### Phase 3: Path Validation
|
||||
|
||||
Each identified vulnerability path is validated by an autonomous Claude agent that confirms control flow correctness (is the path actually executable?) and logic correctness (is the vulnerability real or a false positive?). Agents produce confidence scores, and only validated paths proceed to reporting.
|
||||
|
||||
## SAST: Point Issue Detection
|
||||
|
||||
Point issues are vulnerabilities where security depends on what is happening at a single location rather than across a data flow path. The system pre-filters and organizes files, then feeds each one to an LLM to identify issues such as:
|
||||
|
||||
- Use of weak encryption algorithms
|
||||
- Hardcoded credentials or API keys
|
||||
- Insecure configuration settings (e.g., debug mode enabled in production)
|
||||
- Missing security headers
|
||||
- Weak random number generation
|
||||
- Disabled certificate validation
|
||||
- Overly permissive CORS settings
|
||||
|
||||
## SAST: Business Logic Security Testing
|
||||
|
||||
Traditional security testing tools cannot reason about application-specific correctness properties. Pattern-based scanners look for known vulnerability signatures; conventional fuzzers (AFL, libFuzzer) find crashes and memory errors through input mutation but operate without awareness of business semantics. Neither can determine whether a syntactically valid response actually violates the application's security model. Shannon Pro bridges this gap with automated invariant-based security testing: LLM agents that understand the business semantics of the codebase, automatically discover application-specific invariants, and generate targeted test scenarios that verify whether those invariants hold under adversarial conditions. This approach draws from property-based testing methodology, applied specifically to security-relevant business logic.
|
||||
|
||||
### Why Business Logic Bugs Are Missed
|
||||
|
||||
Pattern-based scanners and traditional SAST are structurally incapable of finding business logic vulnerabilities. These bugs do not involve malformed input reaching a dangerous sink. Instead, they involve legitimate operations that violate unstated rules about how the application should behave. A multi-tenant SaaS platform assumes Organization A's data is never accessible to Organization B. An e-commerce application assumes a checkout total cannot go negative. A healthcare platform assumes a patient record is only visible to the assigned provider. These invariants are implicit in the business domain, never encoded in a generic vulnerability database, and invisible to any tool that does not understand what the application is supposed to do.
|
||||
|
||||
### How It Works
|
||||
|
||||
Shannon Pro's business logic security testing operates in four phases:
|
||||
|
||||
**Phase 1: Invariant Discovery.** An LLM agent performs a deep semantic analysis of the codebase, examining data models, API endpoints, authorization logic, and domain-specific patterns. Rather than looking for known vulnerability signatures, the agent reasons about the application's intended behavior and derives business logic invariants: rules that must hold for the application to be secure. For a multi-tenant platform, the agent identifies invariants such as "document access must verify that the document belongs to the requesting user's organization." For a financial application, it might identify "a transfer cannot be initiated where the source and destination accounts have the same owner but different privilege levels." These are security properties that no generic scanner can know about because they are unique to each application.
|
||||
|
||||
**Phase 2: Fuzzer Generation.** For each discovered invariant, a second agent generates a targeted fuzzer: a test scenario designed to violate the invariant. These are not random inputs. The agent reads the code, understands the expected authorization checks (or lack thereof), and constructs specific adversarial scenarios. For an authorization invariant, the fuzzer might construct a request where a user from one organization references a resource belonging to another organization. For a state machine invariant, it might craft a sequence of API calls that skips a required approval step.
|
||||
|
||||
**Phase 3: Violation Detection.** The generated fuzzers are executed against a stubbed test environment that replicates the application's business logic with mocked dependencies. When a fuzzer succeeds, meaning the invariant does not hold, the system has identified a confirmed business logic vulnerability. The agent traces the violation back to the specific code location where the missing check or flawed logic exists.
|
||||
|
||||
**Phase 4: Exploit Synthesis.** For every confirmed violation, the system produces a full proof-of-concept exploit with step-by-step reproduction instructions, the specific API calls or user actions required, the observed versus expected behavior, and the security impact.
|
||||
|
||||
### Real-World Example: Cross-Tenant Data Access (CWE-639)
|
||||
|
||||
In a production multi-tenant platform, Shannon Pro's business logic security testing discovered a critical Insecure Direct Object Reference (IDOR) vulnerability that no traditional scanner would detect.
|
||||
|
||||
**Invariant discovered:** Document access must verify that the document belongs to the requesting user's organization.
|
||||
|
||||
**Fuzzer generated:** The agent extracted the `GetDocument` handler logic into a stubbed test environment, mocking the database layer to return documents with known organization IDs. The fuzzer generated combinations of requesting user organizations and document owner organizations, testing whether the handler enforces organizational boundaries.
|
||||
|
||||
**Violation confirmed:** An attacker from Organization B can access documents belonging to Organization A by calling the `GetDocument` endpoint with the victim's document ID, without any authorization check preventing cross-organization access.
|
||||
|
||||
**Exploit synthesized:**
|
||||
|
||||
1. Attacker authenticates as a user in Organization B and obtains valid credentials.
|
||||
2. Attacker enumerates or guesses a document ID belonging to Organization A (e.g., through sequential ID guessing, leaked references, or predictable UUID patterns).
|
||||
3. Attacker calls `GET /api/document?document_id=victim-doc-123` with their Organization B credentials.
|
||||
4. The system retrieves the document without verifying organizational ownership.
|
||||
5. The system returns HTTP 200 with the complete document contents, including sensitive data belonging to Organization A.
|
||||
|
||||
**Impact:** Complete breach of multi-tenant data isolation. Attackers can read all documents across all organizations, potentially exposing confidential business data, PII, trade secrets, and compliance-sensitive information.
|
||||
|
||||
**Expected behavior:** HTTP 403 Forbidden with an error message indicating access is denied, or HTTP 404 Not Found to avoid leaking document existence.
|
||||
|
||||
This class of vulnerability, missing authorization at an organizational boundary, is invisible to pattern-based tools because the code is syntactically correct, uses no dangerous functions, and follows normal request-handling patterns. Only a system that understands the business invariant ("documents belong to organizations, and access must respect that boundary") can identify the violation.
|
||||
|
||||
### What This Means
|
||||
|
||||
Business logic security testing extends Shannon Pro's coverage beyond the limits of traditional static and dynamic analysis. Data flow analysis catches injection, XSS, and other input-driven vulnerabilities. Point issue detection catches configuration and cryptographic weaknesses. Business logic security testing catches the authorization failures, state machine violations, and domain-specific logic errors that represent some of the most severe and most commonly missed vulnerabilities in production applications. Together, these three capabilities provide comprehensive SAST coverage across the full vulnerability spectrum.
|
||||
|
||||
## SCA with Reachability Analysis
|
||||
|
||||
Traditional SCA flags any library with a known CVE regardless of whether the vulnerable function is called or even reachable. Shannon Pro goes further with a four-step reachability process:
|
||||
|
||||
1. An AI agent researches each CVE to identify the exact vulnerable function, framework, or conditions.
|
||||
2. For framework-level issues, the system checks whether the application actually uses the affected framework in practice.
|
||||
3. For function-level issues, the CPG is queried to extract nodes where the vulnerable function is used. If no nodes are found, the vulnerability is marked as not reachable.
|
||||
4. If nodes are found, execution flow is traced from entry points (main functions, API endpoints) to determine whether a path exists. Proven executable vulnerabilities are flagged; code that uses the function but is not currently callable is marked as likely reachable.
|
||||
|
||||
## Secrets Detection
|
||||
|
||||
Shannon Pro combines three approaches to secrets scanning. Standard regex-based pattern matching catches known formats (AWS keys, API tokens, etc.). Simultaneously, during the point issue detection phase, LLM-based detection catches secrets that standard patterns miss, such as dynamically constructed credentials, custom credential formats, and obfuscated tokens. The LLM layer also filters out test data, placeholders, and documentation examples that regex scanners frequently flag as false positives.
|
||||
|
||||
For discovered secrets, Shannon Pro performs liveness validation: an agent determines the API context for each credential and attempts to authenticate against the corresponding service. This distinguishes active, exploitable secrets from revoked or rotated credentials, ensuring teams focus remediation effort on secrets that represent real exposure. Liveness checks use read-only API calls (e.g., identity verification endpoints) to avoid triggering side effects or account lockouts, and in the self-hosted runner deployment, all validation occurs within the customer's network.
|
||||
|
||||
## Boundary Analysis
|
||||
|
||||
For large-scale or monorepo architectures, Shannon Pro's boundary analysis capability allows organizations to scope scans to specific services or portions of the codebase. An agent analyzes the repository and identifies logical boundaries (by service, frontend vs. backend, microservice, etc.). Users review, confirm, and optionally edit the detected boundaries, then select which to include in a scan. Findings are tagged by boundary, enabling clear routing to the responsible team.
|
||||
|
||||
## False Positive Tagging
|
||||
|
||||
Any finding can be marked as a false positive. On subsequent scans, the same finding will be flagged as likely false positive, so teams do not repeatedly triage issues they have already dismissed.
|
||||
|
||||
---
|
||||
|
||||
# Stage 2: Autonomous Dynamic Penetration Testing
|
||||
|
||||
Shannon Pro's dynamic testing pipeline mirrors the workflow of a professional human penetration tester, implemented as a multi-agent system powered by the Anthropic Claude Agent SDK. The system operates through five phases using 13 specialized agents.
|
||||
|
||||
## Execution Model
|
||||
|
||||
Phases 1 and 2 (reconnaissance) run sequentially. Phases 3 and 4 (vulnerability analysis and exploitation) run as pipelined parallel: each vulnerability/exploit pair is independent. When a vulnerability agent finishes for a given attack domain, the corresponding exploit agent starts immediately, even if other vulnerability agents are still running. Phase 5 (reporting) runs after all exploitation is complete.
|
||||
|
||||
## Phase 1: Pre-Reconnaissance
|
||||
|
||||
Pure static analysis of the source code without browser interaction. The pre-recon agent maps the application architecture, identifies security-relevant components (authentication systems, database access patterns, input handling), and catalogs the complete attack surface from a code perspective. Outputs include a comprehensive catalog of all network-accessible entry points, technology stack details, authentication and authorization mechanisms, and all identified sinks (XSS, SSRF, injection) with their locations.
|
||||
|
||||
This phase informs everything downstream. If the codebase uses an ORM with parameterized queries everywhere, the injection agents know to focus elsewhere.
|
||||
|
||||
## Phase 2: Reconnaissance
|
||||
|
||||
Bridges static and dynamic analysis using browser automation. The recon agent correlates code findings with the live application, validating that endpoints actually exist, mapping authentication flows, inventorying input vectors (URL parameters, POST fields, headers, cookies), and documenting the real authorization architecture. This phase may also integrate with infrastructure discovery tools including Nmap, Subfinder, and WhatWeb for network perimeter mapping.
|
||||
|
||||
## Phase 3: Vulnerability Analysis
|
||||
|
||||
Five parallel agents, each focused on a distinct attack domain, combine code analysis with runtime probing to generate exploitation hypotheses. Each agent produces a detailed analysis deliverable and an exploitation queue -- a structured JSON file listing specific vulnerabilities to attempt, including the type, location, method, parameter, code evidence, and a suggested initial payload.
|
||||
|
||||
The five vulnerability analysis agents and their methodologies:
|
||||
|
||||
| Agent | Approach | What It Analyzes |
|
||||
| --- | --- | --- |
|
||||
| **Injection** | Source -> Sink taint | User input reaching SQL, command, file, template, or deserialization sinks without adequate sanitization |
|
||||
| **XSS** | Sink -> Source taint | HTML rendering contexts (innerHTML, document.write, event handlers, eval) reachable from user input without proper encoding |
|
||||
| **SSRF** | Sink -> Source taint | HTTP client libraries, raw sockets, URL openers, and headless browsers callable with user-controlled URLs |
|
||||
| **Auth** | Guard validation | Missing security controls: rate limiting, session management, token entropy, password hashing, HSTS, SSO/OAuth configuration |
|
||||
| **Authz** | Guard validation | Missing authorization checks before side effects: horizontal (ownership), vertical (role/capability), and context/workflow violations |
|
||||
|
||||
If a vulnerability agent's exploitation queue is empty for a given attack domain, the corresponding exploit agent is skipped entirely, saving significant time and cost.
|
||||
|
||||
## Phase 4: Exploitation
|
||||
|
||||
Five parallel exploit agents consume the exploitation queues and attempt to verify each hypothesis using full Playwright browser automation. Agents can navigate to endpoints, fill forms with crafted payloads, submit requests, observe responses, take screenshots, and chain multiple requests together to validate complex attack sequences.
|
||||
|
||||
**Core principle: POC or it didn't happen.** Shannon Pro never reports a vulnerability without a working proof-of-concept exploit. Exploitation agents classify each finding as EXPLOITED, POTENTIAL, or FALSE POSITIVE. Only EXPLOITED findings (with concrete evidence) make it to the final report. POTENTIAL findings are programmatically stripped before reporting, giving agents a designated space to log uncertain observations without polluting the deliverable.
|
||||
|
||||
## Phase 5: Reporting
|
||||
|
||||
A reporting agent synthesizes all evidence files into a pentest-grade executive report. The agent only sees confirmed findings (evidence files from Phase 4), never raw hypotheses. It de-duplicates findings, assesses severity, and provides remediation guidance. Every reported vulnerability includes reproducible steps and copy-and-paste commands for verification.
|
||||
|
||||
---
|
||||
|
||||
# Static-Dynamic Correlation
|
||||
|
||||
Shannon Pro's distinguishing capability is the correlation between its static and dynamic analysis stages.
|
||||
|
||||
## How AppSec Feeds Into Dynamic Testing
|
||||
|
||||
After static analysis completes, findings go through an enrichment phase that adds priority, confidence, and application context. CWEs are mapped to Shannon's five attack domains using a best-fit heuristic. Where a CWE maps to multiple domains (e.g., CWE-918 spans both SSRF and injection contexts), the finding is routed to the most exploitation-relevant agent. CWEs that do not map cleanly to any attack domain, such as certain business logic classes, are routed directly to the exploitation queue with their static analysis context preserved rather than forced into an ill-fitting category. Secrets, data flow findings, point issues, and business logic security testing violations are sent to Shannon's exploitation queue, where domain-specific agents attempt to exploit each finding with real proof-of-concept attacks against the running application.
|
||||
|
||||
This correlation means that a data flow vulnerability identified in static analysis (e.g., unsanitized user input reaching a SQL query) is not just reported as a theoretical risk -- it is actively exploited against the live application. Similarly, a business logic invariant violation (e.g., missing cross-tenant authorization) identified by the security testing engine is fed directly into the Authz exploitation agent, which attempts to reproduce the exact cross-organization access scenario against the running application. Confirmed exploits are traced back to their source code location, giving developers both the proof that the vulnerability is real and the exact line of code to fix.
|
||||
|
||||
---
|
||||
|
||||
# Key Technical Capabilities
|
||||
|
||||
- **Fully Autonomous Operation:** Shannon Pro handles complex workflows including 2FA/TOTP logins and SSO (e.g., Sign in with Google) without human intervention. TOTP is handled via a dedicated MCP server tool.
|
||||
- **White-Box Awareness:** Unlike black-box scanners, Shannon Pro reads the source code to intelligently guide its attack strategy, combining code-level insight with runtime validation.
|
||||
- **Parallel Processing:** Vulnerability analysis and exploitation phases run concurrently across attack domains, with pipelined parallelism minimizing total execution time.
|
||||
- **Tool Orchestration:** Shannon Pro orchestrates existing security tools (e.g., Schemathesis for API testing, Nmap for network discovery) while adding LLM reasoning to interpret results.
|
||||
- **Configurable Login Flows:** Authentication configuration specifies login procedures and credentials, which are interpolated into agent prompts for authenticated testing.
|
||||
|
||||
---
|
||||
|
||||
# Container Isolation and Data Security
|
||||
|
||||
Shannon Pro is engineered with a secure-by-design philosophy to ensure code privacy and isolation across every stage of the pipeline.
|
||||
|
||||
## Per-Organization Infrastructure
|
||||
|
||||
Each organization receives its own isolated compute environment. In the managed deployment, Keygraph provisions dedicated ECS infrastructure (containers, IAM roles, task queues) per organization. In the self-hosted runner deployment, the organization provisions and controls the data plane, which handles all code access and LLM calls using the organization's own API keys. The Keygraph control plane receives only aggregate findings. In either model, organizations never share compute environments with other organizations.
|
||||
|
||||
## Ephemeral Code Handling
|
||||
|
||||
When a scan runs, the target repository is cloned to a temporary workspace inside the isolated container. The scan executes against this local copy. Immediately after the scan completes, the entire workspace is deleted, including all cloned code. Source code is never persisted after a scan finishes. Even if a scan fails or is cancelled, a disconnected cleanup process executes regardless of how the scan terminates.
|
||||
|
||||
In the self-hosted runner deployment, all code handling occurs within the customer's own infrastructure. Keygraph's control plane never receives, processes, or stores source code.
|
||||
|
||||
## Encrypted Storage
|
||||
|
||||
Code snippets associated with findings are encrypted before being written to the database. Deliverables uploaded to S3 are encrypted at rest. Each organization's data is stored in org-specific buckets with org-scoped access policies.
|
||||
|
||||
## Network Isolation
|
||||
|
||||
Isolated workers run in private subnets with org-specific security groups, ensuring network-level separation between customer workloads.
|
||||
|
||||
## Self-Hosted Runner
|
||||
|
||||
Shannon Pro supports a self-hosted runner deployment model, following the same architecture as GitHub Actions self-hosted runners. The data plane (the runner that clones code, executes scans, and makes all LLM API calls) runs entirely within the customer's infrastructure using the customer's own LLM API keys. Source code never leaves the customer's network, and no code or LLM interactions pass through Keygraph's systems. The control plane (job orchestration, scan scheduling, and the reporting UI) is hosted by Keygraph and receives only aggregate findings to power dashboards, search, and reporting. This separation ensures that Keygraph never has access to customer source code or raw LLM call content.
|
||||
|
||||
---
|
||||
|
||||
# Deployment and Editions
|
||||
|
||||
Shannon is offered in two editions to serve different operational needs:
|
||||
|
||||
| Feature | Shannon Lite | Shannon Pro |
|
||||
| --- | --- | --- |
|
||||
| **Licensing** | AGPL-3.0 (open source) | Commercial |
|
||||
| **Static Analysis** | Code review prompting | Full agentic static analysis (SAST, SCA, secrets, business logic security testing) |
|
||||
| **Dynamic Testing** | Autonomous AI pentest framework | Autonomous AI pentesting with static-dynamic correlation |
|
||||
| **Analysis Engine** | Code review prompting | CPG-based data flow with LLM reasoning at every node |
|
||||
| **Business Logic** | N/A | Automated invariant discovery, test scenario generation, and exploit synthesis |
|
||||
| **Integration** | Manual / CLI | Native CI/CD, GitHub PR scanning, enterprise support, self-hosted runner |
|
||||
| **Deployment** | CLI / manual | Managed cloud or self-hosted runner (customer data plane, Keygraph control plane) |
|
||||
| **Boundary Analysis** | N/A | Automatic service boundary detection with team routing |
|
||||
| **Best For** | Local testing of own applications | Enterprise application security posture management |
|
||||
|
||||
---
|
||||
|
||||
# Compliance Integration
|
||||
|
||||
Within the broader Keygraph ecosystem, Shannon Pro serves as the primary engine for automated compliance evidence generation. By automating penetration testing and static analysis requirements, Shannon Pro generates real-time evidence for frameworks such as SOC 2 and HIPAA, transforming security testing from a periodic audit obligation into a continuous component of the compliance program.
|
||||
|
||||
---
|
||||
|
||||
# Methodology Standards
|
||||
|
||||
Shannon Pro follows AI-assisted white-box testing methodology broadly aligned with OWASP Web Security Testing Guide (WSTG) and OWASP Top 10 standards. All dynamic testing produces confirmed, exploitable findings with reproducible proof-of-concept exploits. Static analysis covers established CWE categories with LLM-powered validation to minimize false positive rates.
|
||||
@@ -4,7 +4,7 @@ networks:
|
||||
|
||||
services:
|
||||
temporal:
|
||||
image: temporalio/temporal:latest
|
||||
image: temporalio/temporal:1.7.0
|
||||
container_name: shannon-temporal
|
||||
command: ["server", "start-dev", "--db-filename", "/home/temporal/temporal.db", "--ip", "0.0.0.0"]
|
||||
ports:
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.1.0",
|
||||
"@temporalio/client": "^1.11.0",
|
||||
"chokidar": "^5.0.0",
|
||||
"dotenv": "^17.3.1",
|
||||
"smol-toml": "^1.6.1"
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Shared argument parsing for CLI commands.
|
||||
*
|
||||
* Every command declares which boolean flags, value options, and positionals it
|
||||
* accepts; `parseArgs` resolves aliases, rejects anything unrecognized, and hands
|
||||
* back a typed result. This centralizes the common flags (notably `--yes`/`-y`) so
|
||||
* each command no longer re-hardcodes `args.includes('--yes')`, and it makes
|
||||
* unknown flags and stray arguments fail loudly instead of being silently ignored.
|
||||
*/
|
||||
|
||||
import { closestMatch } from './suggest.js';
|
||||
|
||||
/** Thrown when argv does not match a command's schema. The dispatcher formats it. */
|
||||
export class ArgError extends Error {}
|
||||
|
||||
/** Tokens that set the "skip confirmation" flag, declared once for every command. */
|
||||
export const YES_FLAGS = ['--yes', '-y'] as const;
|
||||
|
||||
export interface ArgSchema {
|
||||
/** Boolean flags: result key -> accepted tokens (canonical plus any aliases). */
|
||||
readonly booleans?: Record<string, readonly string[]>;
|
||||
/** Value-taking options: result key -> accepted tokens. */
|
||||
readonly values?: Record<string, readonly string[]>;
|
||||
/** Maximum positional arguments allowed. Defaults to 0. */
|
||||
readonly maxPositionals?: number;
|
||||
/** Extra guidance appended to the error when too many positionals are given. */
|
||||
readonly positionalHint?: string;
|
||||
}
|
||||
|
||||
export interface ParsedArgs {
|
||||
readonly flags: Record<string, boolean>;
|
||||
readonly values: Record<string, string>;
|
||||
readonly positionals: readonly string[];
|
||||
}
|
||||
|
||||
/** Build a token -> result-key lookup from a schema section. */
|
||||
function indexTokens(section: Record<string, readonly string[]>): Map<string, string> {
|
||||
const byToken = new Map<string, string>();
|
||||
for (const [key, tokens] of Object.entries(section)) {
|
||||
for (const token of tokens) {
|
||||
byToken.set(token, key);
|
||||
}
|
||||
}
|
||||
return byToken;
|
||||
}
|
||||
|
||||
export function parseArgs(argv: readonly string[], schema: ArgSchema): ParsedArgs {
|
||||
const booleanByToken = indexTokens(schema.booleans ?? {});
|
||||
const valueByToken = indexTokens(schema.values ?? {});
|
||||
const maxPositionals = schema.maxPositionals ?? 0;
|
||||
|
||||
const flags: Record<string, boolean> = {};
|
||||
const values: Record<string, string> = {};
|
||||
const positionals: string[] = [];
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (arg === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const equalsIndex = arg.startsWith('--') ? arg.indexOf('=') : -1;
|
||||
const token = equalsIndex === -1 ? arg : arg.slice(0, equalsIndex);
|
||||
const inlineValue = equalsIndex === -1 ? undefined : arg.slice(equalsIndex + 1);
|
||||
|
||||
const booleanKey = booleanByToken.get(token);
|
||||
if (booleanKey !== undefined) {
|
||||
if (inlineValue !== undefined) {
|
||||
throw new ArgError(`Flag ${token} does not take a value`);
|
||||
}
|
||||
flags[booleanKey] = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const valueKey = valueByToken.get(token);
|
||||
if (valueKey !== undefined) {
|
||||
if (inlineValue !== undefined) {
|
||||
values[valueKey] = inlineValue;
|
||||
continue;
|
||||
}
|
||||
const next = argv[i + 1];
|
||||
if (next === undefined || next.startsWith('-')) {
|
||||
throw new ArgError(`Option ${token} requires a value`);
|
||||
}
|
||||
values[valueKey] = next;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.startsWith('-')) {
|
||||
const suggestion = closestMatch(token, [...booleanByToken.keys(), ...valueByToken.keys()]);
|
||||
const hint = suggestion ? `\nDid you mean '${suggestion}'?` : '';
|
||||
throw new ArgError(`Unknown option: ${token}${hint}`);
|
||||
}
|
||||
|
||||
positionals.push(arg);
|
||||
}
|
||||
|
||||
if (positionals.length > maxPositionals) {
|
||||
const extra = positionals[maxPositionals];
|
||||
const hint = schema.positionalHint ? `\n${schema.positionalHint}` : '';
|
||||
throw new ArgError(`Unexpected argument: ${extra}${hint}`);
|
||||
}
|
||||
|
||||
return { flags, values, positionals };
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* ANSI color and style escapes — the single source for the CLI's palette.
|
||||
*
|
||||
* Codes are plain constants; callers decide whether to emit them via `paint`
|
||||
* (wrap-and-reset) or `gate` (prefix-or-empty), gating on `supportsColor()` from
|
||||
* `tty.ts`. Cursor-control escapes live with their sole consumer, not here — this
|
||||
* module is color only.
|
||||
*/
|
||||
|
||||
export const RESET = '\x1b[0m';
|
||||
|
||||
/** Shannon brand gold — the running/completed accent, shared with the splash logo. */
|
||||
export const GOLD = '\x1b[38;2;244;197;66m';
|
||||
|
||||
export const BOLD = '\x1b[1m';
|
||||
export const RED = '\x1b[31m';
|
||||
export const YELLOW = '\x1b[33m';
|
||||
export const DIM = '\x1b[90m';
|
||||
|
||||
// The splash logo uses bolder variants of cyan/white/yellow than the progress tree.
|
||||
export const CYAN = '\x1b[36;1m';
|
||||
export const WHITE = '\x1b[1;37m';
|
||||
export const GRAY = '\x1b[0;37m';
|
||||
export const BOLD_YELLOW = '\x1b[1;33m';
|
||||
|
||||
/** Wrap `text` in `code` and reset, or return it unchanged when color is off. */
|
||||
export function paint(text: string, code: string, enabled: boolean): string {
|
||||
return enabled ? `${code}${text}${RESET}` : text;
|
||||
}
|
||||
|
||||
/** A style code when color is on, or an empty string when off — for templates that interleave prefixes directly. */
|
||||
export function gate(code: string, enabled: boolean): string {
|
||||
return enabled ? code : '';
|
||||
}
|
||||
@@ -1,19 +1,20 @@
|
||||
/**
|
||||
* `shannon build` command — build the worker Docker image locally.
|
||||
* Only available in local mode (running from cloned repository).
|
||||
* `shannon build` command — build the worker Docker image from the repository.
|
||||
* Requires a clone (Dockerfile in the working directory).
|
||||
*/
|
||||
|
||||
import { buildImage } from '../docker.js';
|
||||
import { isLocal } from '../mode.js';
|
||||
import { buildImage, canBuildImage, ensureDocker } from '../docker.js';
|
||||
import { fail } from '../errors.js';
|
||||
|
||||
export function build(noCache: boolean): void {
|
||||
if (!isLocal()) {
|
||||
console.error('ERROR: Build is only available when running from the Shannon repository');
|
||||
console.error(' (Dockerfile not found in current directory)');
|
||||
console.error('');
|
||||
console.error('For npx usage, run: shannon update');
|
||||
process.exit(1);
|
||||
export function build(noCache: boolean, version: string): void {
|
||||
ensureDocker();
|
||||
|
||||
if (!canBuildImage()) {
|
||||
fail(
|
||||
'Build is only available when running from the Shannon repository',
|
||||
' (Dockerfile not found in current directory)',
|
||||
);
|
||||
}
|
||||
|
||||
buildImage(noCache);
|
||||
buildImage(noCache, version);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* `shannon logs` command — tail a workspace's workflow log.
|
||||
* `shannon logs` command — tail a scan's live log.
|
||||
*
|
||||
* Uses chokidar for reliable cross-platform file watching and
|
||||
* bounded synchronous reads to prevent duplicate output.
|
||||
@@ -8,10 +8,13 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { watch } from 'chokidar';
|
||||
import { fail } from '../errors.js';
|
||||
import { getWorkspacesDir } from '../home.js';
|
||||
import { resolveRunFile } from '../paths.js';
|
||||
import { stdoutIsTerminal } from '../tty.js';
|
||||
|
||||
// Match the exact line the worker writes — anchored to prevent false positives from agent output
|
||||
const COMPLETION_PATTERN = /^Workflow (COMPLETED|FAILED)$/m;
|
||||
const COMPLETION_PATTERN = /^Scan (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 {
|
||||
@@ -27,80 +30,92 @@ function readRange(filePath: string, start: number, end: number): string {
|
||||
}
|
||||
|
||||
/** Resolve a workspace ID to its workflow.log path, or exit with an error. */
|
||||
function resolveLogFile(workspaceId: string): string {
|
||||
export function resolveLogFile(workspaceId: string): string {
|
||||
const workspacesDir = getWorkspacesDir();
|
||||
|
||||
// 1. Direct match
|
||||
const directPath = path.join(workspacesDir, workspaceId, 'workflow.log');
|
||||
const directPath = resolveRunFile(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');
|
||||
const resumePath = resolveRunFile(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');
|
||||
const namedPath = resolveRunFile(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);
|
||||
fail(
|
||||
`No scan found named: ${workspaceId}`,
|
||||
'',
|
||||
'Possible causes:',
|
||||
" - The scan hasn't started yet",
|
||||
' - The workspace name is incorrect',
|
||||
'',
|
||||
'Check the dashboard at http://localhost:8233 for scan details',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tail a scan's log until it reports completion, resolving when the completion marker appears
|
||||
* (or the file is gone, or Ctrl-C stops it). Never exits the process, so the caller decides what
|
||||
* happens next: plain `logs` exits 0; `start --follow` reads the workflow outcome first.
|
||||
*/
|
||||
export function tailUntilComplete(logFile: string): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Output existing content
|
||||
if (flush()) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Watch for appended content via chokidar
|
||||
const watcher = watch(logFile, { persistent: true });
|
||||
|
||||
const stop = (): void => {
|
||||
watcher.close().finally(() => resolve());
|
||||
// Safety net — resolve anyway if watcher.close() stalls
|
||||
setTimeout(() => resolve(), 1000).unref();
|
||||
};
|
||||
|
||||
watcher.on('change', () => {
|
||||
if (flush()) stop();
|
||||
});
|
||||
|
||||
process.on('SIGINT', stop);
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
console.error(stdoutIsTerminal() ? `Tailing scan log: ${logFile}` : 'Tailing scan log');
|
||||
tailUntilComplete(logFile).finally(() => process.exit(0));
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* `shannon reset` command — stop everything and wipe all Temporal data and volumes,
|
||||
* returning the machine to a clean slate. The destructive counterpart to `stop`.
|
||||
*/
|
||||
|
||||
import * as p from '@clack/prompts';
|
||||
import { confirmByTyping } from '../confirm.js';
|
||||
import { ensureDocker, runningContainers, stopContainers, stopInfra, WORKER_FILTER } from '../docker.js';
|
||||
|
||||
export async function reset(): Promise<void> {
|
||||
ensureDocker();
|
||||
|
||||
console.log('This will stop all running scans and permanently remove all Temporal data and volumes.');
|
||||
await confirmByTyping('reset', 'confirm');
|
||||
|
||||
const spinner = p.spinner();
|
||||
spinner.start('Stopping scans');
|
||||
const running = runningContainers(WORKER_FILTER);
|
||||
await stopContainers(running);
|
||||
spinner.stop(
|
||||
running.length > 0 ? `Stopped ${running.length} scan${running.length === 1 ? '' : 's'}` : 'No scans running',
|
||||
);
|
||||
|
||||
await stopInfra(true);
|
||||
console.log('Reset complete.');
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* `shannon scans` command — list completed scans and where each report lives.
|
||||
*
|
||||
* A scan counts as completed when it produced a report. The report can live in any of a
|
||||
* few locations depending on the version that ran it, so `findReport` probes them in order
|
||||
* and the first hit is both the completion signal and the link target behind the workspace
|
||||
* name. The date and wall-clock duration come from the run's session.json
|
||||
* (createdAt/completedAt), with the report file's mtime as the date fallback for
|
||||
* runs that lack a recorded time.
|
||||
*
|
||||
* Human-readable by default; `--json` emits the same rows as raw machine values on stdout.
|
||||
*
|
||||
* Filesystem-only (local ./workspaces/ or npx ~/.shannon/workspaces/ via getWorkspacesDir);
|
||||
* no Temporal dependency.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { BOLD, GOLD, paint } from '../colors.js';
|
||||
import { getWorkspacesDir } from '../home.js';
|
||||
import { commandPrefix } from '../mode.js';
|
||||
import { FINAL_REPORT_PDF_FILENAME, INTERNAL_DIR, resolveRunFile } from '../paths.js';
|
||||
import { stdoutIsTerminal, supportsColor } from '../tty.js';
|
||||
|
||||
/** Assembled report in the deliverables dir. Must match ASSEMBLED_REPORT_FILENAME in the worker package. */
|
||||
const ASSEMBLED_REPORT_FILENAME = 'comprehensive_security_assessment_report.md';
|
||||
|
||||
/** Run-root markdown surfaced by older versions, before the PDF. Kept so those runs still list. */
|
||||
const FINAL_REPORT_MD_FILENAME = 'Security-Assessment-Report.md';
|
||||
|
||||
const DELIVERABLES_SUBDIR = 'deliverables';
|
||||
|
||||
/** One completed scan; raw values so the table and --json render from one source. */
|
||||
interface ScanRow {
|
||||
readonly workspace: string;
|
||||
/** Completion time in ms — sort key and date source. */
|
||||
readonly finishedMs: number;
|
||||
/** Wall-clock duration (completedAt − createdAt) in ms, or null when unknown. */
|
||||
readonly durationMs: number | null;
|
||||
/** Absolute path to the report file — the link target behind the workspace name. */
|
||||
readonly report: string;
|
||||
}
|
||||
|
||||
/** The --json row shape: raw machine values, one per completed scan. */
|
||||
interface JsonRow {
|
||||
readonly workspace: string;
|
||||
readonly finishedAt: string;
|
||||
readonly durationMs: number | null;
|
||||
readonly reportPath: string;
|
||||
}
|
||||
|
||||
/** Compact wall-clock duration from milliseconds: "47s", "1m 32s", "1h 47m". */
|
||||
function formatDuration(ms: number): string {
|
||||
const totalSeconds = Math.round(ms / 1000);
|
||||
if (totalSeconds < 60) {
|
||||
return `${totalSeconds}s`;
|
||||
}
|
||||
const totalMinutes = Math.floor(totalSeconds / 60);
|
||||
if (totalMinutes < 60) {
|
||||
return `${totalMinutes}m ${totalSeconds % 60}s`;
|
||||
}
|
||||
return `${Math.floor(totalMinutes / 60)}h ${totalMinutes % 60}m`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap `text` in an OSC 8 hyperlink to `url` so a supporting terminal opens it on click,
|
||||
* or return `text` unchanged. Terminals without OSC 8 simply show the text.
|
||||
*/
|
||||
function hyperlink(text: string, url: string): string {
|
||||
return `\x1b]8;;${url}\x1b\\${text}\x1b]8;;\x1b\\`;
|
||||
}
|
||||
|
||||
/** First existing report path for a run (newest-surfaced first), or null if it has none. */
|
||||
function findReport(runDir: string): string | null {
|
||||
const candidates = [
|
||||
path.join(runDir, FINAL_REPORT_PDF_FILENAME),
|
||||
path.join(runDir, FINAL_REPORT_MD_FILENAME),
|
||||
path.join(runDir, INTERNAL_DIR, DELIVERABLES_SUBDIR, ASSEMBLED_REPORT_FILENAME),
|
||||
path.join(runDir, DELIVERABLES_SUBDIR, ASSEMBLED_REPORT_FILENAME),
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface SessionData {
|
||||
readonly session: { readonly createdAt?: string; readonly completedAt?: string };
|
||||
}
|
||||
|
||||
/** Read a run's session.json (dual-read across layouts). Missing or unreadable → empty shape. */
|
||||
function readSession(runDir: string): SessionData {
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(resolveRunFile(runDir, 'session.json'), 'utf8'));
|
||||
return { session: parsed?.session ?? {} };
|
||||
} catch {
|
||||
return { session: {} };
|
||||
}
|
||||
}
|
||||
|
||||
/** Gather every workspace that has a report, one row each. */
|
||||
function collectCompletedScans(workspacesDir: string): ScanRow[] {
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(workspacesDir, { withFileTypes: true });
|
||||
} catch {
|
||||
// Workspaces directory does not exist yet — no scans have ever run.
|
||||
return [];
|
||||
}
|
||||
|
||||
const rows: ScanRow[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const runDir = path.join(workspacesDir, entry.name);
|
||||
const reportPath = findReport(runDir);
|
||||
if (!reportPath) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { session } = readSession(runDir);
|
||||
const completedMs = Date.parse(session.completedAt ?? '');
|
||||
const createdMs = Date.parse(session.createdAt ?? '');
|
||||
const finishedMs = Number.isNaN(completedMs) ? fs.statSync(reportPath).mtimeMs : completedMs;
|
||||
const durationMs = Number.isNaN(completedMs) || Number.isNaN(createdMs) ? null : completedMs - createdMs;
|
||||
|
||||
rows.push({ workspace: entry.name, finishedMs, durationMs, report: reportPath });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function toJsonRow(row: ScanRow): JsonRow {
|
||||
return {
|
||||
workspace: row.workspace,
|
||||
finishedAt: new Date(row.finishedMs).toISOString(),
|
||||
durationMs: row.durationMs,
|
||||
reportPath: row.report,
|
||||
};
|
||||
}
|
||||
|
||||
/** Print the completed scans as an aligned table with the workspace name linked to its report. */
|
||||
function printTable(workspacesDir: string, rows: readonly ScanRow[]): void {
|
||||
if (rows.length === 0) {
|
||||
const prefix = commandPrefix();
|
||||
console.log(`No completed scans yet. Run '${prefix} start -u <url> -r <path>' to begin.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const color = supportsColor();
|
||||
// On a terminal the workspace name is an OSC 8 hyperlink that opens its report; when
|
||||
// piped there is nothing to click, so it prints as plain text.
|
||||
const linkable = stdoutIsTerminal();
|
||||
|
||||
const table = rows.map((row) => ({
|
||||
finished: new Date(row.finishedMs).toISOString().slice(0, 10),
|
||||
duration: row.durationMs === null ? '—' : formatDuration(row.durationMs),
|
||||
workspace: row.workspace,
|
||||
report: row.report,
|
||||
}));
|
||||
|
||||
const dateWidth = Math.max('FINISHED'.length, 'YYYY-MM-DD'.length);
|
||||
const durationWidth = Math.max('DURATION'.length, ...table.map((row) => row.duration.length));
|
||||
|
||||
console.log(`\nCompleted scans in ${workspacesDir}:\n`);
|
||||
const header = `${'FINISHED'.padEnd(dateWidth)} ${'DURATION'.padEnd(durationWidth)} WORKSPACE`;
|
||||
console.log(paint(header, BOLD, color));
|
||||
|
||||
for (const row of table) {
|
||||
const finished = row.finished.padEnd(dateWidth);
|
||||
const duration = row.duration.padEnd(durationWidth);
|
||||
const name = paint(row.workspace, GOLD, color);
|
||||
const workspace = linkable ? hyperlink(name, pathToFileURL(row.report).href) : name;
|
||||
console.log(`${finished} ${duration} ${workspace}`);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
export function scans(opts: { readonly json: boolean }): void {
|
||||
const workspacesDir = getWorkspacesDir();
|
||||
const rows = collectCompletedScans(workspacesDir);
|
||||
|
||||
// Latest on top.
|
||||
rows.sort((a, b) => b.finishedMs - a.finishedMs);
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(rows.map(toJsonRow), null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
printTable(workspacesDir, rows);
|
||||
}
|
||||
+231
-204
@@ -1,58 +1,167 @@
|
||||
/**
|
||||
* `shn setup` — interactive TUI wizard for one-time credential configuration.
|
||||
* `npx @keygraph/shannon setup` — interactive TUI wizard for one-time credential configuration.
|
||||
*
|
||||
* Walks the user through selecting a provider and entering credentials,
|
||||
* then persists everything to ~/.shannon/config.toml with 0o600 permissions.
|
||||
* Walks the user through selecting a provider, entering credentials, and naming
|
||||
* the model that runs the whole scan, then persists everything to
|
||||
* ~/.shannon/config.toml with 0o600 permissions.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import * as p from '@clack/prompts';
|
||||
import { type ShannonConfig, saveConfig } from '../config/writer.js';
|
||||
import { CURATED_PROVIDERS, type CuratedProviderId, isCuratedProvider, type OpenAiFormat } from '../model-spec.js';
|
||||
import { displaySplash } from '../splash.js';
|
||||
import { requireInteractive } from '../tty.js';
|
||||
import { getVersion } from '../version.js';
|
||||
|
||||
const SHANNON_HOME = path.join(os.homedir(), '.shannon');
|
||||
|
||||
type Provider = 'anthropic' | 'custom_base_url' | 'bedrock' | 'vertex';
|
||||
const CUSTOM_MODEL = '__custom__';
|
||||
const CUSTOM_BASE_URL = '__custom_base_url__';
|
||||
const OTHER_PROVIDER = '__other_provider__';
|
||||
|
||||
/**
|
||||
* Wire formats reachable through the gateway route. The format picks the provider
|
||||
* that supplies the credential, and for OpenAI it also picks which of the two
|
||||
* OpenAI APIs Shannon calls.
|
||||
*/
|
||||
const GATEWAY_DIALECTS: readonly {
|
||||
value: string;
|
||||
label: string;
|
||||
provider: 'anthropic' | 'openai';
|
||||
format?: OpenAiFormat;
|
||||
}[] = [
|
||||
{ value: 'anthropic', label: 'Anthropic Messages', provider: 'anthropic' },
|
||||
{
|
||||
value: 'openai-chat-completions',
|
||||
label: 'OpenAI Chat Completions',
|
||||
provider: 'openai',
|
||||
format: 'chat-completions',
|
||||
},
|
||||
{ value: 'openai-responses', label: 'OpenAI Responses', provider: 'openai', format: 'responses' },
|
||||
];
|
||||
|
||||
/** Suggested models per curated provider, best-first. Free-text entry accepts any model in the provider's catalogue. */
|
||||
const MODEL_SUGGESTIONS: Readonly<Record<CuratedProviderId, readonly string[]>> = {
|
||||
anthropic: ['claude-sonnet-4-6', 'claude-opus-4-8', 'claude-opus-4-7', 'claude-haiku-4-5-20251001'],
|
||||
openai: ['gpt-5.6-sol', 'gpt-5.5', 'gpt-5.4'],
|
||||
xai: ['grok-4.5'],
|
||||
'amazon-bedrock': ['us.anthropic.claude-sonnet-4-6', 'us.anthropic.claude-opus-4-8', 'us.anthropic.claude-opus-4-7'],
|
||||
};
|
||||
|
||||
/** Placeholder shown in the free-text model ID prompt, per curated provider. */
|
||||
const MODEL_ID_PLACEHOLDER: Readonly<Record<CuratedProviderId, string>> = {
|
||||
anthropic: 'claude-sonnet-4-6',
|
||||
openai: 'gpt-5.6-sol',
|
||||
xai: 'grok-4.5',
|
||||
'amazon-bedrock': 'us.anthropic.claude-opus-4-8',
|
||||
};
|
||||
|
||||
/** Model ID placeholder for a provider, absent when the provider is not curated. */
|
||||
function modelIdPlaceholder(provider: string): string | undefined {
|
||||
return isCuratedProvider(provider) ? MODEL_ID_PLACEHOLDER[provider] : undefined;
|
||||
}
|
||||
|
||||
export async function setup(): Promise<void> {
|
||||
p.intro('Shannon Setup');
|
||||
requireInteractive('setup', 'For non-interactive use, export credentials as env vars (e.g. ANTHROPIC_API_KEY).');
|
||||
displaySplash(getVersion());
|
||||
p.intro('Setup');
|
||||
|
||||
// 1. Select provider
|
||||
const provider = await p.select({
|
||||
// 1. Select provider. "Custom Base URL" is a route, not a provider — it asks
|
||||
// which API dialect the gateway speaks and configures that provider. "Other
|
||||
// provider" reaches any pi-supported provider Shannon does not curate.
|
||||
const selected = await p.select({
|
||||
message: 'Select your AI provider',
|
||||
options: [
|
||||
{ value: 'anthropic' as const, label: 'Claude Direct', hint: 'recommended' },
|
||||
{ value: 'custom_base_url' as const, label: 'Custom Base URL', hint: 'proxies, gateways' },
|
||||
{ value: 'bedrock' as const, label: 'Claude via AWS Bedrock' },
|
||||
{ value: 'vertex' as const, label: 'Claude via Google Vertex AI' },
|
||||
{ value: 'anthropic' as const, label: 'Anthropic', hint: 'Claude models - recommended' },
|
||||
{ value: 'openai' as const, label: 'OpenAI', hint: 'GPT models' },
|
||||
{ value: 'xai' as const, label: 'xAI', hint: 'Grok models' },
|
||||
{ value: 'amazon-bedrock' as const, label: 'AWS Bedrock', hint: 'Claude models via AWS' },
|
||||
{ value: CUSTOM_BASE_URL as typeof CUSTOM_BASE_URL, label: 'Custom Base URL', hint: 'your own proxy or gateway' },
|
||||
{
|
||||
value: OTHER_PROVIDER as typeof OTHER_PROVIDER,
|
||||
label: 'Other provider',
|
||||
hint: 'any other Pi-supported provider',
|
||||
},
|
||||
],
|
||||
});
|
||||
if (p.isCancel(provider)) return cancelAndExit();
|
||||
if (p.isCancel(selected)) return cancelAndExit();
|
||||
|
||||
const config = await setupProvider(provider as Provider);
|
||||
// 2. Credentials — and, on the gateway route, the endpoint and its dialect.
|
||||
const { provider, config, gateway } = await setupSelection(selected);
|
||||
|
||||
// 3. The model that runs every phase.
|
||||
const modelId = await promptModel(provider);
|
||||
config.core = { ...config.core, model: `${provider}:${modelId}` };
|
||||
if (gateway) config.core = { ...config.core, base_url: gateway.baseUrl };
|
||||
|
||||
// 2. Save config
|
||||
saveConfig(config);
|
||||
|
||||
const configPath = path.join(SHANNON_HOME, 'config.toml');
|
||||
const summary = [`Provider ${provider}`, `Model ${modelId}`];
|
||||
if (gateway) summary.push(`Endpoint ${gateway.baseUrl}`);
|
||||
if (gateway?.format) summary.push(`API ${gateway.format}`);
|
||||
|
||||
p.log.success(`Configuration saved to ${configPath}`);
|
||||
p.log.info(summary.join('\n'));
|
||||
p.outro('Run `npx @keygraph/shannon start` to begin a scan.');
|
||||
}
|
||||
|
||||
async function setupProvider(provider: Provider): Promise<ShannonConfig> {
|
||||
interface Selection {
|
||||
provider: string;
|
||||
config: ShannonConfig;
|
||||
gateway?: GatewaySetup;
|
||||
}
|
||||
|
||||
/** Resolve the provider selection into a provider id and its credential config. */
|
||||
async function setupSelection(
|
||||
selected: CuratedProviderId | typeof CUSTOM_BASE_URL | typeof OTHER_PROVIDER,
|
||||
): Promise<Selection> {
|
||||
if (selected === CUSTOM_BASE_URL) {
|
||||
const gateway = await setupGateway();
|
||||
return { provider: gateway.provider, config: gateway.config, gateway };
|
||||
}
|
||||
if (selected === OTHER_PROVIDER) {
|
||||
return setupOtherProvider();
|
||||
}
|
||||
return { provider: selected, config: await setupProvider(selected) };
|
||||
}
|
||||
|
||||
async function setupProvider(provider: CuratedProviderId): Promise<ShannonConfig> {
|
||||
switch (provider) {
|
||||
case 'amazon-bedrock':
|
||||
return setupBedrock();
|
||||
case 'anthropic':
|
||||
return setupAnthropic();
|
||||
case 'custom_base_url':
|
||||
return setupCustomBaseUrl();
|
||||
case 'bedrock':
|
||||
return setupBedrock();
|
||||
case 'vertex':
|
||||
return setupVertex();
|
||||
case 'openai':
|
||||
return { openai: { api_key: await promptSecret('Enter your OpenAI API key') } };
|
||||
case 'xai':
|
||||
return { xai: { api_key: await promptSecret('Enter your xAI API key') } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Any pi provider Shannon does not curate. The id is free text — the worker's
|
||||
* preflight validates it — and the key is stored generically as SHANNON_AI_API_KEY.
|
||||
*/
|
||||
async function setupOtherProvider(): Promise<Selection> {
|
||||
p.log.info('Browse supported providers and models at https://pi.dev/models');
|
||||
const provider = await p.text({
|
||||
message: 'Provider ID',
|
||||
validate: (value) => {
|
||||
const id = value?.trim();
|
||||
if (!id) return 'Provider ID is required';
|
||||
if (isCuratedProvider(id)) return `${id} has its own option.`;
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
if (p.isCancel(provider)) return cancelAndExit();
|
||||
|
||||
const apiKey = await promptSecret('Enter the API key');
|
||||
return { provider: provider.trim(), config: { provider: { api_key: apiKey } } };
|
||||
}
|
||||
|
||||
// === Provider Setup Flows ===
|
||||
|
||||
async function setupAnthropic(): Promise<ShannonConfig> {
|
||||
@@ -65,112 +174,13 @@ async function setupAnthropic(): Promise<ShannonConfig> {
|
||||
});
|
||||
if (p.isCancel(authMethod)) return cancelAndExit();
|
||||
|
||||
const config: ShannonConfig = {};
|
||||
|
||||
if (authMethod === 'oauth') {
|
||||
const token = await promptSecret('Enter your OAuth token');
|
||||
config.anthropic = { oauth_token: token };
|
||||
} else {
|
||||
const apiKey = await promptSecret('Enter your Anthropic API key');
|
||||
config.anthropic = { api_key: apiKey };
|
||||
return { anthropic: { oauth_token: token } };
|
||||
}
|
||||
|
||||
const customizeModels = await p.confirm({
|
||||
message:
|
||||
'Do you want to change the default models?\n' +
|
||||
' Small - claude-haiku-4-5-20251001\n' +
|
||||
' Medium - claude-sonnet-4-6\n' +
|
||||
' Large - claude-opus-4-6',
|
||||
initialValue: false,
|
||||
});
|
||||
if (p.isCancel(customizeModels)) return cancelAndExit();
|
||||
|
||||
if (customizeModels) {
|
||||
const small = await p.text({
|
||||
message: 'Small model ID',
|
||||
initialValue: 'claude-haiku-4-5-20251001',
|
||||
validate: required('Small model ID is required'),
|
||||
});
|
||||
if (p.isCancel(small)) return cancelAndExit();
|
||||
|
||||
const medium = await p.text({
|
||||
message: 'Medium model ID',
|
||||
initialValue: 'claude-sonnet-4-6',
|
||||
validate: required('Medium model ID is required'),
|
||||
});
|
||||
if (p.isCancel(medium)) return cancelAndExit();
|
||||
|
||||
const large = await p.text({
|
||||
message: 'Large model ID',
|
||||
initialValue: 'claude-opus-4-6',
|
||||
validate: required('Large model ID is required'),
|
||||
});
|
||||
if (p.isCancel(large)) return cancelAndExit();
|
||||
|
||||
config.models = { small, medium, large };
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
async function setupCustomBaseUrl(): Promise<ShannonConfig> {
|
||||
const baseUrl = await p.text({
|
||||
message: 'Endpoint URL',
|
||||
placeholder: 'https://your-proxy.example.com',
|
||||
validate: (value) => {
|
||||
if (!value) return 'Endpoint URL is required';
|
||||
try {
|
||||
new URL(value);
|
||||
} catch {
|
||||
return 'Must be a valid URL';
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
if (p.isCancel(baseUrl)) return cancelAndExit();
|
||||
|
||||
const authToken = await promptSecret('Enter the auth token for the custom endpoint');
|
||||
|
||||
const config: ShannonConfig = {
|
||||
custom_base_url: { base_url: baseUrl, auth_token: authToken },
|
||||
};
|
||||
|
||||
const customizeModels = await p.confirm({
|
||||
message:
|
||||
'Do you want to change the default models?\n' +
|
||||
' Small - claude-haiku-4-5-20251001\n' +
|
||||
' Medium - claude-sonnet-4-6\n' +
|
||||
' Large - claude-opus-4-6',
|
||||
initialValue: false,
|
||||
});
|
||||
if (p.isCancel(customizeModels)) return cancelAndExit();
|
||||
|
||||
if (customizeModels) {
|
||||
const small = await p.text({
|
||||
message: 'Small model ID',
|
||||
initialValue: 'claude-haiku-4-5-20251001',
|
||||
validate: required('Small model ID is required'),
|
||||
});
|
||||
if (p.isCancel(small)) return cancelAndExit();
|
||||
|
||||
const medium = await p.text({
|
||||
message: 'Medium model ID',
|
||||
initialValue: 'claude-sonnet-4-6',
|
||||
validate: required('Medium model ID is required'),
|
||||
});
|
||||
if (p.isCancel(medium)) return cancelAndExit();
|
||||
|
||||
const large = await p.text({
|
||||
message: 'Large model ID',
|
||||
initialValue: 'claude-opus-4-6',
|
||||
validate: required('Large model ID is required'),
|
||||
});
|
||||
if (p.isCancel(large)) return cancelAndExit();
|
||||
|
||||
config.models = { small, medium, large };
|
||||
}
|
||||
|
||||
return config;
|
||||
const apiKey = await promptSecret('Enter your Anthropic API key');
|
||||
return { anthropic: { api_key: apiKey } };
|
||||
}
|
||||
|
||||
async function setupBedrock(): Promise<ShannonConfig> {
|
||||
@@ -183,100 +193,117 @@ async function setupBedrock(): Promise<ShannonConfig> {
|
||||
|
||||
const token = await promptSecret('Enter your AWS Bearer Token');
|
||||
|
||||
const small = await p.text({
|
||||
message: 'Small model ID',
|
||||
placeholder: 'us.anthropic.claude-haiku-4-5-20251001-v1:0',
|
||||
validate: required('Small model ID is required'),
|
||||
});
|
||||
if (p.isCancel(small)) return cancelAndExit();
|
||||
|
||||
const medium = await p.text({
|
||||
message: 'Medium model ID',
|
||||
placeholder: 'us.anthropic.claude-sonnet-4-6',
|
||||
validate: required('Medium model ID is required'),
|
||||
});
|
||||
if (p.isCancel(medium)) return cancelAndExit();
|
||||
|
||||
const large = await p.text({
|
||||
message: 'Large model ID',
|
||||
placeholder: 'us.anthropic.claude-opus-4-6',
|
||||
validate: required('Large model ID is required'),
|
||||
});
|
||||
if (p.isCancel(large)) return cancelAndExit();
|
||||
|
||||
return {
|
||||
bedrock: { use: true, region, token },
|
||||
models: { small, medium, large },
|
||||
};
|
||||
return { bedrock: { region, token } };
|
||||
}
|
||||
|
||||
async function setupVertex(): Promise<ShannonConfig> {
|
||||
// 1. Collect region and project ID
|
||||
const region = await p.text({
|
||||
message: 'Google Cloud region',
|
||||
placeholder: 'us-east5',
|
||||
validate: required('Region is required'),
|
||||
});
|
||||
if (p.isCancel(region)) return cancelAndExit();
|
||||
interface GatewaySetup {
|
||||
provider: CuratedProviderId;
|
||||
config: ShannonConfig;
|
||||
baseUrl: string;
|
||||
format?: OpenAiFormat;
|
||||
}
|
||||
|
||||
const projectId = await p.text({
|
||||
message: 'GCP Project ID',
|
||||
validate: required('Project ID is required'),
|
||||
/**
|
||||
* Gateway route: the endpoint decides where requests go, but the format still
|
||||
* picks a real provider, because that is what supplies the credential and the
|
||||
* wire protocol.
|
||||
*/
|
||||
async function setupGateway(): Promise<GatewaySetup> {
|
||||
const choice = await p.select({
|
||||
message: 'API format',
|
||||
options: GATEWAY_DIALECTS.map(({ value, label }) => ({ value, label })),
|
||||
});
|
||||
if (p.isCancel(projectId)) return cancelAndExit();
|
||||
if (p.isCancel(choice)) return cancelAndExit();
|
||||
|
||||
// 2. File picker for service account key
|
||||
p.log.info('Select the path to your GCP Service Account JSON key file.');
|
||||
const keySourcePath = await p.path({
|
||||
message: 'Service Account JSON key file',
|
||||
const dialect = GATEWAY_DIALECTS.find((entry) => entry.value === choice);
|
||||
if (!dialect) return cancelAndExit();
|
||||
const provider = dialect.provider;
|
||||
|
||||
const baseUrl = await p.text({
|
||||
message: 'Endpoint URL',
|
||||
placeholder: 'https://llm-gateway.example.com',
|
||||
validate: (value) => {
|
||||
if (!value) return 'Path is required';
|
||||
if (!fs.existsSync(value)) return 'File not found';
|
||||
if (!value.endsWith('.json')) return 'Must be a .json file';
|
||||
if (!value) return 'Endpoint URL is required';
|
||||
try {
|
||||
new URL(value);
|
||||
} catch {
|
||||
return 'Must be a valid URL';
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
if (p.isCancel(keySourcePath)) return cancelAndExit();
|
||||
if (p.isCancel(baseUrl)) return cancelAndExit();
|
||||
|
||||
// 3. Copy key to ~/.shannon/ and lock permissions
|
||||
const destPath = path.join(SHANNON_HOME, 'google-sa-key.json');
|
||||
fs.mkdirSync(SHANNON_HOME, { recursive: true });
|
||||
fs.copyFileSync(keySourcePath, destPath);
|
||||
fs.chmodSync(destPath, 0o600);
|
||||
p.log.success(`Key copied to ${destPath} (permissions: 0600)`);
|
||||
const authToken = await promptSecret('Enter the auth token for the endpoint');
|
||||
const config: ShannonConfig =
|
||||
provider === 'anthropic'
|
||||
? { anthropic: { api_key: authToken } }
|
||||
: { openai: { api_key: authToken, ...(dialect.format && { format: dialect.format }) } };
|
||||
|
||||
// 4. Model tiers
|
||||
const models = await p.group({
|
||||
small: () =>
|
||||
p.text({
|
||||
message: 'Small model ID',
|
||||
placeholder: 'claude-haiku-4-5@20251001',
|
||||
validate: required('Small model ID is required'),
|
||||
}),
|
||||
medium: () =>
|
||||
p.text({
|
||||
message: 'Medium model ID',
|
||||
placeholder: 'claude-sonnet-4-6',
|
||||
validate: required('Medium model ID is required'),
|
||||
}),
|
||||
large: () =>
|
||||
p.text({
|
||||
message: 'Large model ID',
|
||||
placeholder: 'claude-opus-4-6',
|
||||
validate: required('Large model ID is required'),
|
||||
}),
|
||||
return { provider, config, baseUrl, ...(dialect.format && { format: dialect.format }) };
|
||||
}
|
||||
|
||||
// === Model Selection ===
|
||||
|
||||
/**
|
||||
* Ask for the one model that runs every phase. Providers with suggestions offer a
|
||||
* pick list with a free-text escape hatch; the rest go straight to free text.
|
||||
*/
|
||||
async function promptModel(provider: string): Promise<string> {
|
||||
const suggestions = isCuratedProvider(provider) ? MODEL_SUGGESTIONS[provider] : [];
|
||||
|
||||
if (suggestions.length === 0) {
|
||||
return promptModelId(provider, modelIdPlaceholder(provider));
|
||||
}
|
||||
|
||||
const choice = await p.select({
|
||||
message: 'Model',
|
||||
options: [
|
||||
...suggestions.map((model) => ({ value: model, label: model })),
|
||||
{ value: CUSTOM_MODEL, label: 'Enter a model ID…' },
|
||||
],
|
||||
});
|
||||
if (p.isCancel(models)) return cancelAndExit();
|
||||
if (p.isCancel(choice)) return cancelAndExit();
|
||||
|
||||
return {
|
||||
vertex: {
|
||||
use: true,
|
||||
region,
|
||||
project_id: projectId,
|
||||
key_path: destPath,
|
||||
if (choice === CUSTOM_MODEL) {
|
||||
return promptModelId(provider, modelIdPlaceholder(provider));
|
||||
}
|
||||
return choice as string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A leading `<provider>:` naming a supported provider other than the selected
|
||||
* one. Bedrock model IDs carry their own colons (`…-v1:0`), so only a genuine
|
||||
* provider id counts as a prefix.
|
||||
*/
|
||||
function conflictingProviderPrefix(provider: string, value: string): string | undefined {
|
||||
const separator = value.indexOf(':');
|
||||
if (separator === -1) return undefined;
|
||||
|
||||
const head = value.slice(0, separator);
|
||||
if (head === provider) return undefined;
|
||||
return (CURATED_PROVIDERS as readonly string[]).includes(head) ? head : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask for a model ID. The provider is already chosen, so this takes the bare ID
|
||||
* and the caller pairs it with the provider — pasting a full `<provider>:<model>`
|
||||
* spec just has its redundant prefix dropped.
|
||||
*/
|
||||
async function promptModelId(provider: string, placeholder?: string): Promise<string> {
|
||||
const modelId = await p.text({
|
||||
message: 'Model ID',
|
||||
...(placeholder && { placeholder }),
|
||||
validate: (value) => {
|
||||
if (!value) return 'Model ID is required';
|
||||
const conflicting = conflictingProviderPrefix(provider, value);
|
||||
if (conflicting) return `That model ID is for ${conflicting}, but you selected ${provider}.`;
|
||||
return undefined;
|
||||
},
|
||||
models: { small: models.small, medium: models.medium, large: models.large },
|
||||
};
|
||||
});
|
||||
if (p.isCancel(modelId)) return cancelAndExit();
|
||||
|
||||
return modelId.startsWith(`${provider}:`) ? modelId.slice(provider.length + 1) : modelId;
|
||||
}
|
||||
|
||||
// === Helpers ===
|
||||
|
||||
+170
-97
@@ -8,12 +8,27 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { ensureImage, ensureInfra, randomSuffix, spawnWorker } from '../docker.js';
|
||||
import { buildEnvFlags, loadEnv, validateCredentials } from '../env.js';
|
||||
import { getCredentialsPath, getWorkspacesDir, initHome } from '../home.js';
|
||||
import { isLocal } from '../mode.js';
|
||||
import { resolveConfig, resolveRepo } from '../paths.js';
|
||||
import { setTimeout as sleep } from 'node:timers/promises';
|
||||
import * as p from '@clack/prompts';
|
||||
import { ensureDocker, ensureImage, ensureInfra, randomSuffix, spawnWorker } from '../docker.js';
|
||||
import { buildEnvFlags, loadEnv, resolveHostPiAuthPath, shouldUsePiAuth, validateCredentials } from '../env.js';
|
||||
import { fail } from '../errors.js';
|
||||
import { getWorkspacesDir, initHome } from '../home.js';
|
||||
import { commandPrefix, isLocal } from '../mode.js';
|
||||
import { resolveModelSpec } from '../model-spec.js';
|
||||
import {
|
||||
expandHome,
|
||||
FINAL_REPORT_PDF_FILENAME,
|
||||
INTERNAL_DIR,
|
||||
resolveConfig,
|
||||
resolveRepo,
|
||||
resolveRunFile,
|
||||
} from '../paths.js';
|
||||
import { resolveWorkflowId } from '../session.js';
|
||||
import { displaySplash } from '../splash.js';
|
||||
import { getTerminalOutcome } from '../temporal-client.js';
|
||||
import { stdoutIsTerminal } from '../tty.js';
|
||||
import { tailUntilComplete } from './logs.js';
|
||||
|
||||
export interface StartArgs {
|
||||
url: string;
|
||||
@@ -22,10 +37,34 @@ export interface StartArgs {
|
||||
workspace?: string;
|
||||
output?: string;
|
||||
pipelineTesting: boolean;
|
||||
debug: boolean;
|
||||
keepContainer: boolean;
|
||||
follow: boolean;
|
||||
version: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upgrade a pre-restructure workspace (flat layout, no INTERNAL_DIR) before it is mounted,
|
||||
* so resume finds the old deliverables and their git checkpoints instead of re-running every
|
||||
* agent. For a legacy run every top-level entry is internal, so move them all into INTERNAL_DIR
|
||||
* (a same-filesystem rename carries the deliverables .git along).
|
||||
*/
|
||||
function migrateLegacyWorkspaceLayout(workspacePath: string): void {
|
||||
const legacySessionJson = path.join(workspacePath, 'session.json');
|
||||
const internalPath = path.join(workspacePath, INTERNAL_DIR);
|
||||
if (!fs.existsSync(legacySessionJson) || fs.existsSync(internalPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
fs.mkdirSync(internalPath, { recursive: true });
|
||||
for (const entry of fs.readdirSync(workspacePath)) {
|
||||
if (entry === INTERNAL_DIR) {
|
||||
continue;
|
||||
}
|
||||
fs.renameSync(path.join(workspacePath, entry), path.join(internalPath, entry));
|
||||
}
|
||||
console.log(`Migrated workspace to ${INTERNAL_DIR}/ layout: ${workspacePath}`);
|
||||
}
|
||||
|
||||
export async function start(args: StartArgs): Promise<void> {
|
||||
// 1. Initialize state directories and load env
|
||||
initHome();
|
||||
@@ -34,22 +73,29 @@ export async function start(args: StartArgs): Promise<void> {
|
||||
// 2. Validate credentials
|
||||
const creds = validateCredentials();
|
||||
if (!creds.valid) {
|
||||
console.error(`ERROR: ${creds.error}`);
|
||||
process.exit(1);
|
||||
fail(creds.error ?? 'Invalid credentials');
|
||||
}
|
||||
|
||||
// 3. Resolve paths
|
||||
const repo = resolveRepo(args.repo);
|
||||
const config = args.config ? resolveConfig(args.config) : undefined;
|
||||
|
||||
// Inputs are valid — show the splash before the Docker/Temporal setup work.
|
||||
displaySplash(isLocal() ? undefined : args.version);
|
||||
|
||||
// 4. Ensure workspaces dir is writable by container user (UID 1001)
|
||||
const workspacesDir = getWorkspacesDir();
|
||||
fs.mkdirSync(workspacesDir, { recursive: true });
|
||||
fs.chmodSync(workspacesDir, 0o777);
|
||||
|
||||
// 5. Ensure image (auto-build in dev, pull in npx) and start infra
|
||||
// 5. Ensure Docker and the worker image are available (pull/build prints its own progress).
|
||||
ensureDocker();
|
||||
ensureImage(args.version);
|
||||
await ensureInfra();
|
||||
|
||||
// One spinner spans the whole launch: bringing up Temporal and registering the worker.
|
||||
const spinner = p.spinner();
|
||||
spinner.start('Starting scan');
|
||||
await ensureInfra(spinner);
|
||||
|
||||
// 6. Generate unique task queue and container name
|
||||
const suffix = randomSuffix();
|
||||
@@ -61,12 +107,17 @@ export async function start(args: StartArgs): Promise<void> {
|
||||
args.workspace ?? `${new URL(args.url).hostname.replace(/[^a-zA-Z0-9-]/g, '-')}_shannon-${Date.now()}`;
|
||||
|
||||
// 8. Create writable overlay directories (mounted over :ro repo paths inside container)
|
||||
// Workspace dir must be 0o777 so the container user (UID 1001) can create audit subdirs
|
||||
// The run dir and its INTERNAL_DIR must be 0o777 so the container user can create audit
|
||||
// subdirs and the overlay backing dirs.
|
||||
const workspacePath = path.join(workspacesDir, workspace);
|
||||
const internalPath = path.join(workspacePath, INTERNAL_DIR);
|
||||
fs.mkdirSync(workspacePath, { recursive: true });
|
||||
fs.chmodSync(workspacePath, 0o777);
|
||||
for (const dir of ['deliverables', 'scratchpad', '.playwright-cli']) {
|
||||
const dirPath = path.join(workspacePath, dir);
|
||||
migrateLegacyWorkspaceLayout(workspacePath);
|
||||
fs.mkdirSync(internalPath, { recursive: true });
|
||||
fs.chmodSync(internalPath, 0o777);
|
||||
for (const dir of ['deliverables', 'scratchpad', '.playwright-cli', '.playwright']) {
|
||||
const dirPath = path.join(internalPath, dir);
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
fs.chmodSync(dirPath, 0o777);
|
||||
}
|
||||
@@ -76,16 +127,10 @@ export async function start(args: StartArgs): Promise<void> {
|
||||
for (const dir of ['deliverables', 'scratchpad', '.playwright-cli']) {
|
||||
fs.mkdirSync(path.join(shannonDir, dir), { recursive: true });
|
||||
}
|
||||
|
||||
const credentialsPath = getCredentialsPath();
|
||||
const hasCredentials = fs.existsSync(credentialsPath);
|
||||
|
||||
if (hasCredentials) {
|
||||
process.env.GOOGLE_APPLICATION_CREDENTIALS = '/app/credentials/google-sa-key.json';
|
||||
}
|
||||
fs.mkdirSync(path.join(repo.hostPath, '.playwright'), { recursive: true });
|
||||
|
||||
// 10. Resolve output directory
|
||||
const outputDir = args.output ? path.resolve(args.output) : undefined;
|
||||
const outputDir = args.output ? path.resolve(expandHome(args.output)) : undefined;
|
||||
if (outputDir) {
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
@@ -93,10 +138,7 @@ export async function start(args: StartArgs): Promise<void> {
|
||||
// 11. Resolve prompts directory (local mode only)
|
||||
const promptsDir = isLocal() ? path.resolve('apps/worker/prompts') : undefined;
|
||||
|
||||
// 12. Display splash screen
|
||||
displaySplash(isLocal() ? undefined : args.version);
|
||||
|
||||
// 13. Spawn worker container
|
||||
// 12. Spawn worker container
|
||||
const proc = spawnWorker({
|
||||
version: args.version,
|
||||
url: args.url,
|
||||
@@ -106,29 +148,27 @@ export async function start(args: StartArgs): Promise<void> {
|
||||
containerName,
|
||||
envFlags: buildEnvFlags(),
|
||||
...(config && { config }),
|
||||
...(hasCredentials && { credentials: credentialsPath }),
|
||||
...(promptsDir && { promptsDir }),
|
||||
...(outputDir && { outputDir }),
|
||||
workspace,
|
||||
...(args.pipelineTesting && { pipelineTesting: true }),
|
||||
...(args.debug && { debug: true }),
|
||||
...(args.keepContainer && { keepContainer: true }),
|
||||
...(shouldUsePiAuth() && { piAuthHostPath: resolveHostPiAuthPath() }),
|
||||
});
|
||||
|
||||
// 14. Bail if `docker run -d` itself fails (mount error, image missing, etc.)
|
||||
// Bail if `docker run -d` itself fails (mount error, image missing, etc.)
|
||||
const dockerExitCode = await new Promise<number>((resolve) => {
|
||||
proc.once('exit', (code) => resolve(code ?? 1));
|
||||
proc.once('error', (err) => {
|
||||
console.error(`Failed to start worker: ${err.message}`);
|
||||
resolve(1);
|
||||
});
|
||||
proc.once('error', () => resolve(1));
|
||||
});
|
||||
|
||||
if (dockerExitCode !== 0) {
|
||||
spinner.error('Could not start the scan');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Detect whether this is a fresh workspace or a resume by checking session.json existence
|
||||
const sessionJson = path.join(workspacesDir, workspace, 'session.json');
|
||||
const sessionJson = resolveRunFile(path.join(workspacesDir, workspace), 'session.json');
|
||||
const isResume = fs.existsSync(sessionJson);
|
||||
let initialResumeCount = 0;
|
||||
if (isResume) {
|
||||
@@ -140,62 +180,23 @@ export async function start(args: StartArgs): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// Poll for workflow to register in session.json
|
||||
process.stdout.write('Waiting for workflow to start...');
|
||||
let workflowId = '';
|
||||
let started = false;
|
||||
let attempts = 0;
|
||||
const pollInterval = setInterval(() => {
|
||||
attempts++;
|
||||
if (attempts > 60) {
|
||||
clearInterval(pollInterval);
|
||||
process.stdout.write('\n');
|
||||
console.error('Timeout waiting for workflow to start');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const session = JSON.parse(fs.readFileSync(sessionJson, 'utf-8'));
|
||||
const resumeAttempts: { workflowId: string }[] = session.session?.resumeAttempts ?? [];
|
||||
|
||||
// Fresh: session.json appears with originalWorkflowId. Resume: new resumeAttempts entry.
|
||||
const ready = isResume ? resumeAttempts.length > initialResumeCount : !!session.session?.originalWorkflowId;
|
||||
|
||||
if (ready) {
|
||||
clearInterval(pollInterval);
|
||||
started = true;
|
||||
|
||||
// Latest workflow ID: last resume attempt, or originalWorkflowId for fresh scans
|
||||
workflowId = resumeAttempts.at(-1)?.workflowId ?? session.session?.originalWorkflowId ?? '';
|
||||
|
||||
// Clear waiting line and show info
|
||||
process.stdout.write('\r\x1b[K');
|
||||
printInfo(args, workspace, workflowId, repo.hostPath, workspacesDir);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// File doesn't exist yet
|
||||
}
|
||||
process.stdout.write('.');
|
||||
}, 2000);
|
||||
|
||||
// Stop the worker container only if it hasn't started yet
|
||||
// Stop the worker only if the scan hasn't registered yet (e.g. Ctrl-C mid-startup).
|
||||
let cleaned = false;
|
||||
const cleanup = (): void => {
|
||||
if (cleaned || started) return;
|
||||
cleaned = true;
|
||||
clearInterval(pollInterval);
|
||||
console.log(`\nStopping worker ${containerName}...`);
|
||||
spinner.stop('Stopping scan');
|
||||
try {
|
||||
execFileSync('docker', ['stop', containerName], { stdio: 'pipe' });
|
||||
} catch {
|
||||
// Container may have already exited
|
||||
}
|
||||
if (args.debug) {
|
||||
printDebugHint(containerName);
|
||||
if (args.keepContainer) {
|
||||
printPreservedContainerHint(containerName);
|
||||
}
|
||||
};
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
cleanup();
|
||||
process.exit(0);
|
||||
@@ -205,9 +206,69 @@ export async function start(args: StartArgs): Promise<void> {
|
||||
process.exit(0);
|
||||
});
|
||||
process.on('exit', cleanup);
|
||||
|
||||
// Poll for the workflow to register in session.json; the spinner resolves once it does.
|
||||
spinner.message('Waiting for the scan to start');
|
||||
for (let attempts = 0; attempts < 60; attempts++) {
|
||||
try {
|
||||
const session = JSON.parse(fs.readFileSync(sessionJson, 'utf-8'));
|
||||
const resumeAttempts: { workflowId: string }[] = session.session?.resumeAttempts ?? [];
|
||||
|
||||
// Fresh: session.json appears with originalWorkflowId. Resume: new resumeAttempts entry.
|
||||
const ready = isResume ? resumeAttempts.length > initialResumeCount : !!session.session?.originalWorkflowId;
|
||||
|
||||
if (ready) {
|
||||
started = true;
|
||||
spinner.stop(`Scan started — ${workspace}`);
|
||||
printInfo(args, workspace, repo.hostPath, workspacesDir);
|
||||
if (args.follow) {
|
||||
await followScan(workspace, workspacesDir);
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// File doesn't exist yet
|
||||
}
|
||||
await sleep(2000);
|
||||
}
|
||||
|
||||
spinner.error('Timed out waiting for the scan to start');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function printDebugHint(containerName: string): void {
|
||||
/**
|
||||
* Follow a just-started scan (for `--follow`, aimed at CI): stream its log to completion, then
|
||||
* exit on the workflow outcome — 0 if the assessment ran, 1 if the scan failed. That tracks
|
||||
* whether the pipeline ran, not whether vulnerabilities were found.
|
||||
*/
|
||||
async function followScan(workspace: string, workspacesDir: string): Promise<never> {
|
||||
const logFile = resolveRunFile(path.join(workspacesDir, workspace), 'workflow.log');
|
||||
|
||||
// The worker creates workflow.log as it starts; wait briefly so the first read doesn't
|
||||
// mistake a not-yet-created file for an already-finished scan.
|
||||
for (let attempts = 0; attempts < 30 && !fs.existsSync(logFile); attempts++) {
|
||||
await sleep(1000);
|
||||
}
|
||||
|
||||
if (stdoutIsTerminal()) {
|
||||
console.error('\n Following scan log (Ctrl-C to stop watching):\n');
|
||||
}
|
||||
await tailUntilComplete(logFile);
|
||||
|
||||
const workflowId = resolveWorkflowId(workspace);
|
||||
if (!workflowId) {
|
||||
fail('Scan finished but its workflow id could not be resolved from session.json.');
|
||||
}
|
||||
|
||||
try {
|
||||
const outcome = await getTerminalOutcome(workflowId);
|
||||
process.exit(outcome.kind === 'success' ? 0 : 1);
|
||||
} catch {
|
||||
fail('Could not reach Temporal at 127.0.0.1:7233 to read the scan outcome.');
|
||||
}
|
||||
}
|
||||
|
||||
function printPreservedContainerHint(containerName: string): void {
|
||||
console.log('');
|
||||
console.log(` Worker container preserved: ${containerName}`);
|
||||
console.log(` Inspect logs: docker logs ${containerName}`);
|
||||
@@ -215,35 +276,47 @@ function printDebugHint(containerName: string): void {
|
||||
console.log('');
|
||||
}
|
||||
|
||||
function printInfo(
|
||||
args: StartArgs,
|
||||
workspace: string,
|
||||
workflowId: string,
|
||||
repoPath: string,
|
||||
workspacesDir: string,
|
||||
): void {
|
||||
const logsCmd = isLocal() ? `./shannon logs ${workspace}` : `npx @keygraph/shannon logs ${workspace}`;
|
||||
const reportsPath = path.join(workspacesDir, workspace);
|
||||
function printInfo(args: StartArgs, workspace: string, repoPath: string, workspacesDir: string): void {
|
||||
const interactive = stdoutIsTerminal();
|
||||
|
||||
if (interactive && !args.follow) {
|
||||
console.log(' It runs in the background — you can close this terminal.');
|
||||
console.log('');
|
||||
}
|
||||
|
||||
console.log(` Target: ${args.url}`);
|
||||
console.log(` Repository: ${repoPath}`);
|
||||
console.log(` Repository: ${interactive ? repoPath : path.basename(repoPath)}`);
|
||||
console.log(` Workspace: ${workspace}`);
|
||||
if (args.config) {
|
||||
console.log(` Config: ${path.resolve(args.config)}`);
|
||||
console.log(` Config: ${interactive ? path.resolve(args.config) : path.basename(args.config)}`);
|
||||
}
|
||||
if (args.pipelineTesting) {
|
||||
console.log(' Mode: Pipeline Testing');
|
||||
}
|
||||
console.log('');
|
||||
console.log(' Monitor:');
|
||||
if (workflowId) {
|
||||
console.log(` Web UI: http://localhost:8233/namespaces/default/workflows/${workflowId}`);
|
||||
} else {
|
||||
console.log(' Web UI: http://localhost:8233');
|
||||
|
||||
const spec = resolveModelSpec();
|
||||
if (typeof spec !== 'string') {
|
||||
console.log(` Model: ${spec.providerId}:${spec.modelId}`);
|
||||
}
|
||||
console.log(` Logs: ${logsCmd}`);
|
||||
|
||||
if (!interactive) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reportPath = path.join(workspacesDir, workspace, FINAL_REPORT_PDF_FILENAME);
|
||||
|
||||
// When following, the scan log streams inline next, so the "run these to watch it" hints
|
||||
// would only contradict that.
|
||||
if (!args.follow) {
|
||||
const prefix = commandPrefix();
|
||||
console.log('');
|
||||
console.log(' Watch scan progress:');
|
||||
console.log(` Live logs: ${prefix} logs ${workspace}`);
|
||||
console.log(` Progress: ${prefix} status ${workspace}`);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(' Output:');
|
||||
console.log(` Reports: ${reportsPath}/`);
|
||||
console.log(' Report (when the scan finishes):');
|
||||
console.log(` ${reportPath}`);
|
||||
console.log('');
|
||||
}
|
||||
+188
-16
@@ -1,24 +1,196 @@
|
||||
/**
|
||||
* `shannon status` command — show running workers and Temporal health.
|
||||
* `shannon status <workspace>` — one scan's live progress from Temporal.
|
||||
*
|
||||
* While the scan runs, polls Temporal and redraws the phase/agent tree on a
|
||||
* terminal (a pipe or a finished scan gets a single frame). When the scan reaches
|
||||
* a terminal state, prints the overall result and exits. Reads Temporal directly —
|
||||
* no worker, no session files — so it needs Temporal up and shows scans within its
|
||||
* ~24h retention window.
|
||||
*/
|
||||
|
||||
import { isTemporalReady, listRunningWorkers } from '../docker.js';
|
||||
import { setTimeout as sleep } from 'node:timers/promises';
|
||||
import { fail } from '../errors.js';
|
||||
import { isLocal } from '../mode.js';
|
||||
import { type RenderInput, renderScan } from '../scan/render.js';
|
||||
import { toStatusJson } from '../scan/status-json.js';
|
||||
import { resolveWorkflowId } from '../session.js';
|
||||
import { displaySplash } from '../splash.js';
|
||||
import { describeScan, getTerminalOutcome, queryProgress, type ScanDescription } from '../temporal-client.js';
|
||||
import { stdoutIsTerminal, supportsColor } from '../tty.js';
|
||||
import { getVersion } from '../version.js';
|
||||
|
||||
export function status(): void {
|
||||
// 1. Temporal health
|
||||
const temporalUp = isTemporalReady();
|
||||
console.log(`Temporal: ${temporalUp ? 'running' : 'not running'}`);
|
||||
if (temporalUp) {
|
||||
console.log(' Web UI: http://localhost:8233');
|
||||
const HIDE_CURSOR = '\x1b[?25l';
|
||||
const SHOW_CURSOR = '\x1b[?25h';
|
||||
/** Redraw cadence for the spinner animation; data is refreshed on the slower poll. */
|
||||
const RENDER_MS = 120;
|
||||
const POLL_MS = 1200;
|
||||
|
||||
/** Terminal = anything other than an open, running execution. */
|
||||
function isTerminalStatus(status: string): boolean {
|
||||
return status !== 'RUNNING' && status !== 'UNSPECIFIED';
|
||||
}
|
||||
|
||||
// Match SGR color escapes (ESC[…m) so a line's on-screen width excludes them. Built from the ESC
|
||||
// char code so the source carries no literal control character.
|
||||
const ANSI_PATTERN = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g');
|
||||
|
||||
/**
|
||||
* Physical terminal rows a frame occupies, so the live redraw moves the cursor up by the right
|
||||
* amount. A line wider than the terminal wraps onto extra rows, so counting logical lines alone
|
||||
* undercounts and the redraw drifts downward. Color escapes don't take screen columns, so strip them.
|
||||
*/
|
||||
function physicalRows(frame: string): number {
|
||||
const columns = process.stdout.columns || 80;
|
||||
return frame.split('\n').reduce((rows, line) => {
|
||||
const width = line.replace(ANSI_PATTERN, '').length;
|
||||
return rows + Math.max(1, Math.ceil(width / columns));
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function exitCodeFor(input: RenderInput): number {
|
||||
if (input.temporalStatus === 'FAILED' || input.temporalStatus === 'TIMED_OUT') return 1;
|
||||
if (input.state?.status === 'failed') return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Live view of a running scan: its progress query plus the in-flight agents from describe. */
|
||||
async function buildRunningInput(workspace: string, workflowId: string, desc: ScanDescription): Promise<RenderInput> {
|
||||
const state = await queryProgress(workflowId);
|
||||
return {
|
||||
workspace,
|
||||
workflowId,
|
||||
temporalStatus: desc.status,
|
||||
state,
|
||||
running: desc.runningAgents,
|
||||
...(desc.startedAt !== undefined && { startedAt: desc.startedAt }),
|
||||
};
|
||||
}
|
||||
|
||||
/** Final view of a closed scan: its result (or the failure) plus timing from describe. */
|
||||
async function buildTerminalInput(workspace: string, workflowId: string, desc: ScanDescription): Promise<RenderInput> {
|
||||
const outcome = await getTerminalOutcome(workflowId);
|
||||
const timing = {
|
||||
...(desc.startedAt !== undefined && { startedAt: desc.startedAt }),
|
||||
...(desc.closedAt !== undefined && { endedAt: desc.closedAt }),
|
||||
};
|
||||
if (outcome.kind === 'success') {
|
||||
return { workspace, workflowId, temporalStatus: desc.status, state: outcome.state, running: [], ...timing };
|
||||
}
|
||||
console.log('');
|
||||
return {
|
||||
workspace,
|
||||
workflowId,
|
||||
temporalStatus: desc.status,
|
||||
state: null,
|
||||
running: [],
|
||||
failureMessage: outcome.message,
|
||||
...timing,
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Running workers
|
||||
const workers = listRunningWorkers();
|
||||
if (workers) {
|
||||
console.log('Workers:');
|
||||
console.log(workers);
|
||||
} else {
|
||||
console.log('Workers: none running');
|
||||
function printFrame(input: RenderInput): void {
|
||||
const frame = renderScan(input, {
|
||||
now: Date.now(),
|
||||
color: supportsColor(),
|
||||
unicode: stdoutIsTerminal(),
|
||||
live: false,
|
||||
frame: 0,
|
||||
});
|
||||
process.stdout.write(`${frame}\n`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll Temporal and redraw until the scan reaches a terminal state, then print the
|
||||
* final frame and exit. A fast ticker animates the running spinner off the cached
|
||||
* snapshot; the network poll refreshes that snapshot on a slower cadence.
|
||||
*/
|
||||
async function watch(workspace: string, workflowId: string): Promise<never> {
|
||||
let prevRows = 0;
|
||||
let frame = 0;
|
||||
let cached: RenderInput | null = null;
|
||||
|
||||
const draw = (input: RenderInput, live: boolean): void => {
|
||||
const out = renderScan(input, { now: Date.now(), color: supportsColor(), unicode: true, live, frame });
|
||||
if (prevRows > 0) process.stdout.write(`\x1b[${prevRows}A\x1b[0J`);
|
||||
process.stdout.write(`${out}\n`);
|
||||
prevRows = physicalRows(out);
|
||||
};
|
||||
|
||||
process.on('exit', () => process.stdout.write(SHOW_CURSOR));
|
||||
process.on('SIGINT', () => {
|
||||
process.stdout.write('\n');
|
||||
process.exit(0);
|
||||
});
|
||||
process.stdout.write(HIDE_CURSOR);
|
||||
|
||||
const ticker = setInterval(() => {
|
||||
frame++;
|
||||
if (cached) draw(cached, true);
|
||||
}, RENDER_MS);
|
||||
|
||||
for (;;) {
|
||||
const desc = await describeScan(workflowId);
|
||||
if (!desc) {
|
||||
clearInterval(ticker);
|
||||
fail(`Scan "${workspace}" is no longer in Temporal.`);
|
||||
}
|
||||
|
||||
if (isTerminalStatus(desc.status)) {
|
||||
clearInterval(ticker);
|
||||
const input = await buildTerminalInput(workspace, workflowId, desc);
|
||||
draw(input, false);
|
||||
process.exit(exitCodeFor(input));
|
||||
}
|
||||
|
||||
cached = await buildRunningInput(workspace, workflowId, desc);
|
||||
await sleep(POLL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
/** Read one point-in-time snapshot from Temporal: the terminal result if closed, else live progress. */
|
||||
async function snapshot(workspace: string, workflowId: string, desc: ScanDescription): Promise<RenderInput> {
|
||||
return isTerminalStatus(desc.status)
|
||||
? buildTerminalInput(workspace, workflowId, desc)
|
||||
: buildRunningInput(workspace, workflowId, desc);
|
||||
}
|
||||
|
||||
export async function status(workspace: string, opts: { readonly json: boolean }): Promise<void> {
|
||||
// A resume spawns a new workflow id (recorded in session.json); resolve through there so status
|
||||
// follows the current resume, not the superseded original. Fresh scans: the name is the id.
|
||||
const workflowId = resolveWorkflowId(workspace) ?? workspace;
|
||||
|
||||
let desc: ScanDescription | null;
|
||||
try {
|
||||
desc = await describeScan(workflowId);
|
||||
} catch {
|
||||
fail('Could not reach Temporal at 127.0.0.1:7233.', 'Start Temporal (it comes up with a scan) and try again.');
|
||||
}
|
||||
|
||||
if (!desc) {
|
||||
fail(
|
||||
`No scan found for "${workspace}".`,
|
||||
'',
|
||||
'Scans are visible while running and for ~24h after they finish (Temporal retention).',
|
||||
);
|
||||
}
|
||||
|
||||
// --json is always a single snapshot then exit, even on a TTY — it never enters the live watch loop.
|
||||
if (opts.json) {
|
||||
const input = await snapshot(workspace, workflowId, desc);
|
||||
process.stdout.write(`${JSON.stringify(toStatusJson(input, Date.now()), null, 2)}\n`);
|
||||
process.exit(exitCodeFor(input));
|
||||
}
|
||||
|
||||
// Human-facing views open with the splash; skip it off a real terminal so piped output stays clean.
|
||||
if (stdoutIsTerminal()) {
|
||||
displaySplash(isLocal() ? undefined : getVersion());
|
||||
}
|
||||
|
||||
// A finished scan, or output that isn't a live terminal, gets a single frame.
|
||||
if (isTerminalStatus(desc.status) || !stdoutIsTerminal()) {
|
||||
const input = await snapshot(workspace, workflowId, desc);
|
||||
printFrame(input);
|
||||
process.exit(exitCodeFor(input));
|
||||
}
|
||||
|
||||
await watch(workspace, workflowId);
|
||||
}
|
||||
+118
-12
@@ -1,21 +1,127 @@
|
||||
/**
|
||||
* `shannon stop` command — stop workers and infrastructure.
|
||||
* `shannon stop` command — stop one scan by workspace, or every scan with --all.
|
||||
* Never touches infra or data; to wipe Temporal state entirely, use `shannon reset`.
|
||||
*/
|
||||
|
||||
import * as p from '@clack/prompts';
|
||||
import { stopInfra, stopWorkers } from '../docker.js';
|
||||
import { confirmOrExit } from '../confirm.js';
|
||||
import {
|
||||
anyRunningScanWorkflow,
|
||||
ensureDocker,
|
||||
isTemporalReady,
|
||||
isWorkflowRunning,
|
||||
runningContainers,
|
||||
scanFilter,
|
||||
stopContainers,
|
||||
terminateAllWorkflows,
|
||||
terminateWorkflow,
|
||||
WORKER_FILTER,
|
||||
} from '../docker.js';
|
||||
import { fail, failUsage, warn } from '../errors.js';
|
||||
import { commandPrefix } from '../mode.js';
|
||||
import { resolveWorkflowId } from '../session.js';
|
||||
|
||||
export async function stop(clean: boolean): Promise<void> {
|
||||
if (clean) {
|
||||
const confirmed = await p.confirm({
|
||||
message: 'This will stop all running scans and remove the Temporal data. Continue?',
|
||||
});
|
||||
if (p.isCancel(confirmed) || !confirmed) {
|
||||
p.cancel('Aborted.');
|
||||
process.exit(0);
|
||||
export interface StopOptions {
|
||||
all: boolean;
|
||||
yes: boolean;
|
||||
workspace?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop a single scan. Terminating the workflow both clears Temporal's record and
|
||||
* brings the container down (the worker waits on the workflow result), so that runs
|
||||
* first; `docker stop` is the fallback for the pre-registration window and an
|
||||
* unreachable Temporal. The stop is then verified rather than assumed.
|
||||
*/
|
||||
async function stopSingleScan(workspace: string, yes: boolean): Promise<void> {
|
||||
const workflowId = resolveWorkflowId(workspace);
|
||||
const filter = scanFilter(workspace);
|
||||
const temporalUp = isTemporalReady();
|
||||
|
||||
const initialContainers = runningContainers(filter);
|
||||
const workflowRunning = Boolean(workflowId && temporalUp && isWorkflowRunning(workflowId));
|
||||
|
||||
// Resolve what is running before prompting, so we never confirm a no-op.
|
||||
if (initialContainers.length === 0 && !workflowRunning) {
|
||||
if (!workflowId) {
|
||||
fail(`No scan found for workspace: ${workspace}`);
|
||||
}
|
||||
console.log(`Nothing was running for ${workspace}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
stopWorkers();
|
||||
stopInfra(clean);
|
||||
await confirmOrExit('stop', `Stop the scan "${workspace}"?`, yes);
|
||||
|
||||
const spinner = p.spinner();
|
||||
spinner.start(`Stopping scan ${workspace}`);
|
||||
|
||||
if (workflowId && workflowRunning) {
|
||||
terminateWorkflow(workflowId, `Stopped via shannon stop ${workspace}`);
|
||||
}
|
||||
await stopContainers(runningContainers(filter));
|
||||
|
||||
const stillRunning = runningContainers(filter);
|
||||
if (stillRunning.length > 0) {
|
||||
spinner.error(`Scan ${workspace} may still be running`);
|
||||
console.error(`${stillRunning.length} container(s) did not stop. Retry: ${commandPrefix()} stop ${workspace}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
spinner.stop(`Stopped scan ${workspace}`);
|
||||
|
||||
if (workflowId && temporalUp && isWorkflowRunning(workflowId)) {
|
||||
warn(`scan ${workspace} stopped, but its workflow is still Running in Temporal.`);
|
||||
}
|
||||
}
|
||||
|
||||
async function stopAllScans(yes: boolean): Promise<void> {
|
||||
const temporalUp = isTemporalReady();
|
||||
const initial = runningContainers(WORKER_FILTER);
|
||||
|
||||
// Resolve what is running before prompting, so we never confirm a no-op.
|
||||
if (initial.length === 0) {
|
||||
console.log('No running scans to stop.');
|
||||
return;
|
||||
}
|
||||
|
||||
await confirmOrExit('stop', 'This will stop all running scans. Continue?', yes);
|
||||
|
||||
const spinner = p.spinner();
|
||||
spinner.start('Stopping all scans');
|
||||
|
||||
if (temporalUp) {
|
||||
terminateAllWorkflows('Stopped via shannon stop --all');
|
||||
}
|
||||
await stopContainers(runningContainers(WORKER_FILTER));
|
||||
|
||||
const stillRunning = runningContainers(WORKER_FILTER);
|
||||
if (stillRunning.length > 0) {
|
||||
spinner.error(`Stopped ${initial.length - stillRunning.length} of ${initial.length} scans`);
|
||||
console.error(`${stillRunning.length} container(s) did not stop. Retry: ${commandPrefix()} stop --all`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
spinner.stop(`Stopped ${initial.length} scan${initial.length === 1 ? '' : 's'}`);
|
||||
|
||||
if (temporalUp && anyRunningScanWorkflow()) {
|
||||
warn('some scan workflows are still Running in Temporal — check http://localhost:8233');
|
||||
}
|
||||
}
|
||||
|
||||
export async function stop(opts: StopOptions): Promise<void> {
|
||||
ensureDocker();
|
||||
|
||||
// Validate the target: exactly one of <workspace> or --all.
|
||||
if (opts.all && opts.workspace) {
|
||||
failUsage('Pass a workspace name or --all, not both.');
|
||||
}
|
||||
if (!opts.all && !opts.workspace) {
|
||||
failUsage('Specify which scan to stop: `stop <workspace>`, or `stop --all` to stop every scan.');
|
||||
}
|
||||
|
||||
if (opts.workspace) {
|
||||
await stopSingleScan(opts.workspace, opts.yes);
|
||||
} else {
|
||||
await stopAllScans(opts.yes);
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
/**
|
||||
* `shn uninstall` command — remove ~/.shannon/ after confirmation (npx only).
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import * as p from '@clack/prompts';
|
||||
import { stopInfra, stopWorkers } from '../docker.js';
|
||||
|
||||
const SHANNON_HOME = path.join(os.homedir(), '.shannon');
|
||||
|
||||
export async function uninstall(): Promise<void> {
|
||||
p.intro('Shannon Uninstall');
|
||||
|
||||
if (!fs.existsSync(SHANNON_HOME)) {
|
||||
p.log.info('Nothing to remove. Shannon is not configured on this machine.');
|
||||
p.outro('Done.');
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = await p.confirm({
|
||||
message: 'This will permanently remove all past scan data, saved configurations, and API keys. Continue?',
|
||||
});
|
||||
if (p.isCancel(confirmed) || !confirmed) {
|
||||
p.cancel('Aborted.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Stop any running containers first
|
||||
stopWorkers();
|
||||
stopInfra(false);
|
||||
|
||||
fs.rmSync(SHANNON_HOME, { recursive: true, force: true });
|
||||
p.log.success('All Shannon data has been removed.');
|
||||
p.outro('Shannon has been uninstalled. Run `npx @keygraph/shannon setup` to start fresh.');
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
/**
|
||||
* `shannon workspaces` command — list all workspaces.
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import os from 'node:os';
|
||||
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',
|
||||
'apps/worker/dist/temporal/workspaces.js',
|
||||
],
|
||||
{ stdio: 'inherit', ...(os.platform() === 'win32' && { env: { ...process.env, MSYS_NO_PATHCONV: '1' } }) },
|
||||
);
|
||||
} catch {
|
||||
console.error('ERROR: Failed to list workspaces. Is the Docker image available?');
|
||||
console.error(` Run: docker pull ${image}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
+92
-101
@@ -7,8 +7,16 @@
|
||||
|
||||
import fs from 'node:fs';
|
||||
import { parse as parseTOML } from 'smol-toml';
|
||||
import { fail } from '../errors.js';
|
||||
import { getConfigFile } from '../home.js';
|
||||
import { getMode } from '../mode.js';
|
||||
import {
|
||||
type CuratedProviderId,
|
||||
DEFAULT_MODEL_SPEC,
|
||||
GENERIC_API_KEY_ENV,
|
||||
isCuratedProvider,
|
||||
parseModelSpec,
|
||||
} from '../model-spec.js';
|
||||
|
||||
// === TOML ↔ Env Mapping ===
|
||||
|
||||
@@ -18,47 +26,54 @@ interface ConfigMapping {
|
||||
readonly env: string;
|
||||
readonly toml: string;
|
||||
readonly type: TOMLType;
|
||||
readonly boolFormat?: 'numeric' | 'literal';
|
||||
}
|
||||
|
||||
/** Maps every supported env var to its TOML path (section.key) and expected type. */
|
||||
const CONFIG_MAP: readonly ConfigMapping[] = [
|
||||
// Core
|
||||
{ env: 'CLAUDE_CODE_MAX_OUTPUT_TOKENS', toml: 'core.max_tokens', type: 'number' },
|
||||
// Core — base_url points any provider at a proxy or gateway
|
||||
{ env: 'SHANNON_AI_MODEL', toml: 'core.model', type: 'string' },
|
||||
{ env: 'SHANNON_AI_BASE_URL', toml: 'core.base_url', type: 'string' },
|
||||
|
||||
// Anthropic
|
||||
{ env: 'ANTHROPIC_API_KEY', toml: 'anthropic.api_key', type: 'string' },
|
||||
{ env: 'CLAUDE_CODE_OAUTH_TOKEN', toml: 'anthropic.oauth_token', type: 'string' },
|
||||
|
||||
// OpenAI — format picks the wire API a gateway serves
|
||||
{ env: 'OPENAI_API_KEY', toml: 'openai.api_key', type: 'string' },
|
||||
{ env: 'SHANNON_AI_OPENAI_FORMAT', toml: 'openai.format', type: 'string' },
|
||||
|
||||
// xAI
|
||||
{ env: 'XAI_API_KEY', toml: 'xai.api_key', type: 'string' },
|
||||
|
||||
// Bedrock
|
||||
{ env: 'CLAUDE_CODE_USE_BEDROCK', toml: 'bedrock.use', type: 'boolean' },
|
||||
{ env: 'AWS_REGION', toml: 'bedrock.region', type: 'string' },
|
||||
{ env: 'AWS_BEARER_TOKEN_BEDROCK', toml: 'bedrock.token', type: 'string' },
|
||||
|
||||
// Vertex
|
||||
{ env: 'CLAUDE_CODE_USE_VERTEX', toml: 'vertex.use', type: 'boolean' },
|
||||
{ env: 'CLOUD_ML_REGION', toml: 'vertex.region', type: 'string' },
|
||||
{ env: 'ANTHROPIC_VERTEX_PROJECT_ID', toml: 'vertex.project_id', type: 'string' },
|
||||
{ env: 'GOOGLE_APPLICATION_CREDENTIALS', toml: 'vertex.key_path', type: 'string' },
|
||||
|
||||
// Custom Base URL
|
||||
{ env: 'ANTHROPIC_BASE_URL', toml: 'custom_base_url.base_url', type: 'string' },
|
||||
{ env: 'ANTHROPIC_AUTH_TOKEN', toml: 'custom_base_url.auth_token', type: 'string' },
|
||||
|
||||
// Model tiers
|
||||
{ env: 'ANTHROPIC_SMALL_MODEL', toml: 'models.small', type: 'string' },
|
||||
{ env: 'ANTHROPIC_MEDIUM_MODEL', toml: 'models.medium', type: 'string' },
|
||||
{ env: 'ANTHROPIC_LARGE_MODEL', toml: 'models.large', type: 'string' },
|
||||
// Generic — credential for any provider Shannon does not curate
|
||||
{ env: GENERIC_API_KEY_ENV, toml: 'provider.api_key', type: 'string' },
|
||||
] as const;
|
||||
|
||||
/** TOML section holding each curated provider's credentials, keyed by provider id. */
|
||||
const PROVIDER_SECTIONS: Readonly<Record<CuratedProviderId, string>> = {
|
||||
anthropic: 'anthropic',
|
||||
openai: 'openai',
|
||||
xai: 'xai',
|
||||
'amazon-bedrock': 'bedrock',
|
||||
};
|
||||
|
||||
/** TOML section holding the generic credential for uncurated providers. */
|
||||
const GENERIC_PROVIDER_SECTION = 'provider';
|
||||
|
||||
// === TOML Parsing ===
|
||||
|
||||
type TOMLValue = string | number | boolean;
|
||||
type TOMLSection = Record<string, TOMLValue>;
|
||||
type TOMLConfig = Record<string, TOMLSection>;
|
||||
|
||||
/** Read a nested TOML value by dotted path (e.g. "anthropic.api_key"). */
|
||||
function getTomlValue(config: TOMLConfig, path: string): string | undefined {
|
||||
const [section, key] = path.split('.');
|
||||
/** Read a nested TOML value for a given mapping. */
|
||||
function getTomlValue(config: TOMLConfig, mapping: ConfigMapping): string | undefined {
|
||||
const [section, key] = mapping.toml.split('.');
|
||||
if (!section || !key) return undefined;
|
||||
|
||||
const sectionObj = config[section];
|
||||
@@ -67,8 +82,10 @@ function getTomlValue(config: TOMLConfig, path: string): string | undefined {
|
||||
const value = sectionObj[key];
|
||||
if (value === undefined || value === null) return undefined;
|
||||
|
||||
// NOTE: env.ts checks bedrock/vertex via `=== '1'`, so booleans must map to "1"/"0"
|
||||
if (typeof value === 'boolean') return value ? '1' : '0';
|
||||
if (typeof value === 'boolean') {
|
||||
if (mapping.boolFormat === 'literal') return value ? 'true' : 'false';
|
||||
return value ? '1' : '0';
|
||||
}
|
||||
|
||||
return String(value);
|
||||
}
|
||||
@@ -84,8 +101,9 @@ function loadTOML(): TOMLConfig | null {
|
||||
const mode = fs.statSync(configPath).mode;
|
||||
if (mode & 0o077) {
|
||||
const actual = (mode & 0o777).toString(8).padStart(3, '0');
|
||||
console.error(`\nInsecure permissions (${actual}) on ${configPath}. Run: chmod 600 ${configPath}\n`);
|
||||
process.exit(1);
|
||||
fail(
|
||||
`Your config file is readable by other users on this machine (${actual}). Lock it down: chmod 600 ${configPath}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,9 +112,7 @@ function loadTOML(): TOMLConfig | null {
|
||||
return parseTOML(content) as TOMLConfig;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error(`\nFailed to parse ${configPath}: ${message}`);
|
||||
console.error(`\nRun 'npx @keygraph/shannon setup' to reconfigure.\n`);
|
||||
process.exit(1);
|
||||
fail(`Failed to parse ${configPath}: ${message}`, `Run 'npx @keygraph/shannon setup' to reconfigure.`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,62 +135,42 @@ function buildSchema(): Map<string, Map<string, TOMLType>> {
|
||||
return schema;
|
||||
}
|
||||
|
||||
/** Check that a provider section has all required fields and dependencies. */
|
||||
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);
|
||||
|
||||
switch (provider) {
|
||||
case 'anthropic':
|
||||
if (!keys.includes('api_key') && !keys.includes('oauth_token')) {
|
||||
errors.push('[anthropic] requires either api_key or oauth_token');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'custom_base_url': {
|
||||
const required = ['base_url', 'auth_token'];
|
||||
const missing = required.filter((k) => !keys.includes(k));
|
||||
if (missing.length > 0) {
|
||||
errors.push(`[custom_base_url] missing required keys: ${missing.join(', ')}`);
|
||||
}
|
||||
break;
|
||||
/**
|
||||
* Check that the section backing the selected provider carries a usable
|
||||
* credential. `core.model` names the provider, so only that section is required;
|
||||
* other providers' sections are ignored and never forwarded. An uncurated
|
||||
* provider draws its credential from the generic [provider] section.
|
||||
*/
|
||||
function validateProviderFields(config: TOMLConfig, providerId: string, errors: string[]): void {
|
||||
if (!isCuratedProvider(providerId)) {
|
||||
const section = config[GENERIC_PROVIDER_SECTION] as Record<string, unknown> | undefined;
|
||||
if (!section || !Object.keys(section).includes('api_key')) {
|
||||
errors.push(`[${GENERIC_PROVIDER_SECTION}] requires api_key for provider "${providerId}"`);
|
||||
}
|
||||
|
||||
case 'bedrock': {
|
||||
const required = ['use', 'region', 'token'];
|
||||
const missing = required.filter((k) => !keys.includes(k));
|
||||
if (missing.length > 0) {
|
||||
errors.push(`[bedrock] missing required keys: ${missing.join(', ')}`);
|
||||
}
|
||||
validateModelTiers(config, 'bedrock', errors);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'vertex': {
|
||||
const required = ['use', 'region', 'project_id', 'key_path'];
|
||||
const missing = required.filter((k) => !keys.includes(k));
|
||||
if (missing.length > 0) {
|
||||
errors.push(`[vertex] missing required keys: ${missing.join(', ')}`);
|
||||
}
|
||||
validateModelTiers(config, 'vertex', errors);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Bedrock and Vertex require a [models] section with all three tiers. */
|
||||
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`);
|
||||
return;
|
||||
}
|
||||
|
||||
const required = ['small', 'medium', 'large'];
|
||||
const missing = required.filter((k) => !Object.keys(models).includes(k));
|
||||
if (missing.length > 0) {
|
||||
errors.push(`[models] missing required keys for ${provider}: ${missing.join(', ')}`);
|
||||
const sectionName = PROVIDER_SECTIONS[providerId];
|
||||
const section = config[sectionName] as Record<string, unknown> | undefined;
|
||||
const keys = section ? Object.keys(section) : [];
|
||||
|
||||
if (providerId === 'amazon-bedrock') {
|
||||
const missing = ['region', 'token'].filter((k) => !keys.includes(k));
|
||||
if (missing.length > 0) {
|
||||
errors.push(`[bedrock] missing required keys: ${missing.join(', ')}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (providerId === 'anthropic') {
|
||||
if (!keys.includes('api_key') && !keys.includes('oauth_token')) {
|
||||
errors.push('[anthropic] requires either api_key or oauth_token');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!keys.includes('api_key')) {
|
||||
errors.push(`[${sectionName}] requires api_key`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,23 +218,19 @@ function validateConfig(config: TOMLConfig): string[] {
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Only one provider section allowed (ignore empty sections)
|
||||
const PROVIDER_SECTIONS = ['anthropic', 'custom_base_url', 'bedrock', 'vertex'] as const;
|
||||
const present = PROVIDER_SECTIONS.filter((s) => {
|
||||
const section = config[s];
|
||||
return section && typeof section === 'object' && Object.keys(section).length > 0;
|
||||
});
|
||||
if (present.length > 1) {
|
||||
errors.push(
|
||||
`Multiple providers configured: [${present.join('], [')}]. Only one provider section is allowed at a time`,
|
||||
);
|
||||
// 4. core.model must parse and name a supported provider
|
||||
const modelValue = config.core?.model;
|
||||
if (modelValue !== undefined && typeof modelValue !== 'string') {
|
||||
return errors;
|
||||
}
|
||||
const spec = parseModelSpec(modelValue || DEFAULT_MODEL_SPEC);
|
||||
if (typeof spec === 'string') {
|
||||
errors.push(`[core].model — ${spec}`);
|
||||
return errors;
|
||||
}
|
||||
|
||||
// 5. Required fields per provider
|
||||
const singleProvider = present.length === 1 ? present[0] : undefined;
|
||||
if (singleProvider) {
|
||||
validateProviderFields(config, singleProvider, errors);
|
||||
}
|
||||
// 5. The selected provider's section must carry a credential
|
||||
validateProviderFields(config, spec.providerId, errors);
|
||||
|
||||
return errors;
|
||||
}
|
||||
@@ -262,18 +254,17 @@ export function resolveConfig(): void {
|
||||
// Validate before injecting
|
||||
const errors = validateConfig(toml);
|
||||
if (errors.length > 0) {
|
||||
console.error('\nInvalid configuration:');
|
||||
for (const err of errors) {
|
||||
console.error(` - ${err}`);
|
||||
}
|
||||
console.error(`\nRun 'shn setup' to reconfigure.\n`);
|
||||
process.exit(1);
|
||||
fail(
|
||||
'Invalid configuration:',
|
||||
...errors.map((err) => ` - ${err}`),
|
||||
`Run 'npx @keygraph/shannon setup' to reconfigure.`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const mapping of CONFIG_MAP) {
|
||||
if (process.env[mapping.env]) continue;
|
||||
|
||||
const value = getTomlValue(toml, mapping.toml);
|
||||
const value = getTomlValue(toml, mapping);
|
||||
if (value) {
|
||||
process.env[mapping.env] = value;
|
||||
}
|
||||
|
||||
@@ -8,12 +8,13 @@ import { getConfigFile } from '../home.js';
|
||||
// === Types ===
|
||||
|
||||
export interface ShannonConfig {
|
||||
core?: { max_tokens?: number };
|
||||
core?: { model?: string; base_url?: string };
|
||||
anthropic?: { api_key?: string; oauth_token?: string };
|
||||
custom_base_url?: { base_url?: string; auth_token?: string };
|
||||
bedrock?: { use?: boolean; region?: string; token?: string };
|
||||
vertex?: { use?: boolean; region?: string; project_id?: string; key_path?: string };
|
||||
models?: { small?: string; medium?: string; large?: string };
|
||||
openai?: { api_key?: string; format?: string };
|
||||
xai?: { api_key?: string };
|
||||
bedrock?: { region?: string; token?: string };
|
||||
/** Generic credential for any provider Shannon does not curate. Maps to SHANNON_AI_API_KEY. */
|
||||
provider?: { api_key?: string };
|
||||
}
|
||||
|
||||
// === File Operations ===
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Shared confirmation prompt for destructive or batch commands.
|
||||
*
|
||||
* `stop` and `reset` gate their action behind the same "confirm unless --yes"
|
||||
* flow. Centralizing it here keeps the behavior identical across commands and
|
||||
* impossible to change in only one place by accident.
|
||||
*/
|
||||
|
||||
import * as p from '@clack/prompts';
|
||||
import { requireInteractive } from './tty.js';
|
||||
|
||||
/**
|
||||
* Ask the user to confirm an action, unless `yes` was passed. Off a TTY without
|
||||
* `--yes`, fails fast rather than hanging on a prompt. Exits 0 if the user declines.
|
||||
*/
|
||||
export async function confirmOrExit(command: string, message: string, yes: boolean): Promise<void> {
|
||||
if (yes) {
|
||||
return;
|
||||
}
|
||||
|
||||
requireInteractive(command, 'Re-run with --yes to skip this confirmation.');
|
||||
const confirmed = await p.confirm({ message });
|
||||
if (p.isCancel(confirmed) || !confirmed) {
|
||||
p.cancel('Aborted.');
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Severe-tier confirmation: the user must type `word` exactly to proceed. Unlike
|
||||
* `confirmOrExit` there is no `--yes` bypass. Off a TTY it fails fast; exits 0 if declined.
|
||||
*/
|
||||
export async function confirmByTyping(command: string, word: string): Promise<void> {
|
||||
requireInteractive(command, `'${command}' cannot be run non-interactively.`);
|
||||
const typed = await p.text({
|
||||
message: `Type ${word} to confirm — this cannot be undone:`,
|
||||
validate: (value) => (value === word ? undefined : `Type ${word} to proceed, or press Ctrl-C to abort.`),
|
||||
});
|
||||
if (p.isCancel(typed) || typed !== word) {
|
||||
p.cancel('Aborted.');
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
+259
-67
@@ -7,21 +7,41 @@
|
||||
|
||||
import { type ChildProcess, execFileSync, spawn } from 'node:child_process';
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { setTimeout as sleep } from 'node:timers/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { getMode } from './mode.js';
|
||||
import type { SpinnerResult } from '@clack/prompts';
|
||||
import { envBool, PI_AUTH_CONTAINER_PATH } from './env.js';
|
||||
import { fail } from './errors.js';
|
||||
import { getMode, isDevMode } from './mode.js';
|
||||
import { INTERNAL_DIR } from './paths.js';
|
||||
import { runStep, spawnCaptured, surfaceOutput } from './ui.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const NPX_IMAGE_REPO = 'keygraph/shannon';
|
||||
const DEV_IMAGE = 'shannon-worker';
|
||||
|
||||
/** Docker label stamped on each worker container, mapping it back to its workspace so a single scan can be stopped by name. */
|
||||
const WORKSPACE_LABEL = 'shannon.workspace';
|
||||
|
||||
export function getWorkerImage(version: string): string {
|
||||
return getMode() === 'local' ? DEV_IMAGE : `${NPX_IMAGE_REPO}:${version}`;
|
||||
}
|
||||
|
||||
/** True when the working directory supplies a Dockerfile and build context. */
|
||||
export function canBuildImage(): boolean {
|
||||
if (getMode() === 'local') return true;
|
||||
if (!isDevMode()) return false;
|
||||
|
||||
const hasDockerfile = fs.existsSync(path.resolve('Dockerfile'));
|
||||
const hasCompose = fs.existsSync(path.resolve('docker-compose.yml'));
|
||||
|
||||
return hasDockerfile && hasCompose;
|
||||
}
|
||||
|
||||
function getComposeFile(): string {
|
||||
return getMode() === 'local'
|
||||
? path.resolve('docker-compose.yml')
|
||||
@@ -52,80 +72,116 @@ function runOutput(cmd: string, args: string[]): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** Run a command asynchronously, resolving true on success. Never rejects. */
|
||||
function spawnQuiet(cmd: string, args: string[]): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(cmd, args, { stdio: 'ignore' });
|
||||
child.on('close', (code) => resolve(code === 0));
|
||||
child.on('error', () => resolve(false));
|
||||
});
|
||||
}
|
||||
|
||||
const TEMPORAL_CONTAINER = 'shannon-temporal';
|
||||
const TEMPORAL_ADDRESS = 'localhost:7233';
|
||||
|
||||
/** Query matching every running pentest scan workflow. */
|
||||
const RUNNING_SCAN_QUERY = "ExecutionStatus = 'Running' AND WorkflowType = 'pentestPipelineWorkflow'";
|
||||
|
||||
/** Build `docker exec` args for a `temporal` CLI command run inside the Temporal container. */
|
||||
function temporalCmd(...args: string[]): string[] {
|
||||
return ['exec', TEMPORAL_CONTAINER, 'temporal', ...args, '--address', TEMPORAL_ADDRESS];
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify Docker is installed and its daemon is running, exiting otherwise.
|
||||
* `docker info` succeeds only when both are true. Call this before any command
|
||||
* that shells out to Docker.
|
||||
*/
|
||||
export function ensureDocker(): void {
|
||||
try {
|
||||
execFileSync('docker', ['info'], { stdio: 'pipe' });
|
||||
} catch {
|
||||
fail(
|
||||
'Docker must be installed and running. Start Docker and try again.',
|
||||
'Install Docker: https://docs.docker.com/get-docker/',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Temporal is running and healthy.
|
||||
*/
|
||||
export function isTemporalReady(): boolean {
|
||||
const output = runOutput('docker', [
|
||||
'exec',
|
||||
'shannon-temporal',
|
||||
'temporal',
|
||||
'operator',
|
||||
'cluster',
|
||||
'health',
|
||||
'--address',
|
||||
'localhost:7233',
|
||||
]);
|
||||
const output = runOutput('docker', temporalCmd('operator', 'cluster', 'health'));
|
||||
return output.includes('SERVING');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure Temporal is running via compose.
|
||||
*/
|
||||
export async function ensureInfra(): Promise<void> {
|
||||
export async function ensureInfra(spinner: SpinnerResult): Promise<void> {
|
||||
if (isTemporalReady()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Drive the caller's spinner — the whole "start" flow is one spinner, not several.
|
||||
spinner.message('Starting Temporal');
|
||||
const composeFile = getComposeFile();
|
||||
console.log('Starting Shannon infrastructure...');
|
||||
execFileSync('docker', ['compose', '-f', composeFile, 'up', '-d'], { stdio: 'inherit' });
|
||||
const result = await spawnCaptured('docker', ['compose', '-f', composeFile, 'up', '-d']);
|
||||
if (!result.ok) {
|
||||
spinner.error('Could not start Temporal');
|
||||
surfaceOutput(result.output);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('Waiting for Temporal to be ready...');
|
||||
spinner.message('Waiting for Temporal to be ready');
|
||||
for (let i = 0; i < 30; i++) {
|
||||
if (isTemporalReady()) {
|
||||
console.log('Temporal is ready!');
|
||||
return;
|
||||
}
|
||||
await sleep(2000);
|
||||
}
|
||||
console.error('Timeout waiting for Temporal');
|
||||
|
||||
spinner.error('Temporal did not become ready in time');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the worker image locally (local mode only).
|
||||
* Build the worker image from the repository, tagged with the name this mode
|
||||
* resolves at run time.
|
||||
*/
|
||||
export function buildImage(noCache: boolean): void {
|
||||
console.log(`Building ${DEV_IMAGE}...`);
|
||||
export function buildImage(noCache: boolean, version: string): void {
|
||||
const image = getWorkerImage(version);
|
||||
console.log(`Building ${image}...`);
|
||||
const args = ['build'];
|
||||
if (noCache) args.push('--no-cache');
|
||||
args.push('-t', DEV_IMAGE, '.');
|
||||
args.push('-t', image, '.');
|
||||
execFileSync('docker', args, { stdio: 'inherit' });
|
||||
console.log(`Build complete: ${DEV_IMAGE}`);
|
||||
console.log(`Build complete: ${image}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the worker image is available.
|
||||
* Local mode: auto-builds if missing. NPX mode: pulls from Docker Hub.
|
||||
* Buildable checkout: auto-builds if missing. Otherwise: pulls from Docker Hub.
|
||||
*/
|
||||
export function ensureImage(version: string): void {
|
||||
const image = getWorkerImage(version);
|
||||
const exists = runQuiet('docker', ['image', 'inspect', image]);
|
||||
if (exists) return;
|
||||
|
||||
if (getMode() === 'local') {
|
||||
console.log('Worker image not found, building...');
|
||||
buildImage(false);
|
||||
if (canBuildImage()) {
|
||||
console.log('Shannon image not found, building...');
|
||||
buildImage(false, version);
|
||||
} else {
|
||||
console.log(`Pulling ${image}...`);
|
||||
try {
|
||||
execFileSync('docker', ['pull', image], { stdio: 'inherit' });
|
||||
} catch {
|
||||
console.error(`\nERROR: Failed to pull ${image}`);
|
||||
console.error('The image may not be available for your platform yet.');
|
||||
console.error('Check https://hub.docker.com/r/keygraph/shannon for available tags.');
|
||||
process.exit(1);
|
||||
fail(
|
||||
`Failed to pull ${image}`,
|
||||
'The image may not be available for your platform yet.',
|
||||
'Check https://hub.docker.com/r/keygraph/shannon for available tags.',
|
||||
);
|
||||
}
|
||||
pruneOldImages(version);
|
||||
}
|
||||
@@ -145,6 +201,87 @@ function addHostFlag(): string[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Names whose standard IPs aren't covered by `shouldSkipHostsIp`. Loopback names
|
||||
* stay because their IPs (127.x, ::1) get rewritten — not skipped. Others like
|
||||
* `broadcasthost` and `ip6-mcastprefix` are intentionally omitted: their IPs
|
||||
* (255.255.255.255, ff00::/8) are already dropped at the IP filter.
|
||||
*/
|
||||
const HOSTS_SKIP_NAMES = new Set([
|
||||
'localhost',
|
||||
'ip6-localhost',
|
||||
'ip6-loopback',
|
||||
'ip6-localnet',
|
||||
'host.docker.internal',
|
||||
'gateway.docker.internal',
|
||||
'kubernetes.docker.internal',
|
||||
]);
|
||||
|
||||
function isLoopbackIp(ip: string): boolean {
|
||||
return ip.startsWith('127.') || ip === '::1';
|
||||
}
|
||||
|
||||
function shouldSkipHostsIp(ip: string): boolean {
|
||||
if (ip === '0.0.0.0' || ip === '255.255.255.255') return true;
|
||||
// Cloud metadata range — consistent with Shannon's SSRF guard
|
||||
if (ip.startsWith('169.254.')) return true;
|
||||
const lower = ip.toLowerCase();
|
||||
if (lower.startsWith('fe80:') || lower.startsWith('ff')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function shouldSkipHostsName(name: string, hostname: string): boolean {
|
||||
const lower = name.toLowerCase();
|
||||
if (HOSTS_SKIP_NAMES.has(lower)) return true;
|
||||
if (lower === hostname.toLowerCase()) return true;
|
||||
if (lower.endsWith('.localhost')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the host's /etc/hosts and emit --add-host flags so the worker resolves
|
||||
* user-added entries the same way. Loopback IPs (127.x, ::1) are rewritten to
|
||||
* `host-gateway` so they target the host's loopback instead of the container's.
|
||||
*/
|
||||
function forwardEtcHostsFlags(): string[] {
|
||||
if (!envBool('SHANNON_FORWARD_HOSTS', true)) return [];
|
||||
if (os.platform() === 'win32') return [];
|
||||
|
||||
let content: string;
|
||||
try {
|
||||
content = fs.readFileSync('/etc/hosts', 'utf-8');
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const hostname = os.hostname();
|
||||
const flags: string[] = [];
|
||||
|
||||
for (const rawLine of content.split('\n')) {
|
||||
const hashIdx = rawLine.indexOf('#');
|
||||
const line = (hashIdx >= 0 ? rawLine.slice(0, hashIdx) : rawLine).trim();
|
||||
if (!line) continue;
|
||||
|
||||
const tokens = line
|
||||
.split(' ')
|
||||
.flatMap((t) => t.split('\t'))
|
||||
.filter(Boolean);
|
||||
const ip = tokens[0];
|
||||
const names = tokens.slice(1);
|
||||
if (!ip || names.length === 0) continue;
|
||||
if (shouldSkipHostsIp(ip)) continue;
|
||||
|
||||
const targetIp = isLoopbackIp(ip) ? 'host-gateway' : ip;
|
||||
const formattedIp = targetIp.includes(':') ? `[${targetIp}]` : targetIp;
|
||||
for (const name of names) {
|
||||
if (shouldSkipHostsName(name, hostname)) continue;
|
||||
flags.push('--add-host', `${name}:${formattedIp}`);
|
||||
}
|
||||
}
|
||||
|
||||
return flags;
|
||||
}
|
||||
|
||||
export interface WorkerOptions {
|
||||
version: string;
|
||||
url: string;
|
||||
@@ -154,28 +291,34 @@ export interface WorkerOptions {
|
||||
containerName: string;
|
||||
envFlags: string[];
|
||||
config?: { hostPath: string; containerPath: string };
|
||||
credentials?: string;
|
||||
promptsDir?: string;
|
||||
outputDir?: string;
|
||||
workspace: string;
|
||||
pipelineTesting?: boolean;
|
||||
debug?: boolean;
|
||||
keepContainer?: boolean;
|
||||
piAuthHostPath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn the worker container in detached mode and return the process.
|
||||
* When `opts.debug` is true, omits `--rm` so the container persists for log inspection.
|
||||
* When `opts.keepContainer` is true, omits `--rm` so the container persists for log inspection.
|
||||
*/
|
||||
export function spawnWorker(opts: WorkerOptions): ChildProcess {
|
||||
const args = ['run', '-d'];
|
||||
if (!opts.debug) {
|
||||
if (!opts.keepContainer) {
|
||||
args.push('--rm');
|
||||
}
|
||||
args.push('--name', opts.containerName, '--network', 'shannon-net');
|
||||
|
||||
// Tag with the workspace so `stop <workspace>` can target this scan's container
|
||||
args.push('--label', `${WORKSPACE_LABEL}=${opts.workspace}`);
|
||||
|
||||
// Add host flag for Linux
|
||||
args.push(...addHostFlag());
|
||||
|
||||
// Forward user-added /etc/hosts entries into the worker
|
||||
args.push(...forwardEtcHostsFlags());
|
||||
|
||||
// UID remapping for Linux bind mounts
|
||||
if (os.platform() === 'linux' && process.getuid && process.getgid) {
|
||||
args.push('-e', `SHANNON_HOST_UID=${process.getuid()}`, '-e', `SHANNON_HOST_GID=${process.getgid()}`);
|
||||
@@ -185,11 +328,13 @@ export function spawnWorker(opts: WorkerOptions): ChildProcess {
|
||||
args.push('-v', `${opts.workspacesDir}:/app/workspaces`);
|
||||
args.push('-v', `${opts.repo.hostPath}:${opts.repo.containerPath}:ro`);
|
||||
|
||||
// Writable overlays: shadow .shannon/ inside the :ro repo with workspace-backed dirs
|
||||
const workspacePath = path.join(opts.workspacesDir, opts.workspace);
|
||||
args.push('-v', `${path.join(workspacePath, 'deliverables')}:${opts.repo.containerPath}/.shannon/deliverables`);
|
||||
args.push('-v', `${path.join(workspacePath, 'scratchpad')}:${opts.repo.containerPath}/.shannon/scratchpad`);
|
||||
args.push('-v', `${path.join(workspacePath, '.playwright-cli')}:${opts.repo.containerPath}/.shannon/.playwright-cli`);
|
||||
// Writable overlays: shadow .shannon/ and .playwright/ inside the :ro repo with workspace-backed
|
||||
// dirs, nested under the run's INTERNAL_DIR. Container paths are unchanged.
|
||||
const internalPath = path.join(opts.workspacesDir, opts.workspace, INTERNAL_DIR);
|
||||
args.push('-v', `${path.join(internalPath, 'deliverables')}:${opts.repo.containerPath}/.shannon/deliverables`);
|
||||
args.push('-v', `${path.join(internalPath, 'scratchpad')}:${opts.repo.containerPath}/.shannon/scratchpad`);
|
||||
args.push('-v', `${path.join(internalPath, '.playwright-cli')}:${opts.repo.containerPath}/.shannon/.playwright-cli`);
|
||||
args.push('-v', `${path.join(internalPath, '.playwright')}:${opts.repo.containerPath}/.playwright`);
|
||||
|
||||
// Local mode: mount prompts for live editing
|
||||
if (opts.promptsDir) {
|
||||
@@ -205,9 +350,9 @@ export function spawnWorker(opts: WorkerOptions): ChildProcess {
|
||||
args.push('-v', `${opts.outputDir}:/app/output`);
|
||||
}
|
||||
|
||||
// Mount credentials file to fixed container path
|
||||
if (opts.credentials) {
|
||||
args.push('-v', `${opts.credentials}:/app/credentials/google-sa-key.json:ro`);
|
||||
// Reuse the host's pi credentials: mount only the auth file, allowing token refreshes to persist.
|
||||
if (opts.piAuthHostPath) {
|
||||
args.push('-v', `${opts.piAuthHostPath}:${PI_AUTH_CONTAINER_PATH}`);
|
||||
}
|
||||
|
||||
// Environment
|
||||
@@ -242,26 +387,86 @@ export function spawnWorker(opts: WorkerOptions): ChildProcess {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all running shannon-worker-* containers.
|
||||
*/
|
||||
export function stopWorkers(): void {
|
||||
const workers = runOutput('docker', ['ps', '-q', '--filter', 'name=shannon-worker-']);
|
||||
if (!workers) return;
|
||||
/** `docker ps --filter` args matching every running worker container. */
|
||||
export const WORKER_FILTER: readonly string[] = ['--filter', 'name=shannon-worker-'];
|
||||
|
||||
const ids = workers.split('\n').filter(Boolean);
|
||||
console.log('Stopping worker containers...');
|
||||
execFileSync('docker', ['stop', ...ids], { stdio: 'inherit' });
|
||||
/** `docker ps --filter` args matching one scan's worker container(s), by workspace label. */
|
||||
export function scanFilter(workspace: string): readonly string[] {
|
||||
return ['--filter', `label=${WORKSPACE_LABEL}=${workspace}`];
|
||||
}
|
||||
|
||||
/**
|
||||
* Tear down the compose stack.
|
||||
* IDs of running containers matching the filter. Re-querying this after a stop is
|
||||
* the authoritative check for whether containers actually stopped — `docker stop`'s
|
||||
* exit code can't distinguish "already gone" from "failed to stop".
|
||||
*/
|
||||
export function stopInfra(clean: boolean): void {
|
||||
export function runningContainers(filter: readonly string[]): string[] {
|
||||
const output = runOutput('docker', ['ps', '-q', ...filter]);
|
||||
return output.split('\n').filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop containers by ID, tolerating any that vanished between being listed and
|
||||
* stopped (a `--rm` worker exiting is success, not an error). Async so a spinner
|
||||
* can animate during docker's graceful-shutdown wait.
|
||||
*/
|
||||
export async function stopContainers(ids: string[]): Promise<void> {
|
||||
await Promise.all(ids.map((id) => spawnQuiet('docker', ['stop', id])));
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminate a Temporal workflow so a stopped scan doesn't linger as a running
|
||||
* workflow with no worker. Best-effort: returns false if Temporal is unreachable
|
||||
* or the workflow already closed. Requires Temporal to be up (guard with isTemporalReady).
|
||||
*/
|
||||
export function terminateWorkflow(workflowId: string, reason: string): boolean {
|
||||
return runQuiet('docker', temporalCmd('workflow', 'terminate', '--workflow-id', workflowId, '--reason', reason));
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminate every running pentest workflow in one batch, so `stop --all` doesn't
|
||||
* leave workflows running with no worker. Best-effort: returns false if Temporal
|
||||
* is unreachable. Requires Temporal to be up (guard with isTemporalReady).
|
||||
*/
|
||||
export function terminateAllWorkflows(reason: string): boolean {
|
||||
return runQuiet(
|
||||
'docker',
|
||||
temporalCmd('workflow', 'terminate', '--query', RUNNING_SCAN_QUERY, '--reason', reason, '--yes'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a specific workflow is still in the Running state. Re-querying this after
|
||||
* a terminate verifies it actually took effect, rather than trusting the terminate
|
||||
* command's exit code. Requires Temporal to be up (guard with isTemporalReady).
|
||||
*/
|
||||
export function isWorkflowRunning(workflowId: string): boolean {
|
||||
const query = `WorkflowId = '${workflowId}' AND ExecutionStatus = 'Running'`;
|
||||
const output = runOutput('docker', temporalCmd('workflow', 'list', '--query', query));
|
||||
return output.includes(workflowId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any pentest scan workflow is still Running — the `stop --all` counterpart
|
||||
* to isWorkflowRunning. Requires Temporal to be up (guard with isTemporalReady).
|
||||
*/
|
||||
export function anyRunningScanWorkflow(): boolean {
|
||||
const output = runOutput('docker', temporalCmd('workflow', 'list', '--query', RUNNING_SCAN_QUERY));
|
||||
return output.includes('pentestPipelineWorkflow');
|
||||
}
|
||||
|
||||
/**
|
||||
* Tear down the compose stack. When `clean` is set, volumes are removed too.
|
||||
*/
|
||||
export async function stopInfra(clean: boolean): Promise<void> {
|
||||
const composeFile = getComposeFile();
|
||||
const args = ['compose', '-f', composeFile, 'down'];
|
||||
if (clean) args.push('-v');
|
||||
execFileSync('docker', args, { stdio: 'inherit' });
|
||||
const label = clean ? 'Removing Temporal data and volumes' : 'Stopping Temporal';
|
||||
const step = await runStep(label, 'docker', args);
|
||||
if (!step.ok) {
|
||||
fail(`${label} failed. See the output above.`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -277,16 +482,3 @@ function pruneOldImages(currentVersion: string): void {
|
||||
runQuiet('docker', ['rmi', `${NPX_IMAGE_REPO}:${tag}`]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List running worker containers.
|
||||
*/
|
||||
export function listRunningWorkers(): string {
|
||||
return runOutput('docker', [
|
||||
'ps',
|
||||
'--filter',
|
||||
'name=shannon-worker-',
|
||||
'--format',
|
||||
'table {{.Names}}\t{{.Status}}\t{{.RunningFor}}',
|
||||
]);
|
||||
}
|
||||
+140
-96
@@ -5,29 +5,73 @@
|
||||
* NPX mode: fills gaps from ~/.shannon/config.toml (no .env).
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import dotenv from 'dotenv';
|
||||
import { resolveConfig } from './config/resolver.js';
|
||||
import { getMode } from './mode.js';
|
||||
import {
|
||||
CURATED_PROVIDERS,
|
||||
type CuratedProviderId,
|
||||
GENERIC_API_KEY_ENV,
|
||||
isCuratedProvider,
|
||||
PROVIDER_API_KEY_ENV,
|
||||
PROVIDER_CREDENTIAL_HINT,
|
||||
PROVIDER_EXTRA_ENV,
|
||||
resolveModelSpec,
|
||||
} from './model-spec.js';
|
||||
|
||||
/** Environment variables forwarded to worker containers. */
|
||||
const FORWARD_VARS = [
|
||||
'ANTHROPIC_API_KEY',
|
||||
'ANTHROPIC_BASE_URL',
|
||||
'ANTHROPIC_AUTH_TOKEN',
|
||||
'CLAUDE_CODE_OAUTH_TOKEN',
|
||||
'CLAUDE_CODE_USE_BEDROCK',
|
||||
'AWS_REGION',
|
||||
'AWS_BEARER_TOKEN_BEDROCK',
|
||||
'CLAUDE_CODE_USE_VERTEX',
|
||||
'CLOUD_ML_REGION',
|
||||
'ANTHROPIC_VERTEX_PROJECT_ID',
|
||||
'GOOGLE_APPLICATION_CREDENTIALS',
|
||||
'ANTHROPIC_SMALL_MODEL',
|
||||
'ANTHROPIC_MEDIUM_MODEL',
|
||||
'ANTHROPIC_LARGE_MODEL',
|
||||
'CLAUDE_CODE_MAX_OUTPUT_TOKENS',
|
||||
/**
|
||||
* Variables forwarded to every worker container regardless of provider. Each is
|
||||
* forwarded only when set, so an unused one never appears in the container.
|
||||
* SHANNON_AI_API_KEY rides along because it is provider-neutral.
|
||||
*/
|
||||
const COMMON_FORWARD_VARS = [
|
||||
'SHANNON_AI_MODEL',
|
||||
'SHANNON_AI_BASE_URL',
|
||||
'SHANNON_AI_OPENAI_FORMAT',
|
||||
GENERIC_API_KEY_ENV,
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Credential variables for one provider. Only the selected provider's entries are
|
||||
* forwarded, so a key for an unused provider never enters the scan container. An
|
||||
* uncurated provider has none — it relies on the common SHANNON_AI_API_KEY.
|
||||
*/
|
||||
function providerForwardVars(providerId: string): readonly string[] {
|
||||
if (!isCuratedProvider(providerId)) return [];
|
||||
return [...PROVIDER_API_KEY_ENV[providerId], ...PROVIDER_EXTRA_ENV[providerId]];
|
||||
}
|
||||
|
||||
/** Parse a user-facing boolean env var: `1`/`true` (any case) true, `0`/`false`/empty false, else the default. */
|
||||
export function envBool(name: string, defaultValue: boolean): boolean {
|
||||
const raw = process.env[name]?.trim().toLowerCase();
|
||||
if (raw === undefined || raw === '') return defaultValue;
|
||||
if (raw === '1' || raw === 'true') return true;
|
||||
if (raw === '0' || raw === 'false') return false;
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
const USE_PI_AUTH_ENV = 'SHANNON_USE_PI_AUTH';
|
||||
|
||||
/** Where the host's auth.json is mounted: pi's standard location (worker HOME is /tmp), read natively. */
|
||||
export const PI_AUTH_CONTAINER_PATH = '/tmp/.pi/agent/auth.json';
|
||||
|
||||
/** Host path to pi's credential file. */
|
||||
export function resolveHostPiAuthPath(): string {
|
||||
return path.join(os.homedir(), '.pi', 'agent', 'auth.json');
|
||||
}
|
||||
|
||||
export function piAuthFlagEnabled(): boolean {
|
||||
return envBool(USE_PI_AUTH_ENV, false);
|
||||
}
|
||||
|
||||
/** Opted into pi auth via the flag, and the auth file exists to mount. */
|
||||
export function shouldUsePiAuth(): boolean {
|
||||
return piAuthFlagEnabled() && fs.existsSync(resolveHostPiAuthPath());
|
||||
}
|
||||
|
||||
/**
|
||||
* Load credentials into process.env.
|
||||
* Local mode: loads ./.env via dotenv.
|
||||
@@ -43,15 +87,19 @@ export function loadEnv(): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Build `-e KEY=VALUE` flags for docker run, only for set variables.
|
||||
* Build `-e` flags for docker run. Forwards the common vars plus only the
|
||||
* selected provider's credentials, passed by name (`-e KEY`) so secret values
|
||||
* stay out of the `docker run` argv; docker inherits them from this process's env.
|
||||
*/
|
||||
export function buildEnvFlags(): string[] {
|
||||
const flags: string[] = ['-e', 'TEMPORAL_ADDRESS=shannon-temporal:7233'];
|
||||
|
||||
for (const key of FORWARD_VARS) {
|
||||
const value = process.env[key];
|
||||
if (value) {
|
||||
flags.push('-e', `${key}=${value}`);
|
||||
const spec = resolveModelSpec();
|
||||
const providerVars = typeof spec === 'string' ? [] : providerForwardVars(spec.providerId);
|
||||
|
||||
for (const key of [...COMMON_FORWARD_VARS, ...providerVars]) {
|
||||
if (process.env[key]) {
|
||||
flags.push('-e', key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,95 +109,91 @@ export function buildEnvFlags(): string[] {
|
||||
interface CredentialValidation {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
mode: 'api-key' | 'oauth' | 'custom-base-url' | 'bedrock' | 'vertex';
|
||||
}
|
||||
|
||||
/** Check if a custom Anthropic-compatible base URL is configured. */
|
||||
function isCustomBaseUrlConfigured(): boolean {
|
||||
return !!(process.env.ANTHROPIC_BASE_URL && process.env.ANTHROPIC_AUTH_TOKEN);
|
||||
/** Whether a curated provider has its own named credential set (API key plus any extra var). */
|
||||
function hasNamedCredential(providerId: CuratedProviderId): boolean {
|
||||
const apiKeys = PROVIDER_API_KEY_ENV[providerId];
|
||||
if (!apiKeys.some((name) => Boolean(process.env[name]))) return false;
|
||||
return PROVIDER_EXTRA_ENV[providerId].every((name) => Boolean(process.env[name]));
|
||||
}
|
||||
|
||||
/** Detect which providers are configured via environment variables. */
|
||||
function detectProviders(): string[] {
|
||||
const providers: string[] = [];
|
||||
if (process.env.ANTHROPIC_API_KEY) providers.push('Anthropic API key');
|
||||
if (process.env.CLAUDE_CODE_OAUTH_TOKEN) providers.push('Anthropic OAuth');
|
||||
if (isCustomBaseUrlConfigured()) providers.push('Custom Base URL');
|
||||
if (process.env.CLAUDE_CODE_USE_BEDROCK === '1') providers.push('AWS Bedrock');
|
||||
if (process.env.CLAUDE_CODE_USE_VERTEX === '1') providers.push('Google Vertex');
|
||||
return providers;
|
||||
/** Whether the selected provider has a credential. Bedrock needs its AWS_ vars; the generic key never stands in for it. */
|
||||
function hasCredential(providerId: string): boolean {
|
||||
if (providerId === 'amazon-bedrock') return hasNamedCredential('amazon-bedrock');
|
||||
if (isCuratedProvider(providerId) && hasNamedCredential(providerId)) return true;
|
||||
return Boolean(process.env[GENERIC_API_KEY_ENV]);
|
||||
}
|
||||
|
||||
/** Curated providers with a named credential. The generic key is neutral, so it never counts toward ambiguity. */
|
||||
function configuredProviders(): CuratedProviderId[] {
|
||||
return CURATED_PROVIDERS.filter((providerId) => hasNamedCredential(providerId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that exactly one authentication method is configured.
|
||||
* Validate that the model selection parses and its provider has a credential.
|
||||
* Runs before any Docker work so mistakes fail immediately.
|
||||
*/
|
||||
export function validateCredentials(): CredentialValidation {
|
||||
// Reject multiple providers
|
||||
const providers = detectProviders();
|
||||
if (providers.length > 1) {
|
||||
// 1. Model selection must parse into a provider and model id
|
||||
const spec = resolveModelSpec();
|
||||
if (typeof spec === 'string') {
|
||||
return { valid: false, error: spec };
|
||||
}
|
||||
|
||||
// Pi-auth: skip the API-key checks, but the host auth file must exist to mount.
|
||||
if (piAuthFlagEnabled()) {
|
||||
const authPath = resolveHostPiAuthPath();
|
||||
if (!fs.existsSync(authPath)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `${USE_PI_AUTH_ENV} is set but no pi credentials were found at ${authPath}. Authenticate with pi first.`,
|
||||
};
|
||||
}
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
// 2. The selected provider must have a credential
|
||||
if (!hasCredential(spec.providerId)) {
|
||||
const requirement = isCuratedProvider(spec.providerId)
|
||||
? PROVIDER_CREDENTIAL_HINT[spec.providerId]
|
||||
: GENERIC_API_KEY_ENV;
|
||||
const hint =
|
||||
getMode() === 'local'
|
||||
? `Set ${requirement} in .env or export it.`
|
||||
: `Export the variables or run 'npx @keygraph/shannon setup'.`;
|
||||
return {
|
||||
valid: false,
|
||||
mode: 'api-key',
|
||||
error: `Multiple providers detected: ${providers.join(', ')}. Only one provider can be active at a time.`,
|
||||
error: `No credentials found for provider "${spec.providerId}". ${hint}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (process.env.ANTHROPIC_API_KEY) {
|
||||
return { valid: true, mode: 'api-key' };
|
||||
}
|
||||
if (process.env.CLAUDE_CODE_OAUTH_TOKEN) {
|
||||
return { valid: true, mode: 'oauth' };
|
||||
}
|
||||
if (isCustomBaseUrlConfigured()) {
|
||||
return { valid: true, mode: 'custom-base-url' };
|
||||
}
|
||||
if (process.env.CLAUDE_CODE_USE_BEDROCK === '1') {
|
||||
const missing: string[] = [];
|
||||
if (!process.env.AWS_REGION) missing.push('AWS_REGION');
|
||||
if (!process.env.AWS_BEARER_TOKEN_BEDROCK) missing.push('AWS_BEARER_TOKEN_BEDROCK');
|
||||
if (!process.env.ANTHROPIC_SMALL_MODEL) missing.push('ANTHROPIC_SMALL_MODEL');
|
||||
if (!process.env.ANTHROPIC_MEDIUM_MODEL) missing.push('ANTHROPIC_MEDIUM_MODEL');
|
||||
if (!process.env.ANTHROPIC_LARGE_MODEL) missing.push('ANTHROPIC_LARGE_MODEL');
|
||||
if (missing.length > 0) {
|
||||
return {
|
||||
valid: false,
|
||||
mode: 'bedrock',
|
||||
error: `Bedrock mode requires: ${missing.join(', ')}`,
|
||||
};
|
||||
// 3. Exactly one provider may be configured. Several complete credentials make
|
||||
// the scan's provider depend on SHANNON_AI_MODEL alone, which is too easy to
|
||||
// misread as "both are in play" and too easy to redirect by editing one line.
|
||||
const configured = configuredProviders();
|
||||
if (configured.length > 1) {
|
||||
const setKeys = (id: CuratedProviderId): string[] =>
|
||||
PROVIDER_API_KEY_ENV[id].filter((name) => Boolean(process.env[name]));
|
||||
const list = configured.map((id) => `${id} (${setKeys(id).join(', ')})`).join(' and ');
|
||||
const others = configured.filter((id) => id !== spec.providerId);
|
||||
const extraVars = others.flatMap(setKeys);
|
||||
|
||||
const dropHint =
|
||||
getMode() === 'local'
|
||||
? 'remove them from .env or unset them in your shell:'
|
||||
: "unset them in your shell, or reconfigure with 'npx @keygraph/shannon setup':";
|
||||
|
||||
const lines = [`Credentials for more than one provider are set: ${list}.`];
|
||||
if (extraVars.length > 0) {
|
||||
lines.push(
|
||||
`Shannon runs one provider per scan, selected by SHANNON_AI_MODEL ("${spec.providerId}:...").`,
|
||||
`Keep ${spec.providerId} and drop the rest — ${dropHint}`,
|
||||
` unset ${extraVars.join(' ')}`,
|
||||
);
|
||||
}
|
||||
return { valid: true, mode: 'bedrock' };
|
||||
}
|
||||
if (process.env.CLAUDE_CODE_USE_VERTEX === '1') {
|
||||
const missing: string[] = [];
|
||||
if (!process.env.CLOUD_ML_REGION) missing.push('CLOUD_ML_REGION');
|
||||
if (!process.env.ANTHROPIC_VERTEX_PROJECT_ID) missing.push('ANTHROPIC_VERTEX_PROJECT_ID');
|
||||
if (!process.env.ANTHROPIC_SMALL_MODEL) missing.push('ANTHROPIC_SMALL_MODEL');
|
||||
if (!process.env.ANTHROPIC_MEDIUM_MODEL) missing.push('ANTHROPIC_MEDIUM_MODEL');
|
||||
if (!process.env.ANTHROPIC_LARGE_MODEL) missing.push('ANTHROPIC_LARGE_MODEL');
|
||||
if (missing.length > 0) {
|
||||
return {
|
||||
valid: false,
|
||||
mode: 'vertex',
|
||||
error: `Vertex AI mode requires: ${missing.join(', ')}`,
|
||||
};
|
||||
}
|
||||
if (!process.env.GOOGLE_APPLICATION_CREDENTIALS) {
|
||||
return {
|
||||
valid: false,
|
||||
mode: 'vertex',
|
||||
error: 'Vertex AI mode requires GOOGLE_APPLICATION_CREDENTIALS',
|
||||
};
|
||||
}
|
||||
return { valid: true, mode: 'vertex' };
|
||||
return { valid: false, error: lines.join('\n') };
|
||||
}
|
||||
|
||||
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',
|
||||
error: hint,
|
||||
};
|
||||
return { valid: true };
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Centralized error reporting.
|
||||
*
|
||||
* `fail` — an expected, user-fixable error (bad input, missing prerequisite):
|
||||
* a clean message on stderr and a non-zero exit, never a stack trace.
|
||||
* `failUsage` — a malformed invocation (unknown command, bad or missing
|
||||
* arguments): the same clean message, but a distinct exit code so callers can
|
||||
* tell a usage mistake from an operational failure.
|
||||
* `crash` — an unexpected error (a bug): a brief message, the full stack written
|
||||
* to a log file for a bug report, and a pointer to the issue tracker.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
const ISSUES_URL = 'https://github.com/KeygraphHQ/shannon/issues';
|
||||
|
||||
/** Report an expected, user-fixable error (with optional extra lines) and exit non-zero. */
|
||||
export function fail(message: string, ...hints: string[]): never {
|
||||
console.error(`ERROR: ${message}`);
|
||||
for (const hint of hints) {
|
||||
console.error(hint);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/** Report a usage/argument error (with optional extra lines) and exit 2. */
|
||||
export function failUsage(message: string, ...hints: string[]): never {
|
||||
console.error(`ERROR: ${message}`);
|
||||
for (const hint of hints) {
|
||||
console.error(hint);
|
||||
}
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
/** Report a non-fatal warning on stderr (with optional extra lines) without exiting. */
|
||||
export function warn(message: string, ...hints: string[]): void {
|
||||
console.error(`WARNING: ${message}`);
|
||||
for (const hint of hints) {
|
||||
console.error(hint);
|
||||
}
|
||||
}
|
||||
|
||||
/** Report an unexpected error: brief message, full stack to a log file, plus the issue link. */
|
||||
export function crash(error: unknown): never {
|
||||
console.error(`ERROR: ${error instanceof Error ? error.message : String(error)}`);
|
||||
if (process.env.DEBUG) {
|
||||
console.error(error instanceof Error ? error.stack : String(error));
|
||||
}
|
||||
|
||||
const logPath = writeCrashLog(error);
|
||||
if (logPath) {
|
||||
console.error(`Details written to ${logPath}`);
|
||||
}
|
||||
console.error(`If this looks like a bug, please report it: ${ISSUES_URL}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/** Write the full error and stack to a log file; return its path, or null if it can't be written. */
|
||||
function writeCrashLog(error: unknown): string | null {
|
||||
try {
|
||||
const logPath = path.join(os.tmpdir(), 'shannon-error.log');
|
||||
const detail = error instanceof Error && error.stack ? error.stack : String(error);
|
||||
fs.writeFileSync(logPath, `${new Date().toISOString()}\n${detail}\n`);
|
||||
return logPath;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Per-command help text.
|
||||
*
|
||||
* `shannon <command> --help`, `shannon <command> -h`, and `shannon help <command>`
|
||||
* all render the matching command's usage, so a user can discover a command's
|
||||
* flags without scanning the global help. The global help lives in index.ts.
|
||||
*/
|
||||
|
||||
import { commandPrefix, getMode } from './mode.js';
|
||||
|
||||
interface CommandHelp {
|
||||
readonly usage: readonly string[];
|
||||
readonly description: string;
|
||||
readonly options?: readonly (readonly [string, string])[];
|
||||
readonly examples?: readonly string[];
|
||||
}
|
||||
|
||||
const YES_OPTION: readonly [string, string] = [
|
||||
'-y, --yes',
|
||||
'Skip the confirmation prompt (required for non-interactive use)',
|
||||
];
|
||||
const HELP_OPTION: readonly [string, string] = ['-h, --help', 'Show this help'];
|
||||
|
||||
/**
|
||||
* `start`'s flags, the single source rendered by both the per-command help here
|
||||
* and the global help in index.ts, so the two can never drift.
|
||||
*/
|
||||
export const START_OPTIONS: readonly (readonly [string, string])[] = [
|
||||
['-u, --url <url>', 'Target URL (required)'],
|
||||
['-r, --repo <path>', 'Repository path (required)'],
|
||||
['-c, --config <path>', 'Configuration file (YAML)'],
|
||||
['-o, --output <path>', 'Copy deliverables to this directory after the run'],
|
||||
['-w, --workspace <name>', 'Named workspace (auto-resumes if it exists)'],
|
||||
['-f, --follow', 'Stream the scan log until it finishes'],
|
||||
['--pipeline-testing', 'Use minimal prompts for fast testing'],
|
||||
['--keep-container', 'Preserve the worker container after exit for log inspection'],
|
||||
];
|
||||
|
||||
const COMMAND_HELP: Readonly<Record<string, CommandHelp>> = {
|
||||
start: {
|
||||
usage: ['start -u <url> -r <path> [options]'],
|
||||
description: 'Start a pentest scan.',
|
||||
examples: [
|
||||
'start -u https://example.com -r ./my-repo',
|
||||
'start -u https://example.com -r /path/to/repo -c config.yaml -w q1-audit',
|
||||
'start -u https://example.com -r ./my-repo --follow',
|
||||
],
|
||||
},
|
||||
stop: {
|
||||
usage: ['stop <workspace> [--yes]', 'stop --all [--yes]'],
|
||||
description: 'Stop one scan by workspace, or every scan with --all (Temporal stays up).',
|
||||
options: [['--all', 'Stop all running scans'], YES_OPTION],
|
||||
examples: ['stop q1-audit', 'stop --all'],
|
||||
},
|
||||
reset: {
|
||||
usage: ['reset'],
|
||||
description: 'Stop everything and permanently remove all Temporal data and volumes.',
|
||||
},
|
||||
logs: {
|
||||
usage: ['logs <workspace>'],
|
||||
description: "Tail a scan's live log until it completes.",
|
||||
examples: ['logs q1-audit'],
|
||||
},
|
||||
status: {
|
||||
usage: ['status <workspace> [--json]'],
|
||||
description:
|
||||
"Show one scan's phase-by-phase progress, read live from Temporal. Watches and redraws until the scan finishes on a terminal; prints one frame when piped or already finished. With --json, prints a single machine-readable snapshot and exits.",
|
||||
options: [['--json', 'Output a point-in-time snapshot as JSON, then exit']],
|
||||
examples: ['status q1-audit', 'status q1-audit --json'],
|
||||
},
|
||||
scans: {
|
||||
usage: ['scans [--json]'],
|
||||
description: 'List completed scans and where each report lives.',
|
||||
options: [['--json', 'Output the scan list as JSON']],
|
||||
examples: ['scans', 'scans --json'],
|
||||
},
|
||||
build: {
|
||||
usage: ['build [--no-cache]'],
|
||||
description: 'Build the worker Docker image (local mode only).',
|
||||
options: [['--no-cache', 'Build without using the Docker layer cache']],
|
||||
},
|
||||
setup: {
|
||||
usage: ['setup'],
|
||||
description: 'Configure provider credentials interactively (npx mode only).',
|
||||
},
|
||||
version: {
|
||||
usage: ['version [--json]'],
|
||||
description: 'Show the version. With --json, prints the version and mode as a machine-readable object.',
|
||||
options: [['--json', 'Output the version and mode as JSON']],
|
||||
examples: ['version', 'version --json'],
|
||||
},
|
||||
};
|
||||
|
||||
/** Commands that only exist in one mode; everything else is available in both. */
|
||||
const MODE_ONLY: Readonly<Record<string, 'local' | 'npx'>> = {
|
||||
build: 'local',
|
||||
setup: 'npx',
|
||||
};
|
||||
|
||||
/** Whether a command has its own help page (and so responds to `--help`/`-h`). */
|
||||
export function isHelpableCommand(command: string): boolean {
|
||||
return command in COMMAND_HELP;
|
||||
}
|
||||
|
||||
/**
|
||||
* User-facing command names available in the current mode, for "did you mean?"
|
||||
* suggestions. Derived from the same table that backs per-command help, so the
|
||||
* suggestion set can never drift from the commands that actually exist.
|
||||
*/
|
||||
export function availableCommands(): readonly string[] {
|
||||
const mode = getMode();
|
||||
const commands = Object.keys(COMMAND_HELP).filter((command) => (MODE_ONLY[command] ?? mode) === mode);
|
||||
return [...commands, 'help'];
|
||||
}
|
||||
|
||||
/** Print the help page for one command. No-op if the command has no page. */
|
||||
export function printCommandHelp(command: string): void {
|
||||
const help = COMMAND_HELP[command];
|
||||
if (!help) return;
|
||||
|
||||
const prefix = commandPrefix();
|
||||
const baseOptions = command === 'start' ? START_OPTIONS : (help.options ?? []);
|
||||
const options = [...baseOptions, HELP_OPTION];
|
||||
const flagWidth = Math.max(...options.map(([flag]) => flag.length));
|
||||
|
||||
const lines: string[] = ['', help.description, '', 'USAGE'];
|
||||
for (const line of help.usage) {
|
||||
lines.push(` ${prefix} ${line}`);
|
||||
}
|
||||
|
||||
lines.push('', 'OPTIONS');
|
||||
for (const [flag, desc] of options) {
|
||||
lines.push(` ${flag.padEnd(flagWidth)} ${desc}`);
|
||||
}
|
||||
|
||||
if (help.examples && help.examples.length > 0) {
|
||||
lines.push('', 'EXAMPLES');
|
||||
for (const example of help.examples) {
|
||||
lines.push(` ${prefix} ${example}`);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
console.log(lines.join('\n'));
|
||||
}
|
||||
+2
-20
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Shannon state directory management.
|
||||
*
|
||||
* Local mode (cloned repo): uses ./workspaces/, ./credentials/
|
||||
* Local mode (cloned repo): uses ./workspaces/
|
||||
* NPX mode: uses ~/.shannon/workspaces/, ~/.shannon/
|
||||
*/
|
||||
|
||||
@@ -20,32 +20,14 @@ export function getWorkspacesDir(): string {
|
||||
return getMode() === 'local' ? path.resolve('workspaces') : path.join(SHANNON_HOME, 'workspaces');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the Vertex credentials file path.
|
||||
*
|
||||
* Checks GOOGLE_APPLICATION_CREDENTIALS env var first (may be set by TOML resolver),
|
||||
* then falls back to mode-appropriate default location.
|
||||
*/
|
||||
export function getCredentialsPath(): string {
|
||||
const envPath = process.env.GOOGLE_APPLICATION_CREDENTIALS;
|
||||
if (envPath && fs.existsSync(envPath)) return path.resolve(envPath);
|
||||
|
||||
if (getMode() === 'local') {
|
||||
return path.resolve('credentials', 'google-sa-key.json');
|
||||
}
|
||||
|
||||
return path.join(SHANNON_HOME, 'google-sa-key.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize state directories.
|
||||
* Local mode: creates ./workspaces/ and ./credentials/
|
||||
* Local mode: creates ./workspaces/
|
||||
* NPX mode: creates ~/.shannon/workspaces/
|
||||
*/
|
||||
export function initHome(): void {
|
||||
if (getMode() === 'local') {
|
||||
fs.mkdirSync(path.resolve('workspaces'), { recursive: true });
|
||||
fs.mkdirSync(path.resolve('credentials'), { recursive: true });
|
||||
} else {
|
||||
fs.mkdirSync(path.join(SHANNON_HOME, 'workspaces'), { recursive: true });
|
||||
}
|
||||
|
||||
+193
-163
@@ -9,39 +9,55 @@
|
||||
* in the current working directory.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { ArgError, parseArgs, YES_FLAGS } from './args.js';
|
||||
import { build } from './commands/build.js';
|
||||
import { logs } from './commands/logs.js';
|
||||
import { reset } from './commands/reset.js';
|
||||
import { scans } from './commands/scans.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 { workspaces } from './commands/workspaces.js';
|
||||
import { getMode } from './mode.js';
|
||||
import { crash, fail, failUsage } from './errors.js';
|
||||
import { availableCommands, isHelpableCommand, printCommandHelp, START_OPTIONS } from './help.js';
|
||||
import { commandPrefix, getMode, isLocal } from './mode.js';
|
||||
import { displaySplash } from './splash.js';
|
||||
import { closestMatch } from './suggest.js';
|
||||
import { getVersion, getVersionLine } from './version.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
function blockSudo(): void {
|
||||
const isSudo = !!process.env.SUDO_USER;
|
||||
const isRoot = process.geteuid?.() === 0;
|
||||
if (!isSudo && !isRoot) return;
|
||||
|
||||
function getVersion(): string {
|
||||
try {
|
||||
const pkgPath = path.join(__dirname, '..', 'package.json');
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')) as { version?: string };
|
||||
return pkg.version || '1.0.0';
|
||||
} catch {
|
||||
return '1.0.0';
|
||||
const linuxHints =
|
||||
process.platform === 'linux'
|
||||
? ['Configure Docker to run without sudo first:', 'https://docs.docker.com/engine/install/linux-postinstall']
|
||||
: [];
|
||||
|
||||
if (isSudo) {
|
||||
fail('Shannon must not be run with sudo.', 'Re-run this command as your normal user.', ...linuxHints);
|
||||
}
|
||||
fail(
|
||||
'Shannon must not be run as the root user.',
|
||||
'Switch to a regular user account and re-run this command.',
|
||||
...linuxHints,
|
||||
);
|
||||
}
|
||||
|
||||
function showHelp(): void {
|
||||
/** Render `start`'s flags for the global help, from the same source as `start --help`. */
|
||||
function renderStartOptions(): string {
|
||||
const flagWidth = Math.max(...START_OPTIONS.map(([flag]) => flag.length));
|
||||
return START_OPTIONS.map(([flag, desc]) => ` ${flag.padEnd(flagWidth)} ${desc}`).join('\n');
|
||||
}
|
||||
|
||||
function showHelp(withSplash: boolean): void {
|
||||
const mode = getMode();
|
||||
const prefix = mode === 'local' ? './shannon' : 'npx @keygraph/shannon';
|
||||
const prefix = commandPrefix();
|
||||
|
||||
console.log(`
|
||||
Shannon - AI Penetration Testing Framework
|
||||
const header = withSplash ? '' : '\nShannon - AI Penetration Testing Framework\n';
|
||||
|
||||
console.log(`${header}
|
||||
Usage:${
|
||||
mode === 'local'
|
||||
? ''
|
||||
@@ -49,41 +65,33 @@ Usage:${
|
||||
${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${
|
||||
${prefix} stop <workspace> [--yes] Stop one scan
|
||||
${prefix} stop --all [--yes] Stop all scans (Temporal stays up)
|
||||
${prefix} reset Stop everything and wipe all Temporal data
|
||||
${prefix} logs <workspace> Show a scan's live log
|
||||
${prefix} status <workspace> [--json] Live phase/agent progress of one scan
|
||||
${prefix} scans [--json] List completed scans and their reports${
|
||||
mode === 'local'
|
||||
? `
|
||||
${prefix} build [--no-cache] Build worker image`
|
||||
: `
|
||||
${prefix} uninstall Remove ~/.shannon/ and all data`
|
||||
: ''
|
||||
}
|
||||
${prefix} info Show splash screen
|
||||
${prefix} version [--json] Show version
|
||||
${prefix} help Show this help
|
||||
|
||||
Options for 'start':
|
||||
-u, --url <url> Target URL (required)
|
||||
-r, --repo <path> Repository path${mode === 'local' ? ' or bare name' : ''} (required)
|
||||
-c, --config <path> Configuration file (YAML)
|
||||
-o, --output <path> Copy deliverables to this directory after run
|
||||
-w, --workspace <name> Named workspace (auto-resumes if exists)
|
||||
--pipeline-testing Use minimal prompts for fast testing
|
||||
--debug Preserve worker container after exit for log inspection
|
||||
${renderStartOptions()}
|
||||
|
||||
Examples:
|
||||
${prefix} start -u https://example.com -r ${mode === 'local' ? 'my-repo' : './my-repo'}
|
||||
${prefix} start -u https://example.com -r ./my-repo
|
||||
${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/`
|
||||
}
|
||||
Monitor workflows at http://localhost:8233
|
||||
${prefix} stop q1-audit
|
||||
${prefix} reset
|
||||
|
||||
Run '${prefix} <command> --help' for help on a specific command.
|
||||
|
||||
Docs & source: https://github.com/KeygraphHQ/shannon
|
||||
`);
|
||||
}
|
||||
|
||||
@@ -94,146 +102,168 @@ interface ParsedStartArgs {
|
||||
workspace?: string;
|
||||
output?: string;
|
||||
pipelineTesting: boolean;
|
||||
debug: boolean;
|
||||
keepContainer: boolean;
|
||||
follow: boolean;
|
||||
}
|
||||
|
||||
function parseStartArgs(argv: string[]): ParsedStartArgs {
|
||||
let url = '';
|
||||
let repo = '';
|
||||
let config: string | undefined;
|
||||
let workspace: string | undefined;
|
||||
let output: string | undefined;
|
||||
let pipelineTesting = false;
|
||||
let debug = false;
|
||||
const { flags, values } = parseArgs(argv, {
|
||||
values: {
|
||||
url: ['-u', '--url'],
|
||||
repo: ['-r', '--repo'],
|
||||
config: ['-c', '--config'],
|
||||
output: ['-o', '--output'],
|
||||
workspace: ['-w', '--workspace'],
|
||||
},
|
||||
booleans: {
|
||||
pipelineTesting: ['--pipeline-testing'],
|
||||
keepContainer: ['--keep-container'],
|
||||
follow: ['-f', '--follow'],
|
||||
},
|
||||
});
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
const next = argv[i + 1];
|
||||
|
||||
switch (arg) {
|
||||
case '-u':
|
||||
case '--url':
|
||||
if (next && !next.startsWith('-')) {
|
||||
url = next;
|
||||
i++;
|
||||
}
|
||||
break;
|
||||
case '-r':
|
||||
case '--repo':
|
||||
if (next && !next.startsWith('-')) {
|
||||
repo = next;
|
||||
i++;
|
||||
}
|
||||
break;
|
||||
case '-c':
|
||||
case '--config':
|
||||
if (next && !next.startsWith('-')) {
|
||||
config = next;
|
||||
i++;
|
||||
}
|
||||
break;
|
||||
case '-w':
|
||||
case '--workspace':
|
||||
if (next && !next.startsWith('-')) {
|
||||
workspace = next;
|
||||
i++;
|
||||
}
|
||||
break;
|
||||
case '-o':
|
||||
case '--output':
|
||||
if (next && !next.startsWith('-')) {
|
||||
output = next;
|
||||
i++;
|
||||
}
|
||||
break;
|
||||
case '--pipeline-testing':
|
||||
pipelineTesting = true;
|
||||
break;
|
||||
case '--debug':
|
||||
debug = true;
|
||||
break;
|
||||
default:
|
||||
console.error(`Unknown option: ${arg}`);
|
||||
console.error(`Run "${getMode() === 'local' ? './shannon' : 'npx @keygraph/shannon'} help" for usage`);
|
||||
process.exit(1);
|
||||
}
|
||||
const url = values.url ?? '';
|
||||
const repo = values.repo ?? '';
|
||||
if (!url || !repo) {
|
||||
failUsage('--url and --repo are required', `Usage: ${commandPrefix()} start -u <url> -r <path>`);
|
||||
}
|
||||
|
||||
if (!url || !repo) {
|
||||
console.error('ERROR: --url and --repo are required');
|
||||
console.error(`Usage: ${getMode() === 'local' ? './shannon' : 'npx @keygraph/shannon'} start -u <url> -r <path>`);
|
||||
process.exit(1);
|
||||
try {
|
||||
new URL(url);
|
||||
} catch {
|
||||
failUsage(`invalid --url: ${url}`);
|
||||
}
|
||||
|
||||
return {
|
||||
url,
|
||||
repo,
|
||||
pipelineTesting,
|
||||
debug,
|
||||
...(config && { config }),
|
||||
...(workspace && { workspace }),
|
||||
...(output && { output }),
|
||||
pipelineTesting: !!flags.pipelineTesting,
|
||||
keepContainer: !!flags.keepContainer,
|
||||
follow: !!flags.follow,
|
||||
...(values.config && { config: values.config }),
|
||||
...(values.workspace && { workspace: values.workspace }),
|
||||
...(values.output && { output: values.output }),
|
||||
};
|
||||
}
|
||||
|
||||
// === Main Dispatch ===
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const command = args[0];
|
||||
async function main(): Promise<void> {
|
||||
// A reader that closes early (e.g. `shannon logs my-scan | head`) makes writes
|
||||
// to stdout raise EPIPE. That's normal for a piped CLI, not a crash — exit quietly
|
||||
// instead of letting Node dump an unhandled-error stack trace.
|
||||
process.stdout.on('error', (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === 'EPIPE') process.exit(0);
|
||||
throw err;
|
||||
});
|
||||
|
||||
switch (command) {
|
||||
case 'start': {
|
||||
const parsed = parseStartArgs(args.slice(1));
|
||||
await start({ ...parsed, version: getVersion() });
|
||||
break;
|
||||
blockSudo();
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const command = args[0];
|
||||
const rest = args.slice(1);
|
||||
|
||||
if (command === undefined || command === 'help' || command === '--help' || command === '-h') {
|
||||
const topic = rest[0];
|
||||
if (topic && isHelpableCommand(topic)) {
|
||||
printCommandHelp(topic);
|
||||
} else {
|
||||
const bare = command === undefined;
|
||||
if (bare) displaySplash(isLocal() ? undefined : getVersion());
|
||||
showHelp(bare);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'stop':
|
||||
stop(args.includes('--clean'));
|
||||
break;
|
||||
case 'logs': {
|
||||
const workspaceId = args[1];
|
||||
if (!workspaceId) {
|
||||
console.error('ERROR: Workspace ID is required');
|
||||
console.error(`Usage: ${getMode() === 'local' ? './shannon' : 'npx @keygraph/shannon'} logs <workspace>`);
|
||||
process.exit(1);
|
||||
}
|
||||
logs(workspaceId);
|
||||
break;
|
||||
|
||||
// Reachable from any invocation: `-h`/`--help` anywhere wins over the rest of the line.
|
||||
if (isHelpableCommand(command) && (rest.includes('-h') || rest.includes('--help'))) {
|
||||
printCommandHelp(command);
|
||||
return;
|
||||
}
|
||||
case 'workspaces':
|
||||
workspaces(getVersion());
|
||||
break;
|
||||
case 'status':
|
||||
status();
|
||||
break;
|
||||
case 'setup':
|
||||
if (getMode() === 'local') {
|
||||
console.error('ERROR: setup is only available in npx mode. In local mode, use .env');
|
||||
process.exit(1);
|
||||
|
||||
switch (command) {
|
||||
case 'start': {
|
||||
const parsed = parseStartArgs(rest);
|
||||
await start({ ...parsed, version: getVersion() });
|
||||
break;
|
||||
}
|
||||
setup();
|
||||
break;
|
||||
case 'build':
|
||||
build(args.includes('--no-cache'));
|
||||
break;
|
||||
case 'uninstall':
|
||||
if (getMode() === 'local') {
|
||||
console.error('ERROR: uninstall is only available in npx mode.');
|
||||
process.exit(1);
|
||||
case 'stop': {
|
||||
const { flags, positionals } = parseArgs(rest, {
|
||||
booleans: { all: ['--all'], yes: YES_FLAGS },
|
||||
maxPositionals: 1,
|
||||
});
|
||||
await stop({ all: !!flags.all, yes: !!flags.yes, ...(positionals[0] && { workspace: positionals[0] }) });
|
||||
break;
|
||||
}
|
||||
uninstall();
|
||||
break;
|
||||
case 'info':
|
||||
displaySplash(getMode() === 'local' ? undefined : getVersion());
|
||||
break;
|
||||
case 'help':
|
||||
case '--help':
|
||||
case '-h':
|
||||
case undefined:
|
||||
showHelp();
|
||||
break;
|
||||
default:
|
||||
console.error(`Unknown command: ${command}`);
|
||||
showHelp();
|
||||
process.exit(1);
|
||||
case 'reset': {
|
||||
// reset is all-or-nothing; a stray name likely means the user wanted `stop <name>`.
|
||||
parseArgs(rest, {
|
||||
positionalHint: 'reset takes no workspace argument. To stop one scan, use: stop <name>',
|
||||
});
|
||||
await reset();
|
||||
break;
|
||||
}
|
||||
case 'logs': {
|
||||
const { positionals } = parseArgs(rest, { maxPositionals: 1 });
|
||||
const workspaceId = positionals[0];
|
||||
if (!workspaceId) {
|
||||
failUsage('Workspace ID is required', `Usage: ${commandPrefix()} logs <workspace>`);
|
||||
}
|
||||
logs(workspaceId);
|
||||
break;
|
||||
}
|
||||
case 'status': {
|
||||
const { flags, positionals } = parseArgs(rest, { booleans: { json: ['--json'] }, maxPositionals: 1 });
|
||||
const workspaceId = positionals[0];
|
||||
if (!workspaceId) {
|
||||
failUsage('Workspace is required', `Usage: ${commandPrefix()} status <workspace> [--json]`);
|
||||
}
|
||||
await status(workspaceId, { json: !!flags.json });
|
||||
break;
|
||||
}
|
||||
case 'scans': {
|
||||
const { flags } = parseArgs(rest, { booleans: { json: ['--json'] } });
|
||||
scans({ json: !!flags.json });
|
||||
break;
|
||||
}
|
||||
case 'setup':
|
||||
if (getMode() === 'local') {
|
||||
fail('setup is only available in npx mode. In local mode, use .env');
|
||||
}
|
||||
parseArgs(rest, {});
|
||||
await setup();
|
||||
break;
|
||||
case 'build': {
|
||||
const { flags } = parseArgs(rest, { booleans: { noCache: ['--no-cache'] } });
|
||||
build(!!flags.noCache, getVersion());
|
||||
break;
|
||||
}
|
||||
case 'version':
|
||||
case '--version':
|
||||
case '-v': {
|
||||
const { flags } = parseArgs(rest, { booleans: { json: ['--json'] } });
|
||||
if (flags.json) {
|
||||
console.log(JSON.stringify({ version: getVersion(), mode: getMode() }, null, 2));
|
||||
} else {
|
||||
console.log(getVersionLine());
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
const prefix = commandPrefix();
|
||||
const suggestion = closestMatch(command, availableCommands());
|
||||
const hints = [
|
||||
...(suggestion ? [`Did you mean '${suggestion}'?`] : []),
|
||||
`Run '${prefix} help' to see available commands.`,
|
||||
];
|
||||
failUsage(`Unknown command: ${command}`, ...hints);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
if (err instanceof ArgError) {
|
||||
failUsage(err.message, `Run "${commandPrefix()} help" for usage`);
|
||||
}
|
||||
crash(err);
|
||||
});
|
||||
@@ -23,3 +23,12 @@ export function setMode(mode: Mode): void {
|
||||
export function isLocal(): boolean {
|
||||
return getMode() === 'local';
|
||||
}
|
||||
|
||||
/** The invocation prefix for the current mode, so help and hints point at a runnable command. */
|
||||
export function commandPrefix(): string {
|
||||
return getMode() === 'local' ? './shannon' : 'npx @keygraph/shannon';
|
||||
}
|
||||
|
||||
export function isDevMode(): boolean {
|
||||
return process.env.SHANNON_DEV === '1';
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Parsing for the single model setting, `SHANNON_AI_MODEL=<provider>:<model-id>`.
|
||||
*
|
||||
* Mirrors apps/worker/src/ai/models.ts. The CLI cannot import from the worker
|
||||
* package (it ships as a standalone bundle), so the provider list and the parse
|
||||
* rule are duplicated here deliberately and must stay in sync.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Providers Shannon curates with their own credential variables, config sections,
|
||||
* and setup flows. Any other pi provider is reachable via the generic credential
|
||||
* path. Mirrors CURATED_PROVIDERS in apps/worker/src/ai/models.ts.
|
||||
*/
|
||||
export const CURATED_PROVIDERS = ['anthropic', 'openai', 'xai', 'amazon-bedrock'] as const;
|
||||
|
||||
export type CuratedProviderId = (typeof CURATED_PROVIDERS)[number];
|
||||
|
||||
export function isCuratedProvider(value: string): value is CuratedProviderId {
|
||||
return (CURATED_PROVIDERS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
/** Generic API key, honored for any provider Shannon does not curate. Mirrors the worker. */
|
||||
export const GENERIC_API_KEY_ENV = 'SHANNON_AI_API_KEY';
|
||||
|
||||
/**
|
||||
* Env vars carrying each curated provider's API key, in precedence order. Any one of
|
||||
* them satisfies the provider. Mirrors PROVIDER_API_KEY_ENV in apps/worker/src/ai/models.ts.
|
||||
*/
|
||||
export const PROVIDER_API_KEY_ENV: Readonly<Record<CuratedProviderId, readonly string[]>> = {
|
||||
anthropic: ['ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN'],
|
||||
openai: ['OPENAI_API_KEY'],
|
||||
xai: ['XAI_API_KEY'],
|
||||
'amazon-bedrock': ['AWS_BEARER_TOKEN_BEDROCK'],
|
||||
};
|
||||
|
||||
/** Additional env vars a curated provider requires beyond its API key. All must be set. */
|
||||
export const PROVIDER_EXTRA_ENV: Readonly<Record<CuratedProviderId, readonly string[]>> = {
|
||||
anthropic: [],
|
||||
openai: [],
|
||||
xai: [],
|
||||
'amazon-bedrock': ['AWS_REGION'],
|
||||
};
|
||||
|
||||
/** Human-readable credential requirement, used in "nothing configured" errors. */
|
||||
export const PROVIDER_CREDENTIAL_HINT: Readonly<Record<CuratedProviderId, string>> = {
|
||||
anthropic: 'ANTHROPIC_API_KEY (or CLAUDE_CODE_OAUTH_TOKEN)',
|
||||
openai: 'OPENAI_API_KEY',
|
||||
xai: 'XAI_API_KEY',
|
||||
'amazon-bedrock': 'AWS_REGION and AWS_BEARER_TOKEN_BEDROCK',
|
||||
};
|
||||
|
||||
/** Model used when SHANNON_AI_MODEL is unset. */
|
||||
export const DEFAULT_MODEL_SPEC = 'anthropic:claude-sonnet-4-6';
|
||||
|
||||
/**
|
||||
* Values SHANNON_AI_OPENAI_FORMAT accepts, selecting the wire format an
|
||||
* OpenAI-compatible gateway serves. Mirrors OPENAI_FORMATS in
|
||||
* apps/worker/src/ai/models.ts; the worker validates and applies it.
|
||||
*/
|
||||
export const OPENAI_FORMATS = ['chat-completions', 'responses'] as const;
|
||||
|
||||
export type OpenAiFormat = (typeof OPENAI_FORMATS)[number];
|
||||
|
||||
export interface ModelSpec {
|
||||
providerId: string;
|
||||
modelId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a `<provider>:<model-id>` spec. Splits on the first colon only, so colons
|
||||
* inside a model ID survive (`amazon-bedrock:us.anthropic.claude-opus-4-5-20251101-v1:0`).
|
||||
* The provider id is passed through as given — the worker's preflight validates it
|
||||
* against pi. Returns an error string rather than throwing, for the CLI's flow.
|
||||
*/
|
||||
export function parseModelSpec(spec: string): ModelSpec | string {
|
||||
const trimmed = spec.trim();
|
||||
const separator = trimmed.indexOf(':');
|
||||
const malformed = `SHANNON_AI_MODEL must be "<provider>:<model-id>", got "${trimmed}". Example: ${DEFAULT_MODEL_SPEC}`;
|
||||
if (separator === -1) return malformed;
|
||||
|
||||
const providerId = trimmed.slice(0, separator).trim();
|
||||
const modelId = trimmed.slice(separator + 1).trim();
|
||||
if (!providerId || !modelId) return malformed;
|
||||
|
||||
return { providerId, modelId };
|
||||
}
|
||||
|
||||
/** Resolve the run's model spec from the environment, or an error string. */
|
||||
export function resolveModelSpec(): ModelSpec | string {
|
||||
return parseModelSpec(process.env.SHANNON_AI_MODEL || DEFAULT_MODEL_SPEC);
|
||||
}
|
||||
+57
-31
@@ -1,13 +1,27 @@
|
||||
/**
|
||||
* Path resolution for --repo and --config arguments.
|
||||
*
|
||||
* Local mode supports bare repo names (e.g. "my-repo" → ./repos/my-repo).
|
||||
* Both modes resolve relative paths against CWD.
|
||||
* Both --repo and --config are filesystem paths, absolute or relative to CWD.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { isLocal } from './mode.js';
|
||||
import { fail } from './errors.js';
|
||||
|
||||
/**
|
||||
* Expand a leading `~` or `~/` to the home directory. The shell skips this in the
|
||||
* `--flag=~/x` form (the tilde is not at the word start), so it must be done here.
|
||||
*/
|
||||
export function expandHome(inputPath: string): string {
|
||||
if (inputPath === '~') {
|
||||
return os.homedir();
|
||||
}
|
||||
if (inputPath.startsWith('~/')) {
|
||||
return path.join(os.homedir(), inputPath.slice(2));
|
||||
}
|
||||
return inputPath;
|
||||
}
|
||||
|
||||
export interface MountPair {
|
||||
hostPath: string;
|
||||
@@ -15,36 +29,50 @@ export interface MountPair {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve --repo to absolute path and container mount.
|
||||
* Dev mode: bare names (no / or . prefix) check ./repos/<name> first.
|
||||
* Hidden subdirectory inside each run directory that holds all internals
|
||||
* (deliverables, logs, prompts, session state, browser artifacts). Keeps the
|
||||
* run folder's top level clean so only the final report is visible. Must match
|
||||
* INTERNAL_DIR in the worker package.
|
||||
*/
|
||||
export const INTERNAL_DIR = '.shannon';
|
||||
|
||||
/**
|
||||
* Filename of the human-facing PDF report surfaced at the run directory root.
|
||||
* Must match FINAL_REPORT_PDF_FILENAME in the worker package.
|
||||
*/
|
||||
export const FINAL_REPORT_PDF_FILENAME = 'Security-Assessment-Report.pdf';
|
||||
|
||||
/**
|
||||
* Resolve a run-directory file (e.g. session.json, workflow.log), preferring the
|
||||
* current INTERNAL_DIR location and falling back to the legacy run-root location
|
||||
* so pre-restructure workspaces keep working. Returns the INTERNAL_DIR path when
|
||||
* neither exists — the right default for new runs and error messages.
|
||||
*/
|
||||
export function resolveRunFile(runDir: string, filename: string): string {
|
||||
const current = path.join(runDir, INTERNAL_DIR, filename);
|
||||
if (fs.existsSync(current)) {
|
||||
return current;
|
||||
}
|
||||
const legacy = path.join(runDir, filename);
|
||||
if (fs.existsSync(legacy)) {
|
||||
return legacy;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve --repo to an absolute path and container mount. The argument is a
|
||||
* filesystem path, absolute or relative to CWD.
|
||||
*/
|
||||
export function resolveRepo(repoArg: string): MountPair {
|
||||
let hostPath: string;
|
||||
|
||||
if (isLocal() && !repoArg.startsWith('/') && !repoArg.startsWith('.')) {
|
||||
// Bare name — check ./repos/<name> for backward compatibility
|
||||
const barePath = path.resolve('repos', repoArg);
|
||||
if (fs.existsSync(barePath)) {
|
||||
hostPath = barePath;
|
||||
} else {
|
||||
console.error(`ERROR: Repository not found at ./repos/${repoArg}`);
|
||||
console.error('');
|
||||
console.error('Place your target repository under the ./repos/ directory,');
|
||||
console.error('or pass an absolute/relative path: -r /path/to/repo');
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
hostPath = path.resolve(repoArg);
|
||||
}
|
||||
const hostPath = path.resolve(expandHome(repoArg));
|
||||
|
||||
if (!fs.existsSync(hostPath)) {
|
||||
console.error(`ERROR: Repository not found: ${hostPath}`);
|
||||
process.exit(1);
|
||||
fail(`Repository not found: ${hostPath}`);
|
||||
}
|
||||
|
||||
if (!fs.statSync(hostPath).isDirectory()) {
|
||||
console.error(`ERROR: Not a directory: ${hostPath}`);
|
||||
process.exit(1);
|
||||
fail(`Not a directory: ${hostPath}`);
|
||||
}
|
||||
|
||||
const basename = path.basename(hostPath);
|
||||
@@ -58,16 +86,14 @@ export function resolveRepo(repoArg: string): MountPair {
|
||||
* Resolve --config to absolute path and container mount.
|
||||
*/
|
||||
export function resolveConfig(configArg: string): MountPair {
|
||||
const hostPath = path.resolve(configArg);
|
||||
const hostPath = path.resolve(expandHome(configArg));
|
||||
|
||||
if (!fs.existsSync(hostPath)) {
|
||||
console.error(`ERROR: Config file not found: ${hostPath}`);
|
||||
process.exit(1);
|
||||
fail(`Config file not found: ${hostPath}`);
|
||||
}
|
||||
|
||||
if (!fs.statSync(hostPath).isFile()) {
|
||||
console.error(`ERROR: Not a file: ${hostPath}`);
|
||||
process.exit(1);
|
||||
fail(`Not a file: ${hostPath}`);
|
||||
}
|
||||
|
||||
const basename = path.basename(hostPath);
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Pure derivation of a scan's per-agent and per-phase state from its Temporal snapshot.
|
||||
*
|
||||
* This is the single source of truth for "what state is each agent in" — both the
|
||||
* human progress tree (render.ts) and the machine-readable snapshot (status-json.ts)
|
||||
* consume it, so the two views can never disagree about whether an agent is running,
|
||||
* skipped, or still pending. No glyphs, no color, no formatting live here.
|
||||
*/
|
||||
|
||||
import type { RunningAgent } from '../temporal-client.js';
|
||||
import { agentClass, PIPELINE, type PipelineState } from './pipeline.js';
|
||||
import type { RenderInput } from './render.js';
|
||||
|
||||
export type RunState = 'pending' | 'running' | 'completed' | 'failed' | 'skipped';
|
||||
|
||||
/** One agent's resolved state plus the raw metrics/timing a consumer needs to present it. Null metrics
|
||||
* mean the value doesn't apply to the current state (e.g. duration only for completed agents). */
|
||||
export interface DerivedAgent {
|
||||
readonly name: string;
|
||||
readonly label: string;
|
||||
readonly state: RunState;
|
||||
readonly durationMs: number | null;
|
||||
readonly runningElapsedMs: number | null;
|
||||
readonly attempt: number | null;
|
||||
readonly error?: string;
|
||||
}
|
||||
|
||||
export interface DerivedPhase {
|
||||
readonly key: string;
|
||||
readonly label: string;
|
||||
readonly parallel: boolean;
|
||||
readonly state: RunState;
|
||||
readonly agents: readonly DerivedAgent[];
|
||||
}
|
||||
|
||||
/** Terminal = anything other than an open, running execution. */
|
||||
export function isTerminal(status: string): boolean {
|
||||
return status !== 'RUNNING' && status !== 'UNSPECIFIED';
|
||||
}
|
||||
|
||||
function isFailedAgent(name: string, state: PipelineState | null): boolean {
|
||||
return !!state && (state.failedAgent === name || state.failedPipelines.some((f) => f.vulnType === agentClass(name)));
|
||||
}
|
||||
|
||||
/** An agent has entered play once it is running, has metrics, or has failed. */
|
||||
function isAgentActive(name: string, state: PipelineState | null, running: Set<string>): boolean {
|
||||
return running.has(name) || !!state?.agentMetrics[name] || isFailedAgent(name, state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one agent's state. "Ran" is signalled by a metrics entry, not by
|
||||
* completedAgents — the workflow lists conditionally-skipped agents (e.g. exploit
|
||||
* agents when there is nothing to exploit) as completed but records no metrics for
|
||||
* them. `resolved` is true once we've moved past this agent's phase (the scan is
|
||||
* terminal, or a later phase is already active), at which point a metric-less,
|
||||
* non-running agent is skipped rather than still pending.
|
||||
*/
|
||||
function agentState(name: string, state: PipelineState | null, running: Set<string>, resolved: boolean): RunState {
|
||||
if (running.has(name)) return 'running';
|
||||
if (isFailedAgent(name, state)) return 'failed';
|
||||
if (state?.agentMetrics[name]) return 'completed';
|
||||
return resolved ? 'skipped' : 'pending';
|
||||
}
|
||||
|
||||
function agentError(name: string, state: PipelineState | null, byAgent: Map<string, RunningAgent>): string | undefined {
|
||||
const failed = state?.failedPipelines.find((f) => f.vulnType === agentClass(name));
|
||||
return (
|
||||
failed?.error ??
|
||||
byAgent.get(name)?.lastFailure ??
|
||||
(state?.failedAgent === name ? (state.error ?? undefined) : undefined)
|
||||
);
|
||||
}
|
||||
|
||||
/** Scan wall-clock elapsed ms: recorded duration for a closed scan, live elapsed for a running one. */
|
||||
export function scanElapsedMs(input: RenderInput, now: number): number | undefined {
|
||||
if (isTerminal(input.temporalStatus)) {
|
||||
if (input.state?.summary) return input.state.summary.totalDurationMs;
|
||||
if (input.endedAt !== undefined && input.startedAt !== undefined) return input.endedAt - input.startedAt;
|
||||
return undefined;
|
||||
}
|
||||
return input.startedAt !== undefined ? now - input.startedAt : undefined;
|
||||
}
|
||||
|
||||
/** Collapse a phase's agent states into a single state for the phase line. */
|
||||
export function phaseGlyphState(states: readonly RunState[]): RunState {
|
||||
if (states.some((s) => s === 'running')) return 'running';
|
||||
if (states.some((s) => s === 'failed')) return 'failed';
|
||||
if (states.every((s) => s === 'skipped')) return 'skipped';
|
||||
if (states.every((s) => s === 'completed' || s === 'skipped')) return 'completed';
|
||||
if (states.some((s) => s === 'completed')) return 'running';
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute each agent's RunState. This is the drift-prone part shared by every view.
|
||||
*
|
||||
* The pipeline is sequential across phases: the last phase with any active agent is the
|
||||
* frontier. Earlier phases with nothing active were skipped (e.g. exploitation when no
|
||||
* class had anything to exploit), not still pending.
|
||||
*/
|
||||
export function deriveAgentStates(input: RenderInput): Map<string, RunState> {
|
||||
const runningSet = new Set(input.running.map((r) => r.agent));
|
||||
const terminal = isTerminal(input.temporalStatus);
|
||||
|
||||
let frontier = -1;
|
||||
PIPELINE.forEach((phase, idx) => {
|
||||
if (phase.agents.some((a) => isAgentActive(a.name, input.state, runningSet))) frontier = idx;
|
||||
});
|
||||
|
||||
const states = new Map<string, RunState>();
|
||||
for (const [phaseIdx, phase] of PIPELINE.entries()) {
|
||||
const resolved = terminal || phaseIdx < frontier;
|
||||
for (const agent of phase.agents) {
|
||||
states.set(agent.name, agentState(agent.name, input.state, runningSet, resolved));
|
||||
}
|
||||
}
|
||||
return states;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full structured view of the pipeline: every agent's state plus the raw
|
||||
* metrics/timing needed to present it, and each phase's collapsed state.
|
||||
*/
|
||||
export function derivePipeline(input: RenderInput, now: number): DerivedPhase[] {
|
||||
const states = deriveAgentStates(input);
|
||||
const byAgent = new Map(input.running.map((r) => [r.agent, r]));
|
||||
|
||||
return PIPELINE.map((phase) => {
|
||||
const agents = phase.agents.map((a): DerivedAgent => {
|
||||
const state = states.get(a.name) ?? 'pending';
|
||||
const metrics = input.state?.agentMetrics[a.name];
|
||||
const runner = byAgent.get(a.name);
|
||||
const error = agentError(a.name, input.state, byAgent);
|
||||
return {
|
||||
name: a.name,
|
||||
label: a.label,
|
||||
state,
|
||||
durationMs: state === 'completed' && metrics ? metrics.durationMs : null,
|
||||
runningElapsedMs: state === 'running' && runner?.startedAt !== undefined ? now - runner.startedAt : null,
|
||||
attempt: state === 'running' && runner ? runner.attempt : null,
|
||||
...(error !== undefined && { error }),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
key: phase.key,
|
||||
label: phase.label,
|
||||
parallel: phase.parallel,
|
||||
state: phaseGlyphState(agents.map((ag) => ag.state)),
|
||||
agents,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export { agentError };
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Static description of the Shannon scan pipeline, plus the worker types the CLI
|
||||
* reads back from Temporal.
|
||||
*
|
||||
* The CLI cannot import from the worker package, so this mirrors it. Keep in sync with:
|
||||
* - apps/worker/src/types/agents.ts (agent names / ordering)
|
||||
* - apps/worker/src/session-manager.ts (phase membership)
|
||||
* - apps/worker/src/temporal/activities.ts (the run*Agent activity names → `activityType`)
|
||||
* - apps/worker/src/temporal/shared.ts (PipelineState / PipelineSummary)
|
||||
* - apps/worker/src/types/metrics.ts (AgentMetrics)
|
||||
*/
|
||||
|
||||
export interface AgentSpec {
|
||||
/** Canonical agent name as it appears in PipelineState.completedAgents / agentMetrics. */
|
||||
readonly name: string;
|
||||
/** Short label for the progress tree. */
|
||||
readonly label: string;
|
||||
/** Temporal activity type name — how a running agent shows up in pendingActivities. */
|
||||
readonly activityType: string;
|
||||
}
|
||||
|
||||
export interface PhaseSpec {
|
||||
readonly key: string;
|
||||
readonly label: string;
|
||||
readonly parallel: boolean;
|
||||
readonly agents: readonly AgentSpec[];
|
||||
}
|
||||
|
||||
/** The pipeline phases in execution order, each with its agents. */
|
||||
export const PIPELINE: readonly PhaseSpec[] = [
|
||||
{
|
||||
// Preflight login check. Only authenticated scans record metrics here; a non-auth scan
|
||||
// records none, so it renders as skipped — like Exploitation when nothing is exploitable.
|
||||
key: 'auth-validation',
|
||||
label: 'Authentication',
|
||||
parallel: false,
|
||||
agents: [{ name: 'validate-authentication', label: 'auth', activityType: 'runAuthenticationValidation' }],
|
||||
},
|
||||
{
|
||||
key: 'pre-recon',
|
||||
label: 'Pre-Recon',
|
||||
parallel: false,
|
||||
agents: [{ name: 'pre-recon', label: 'pre-recon', activityType: 'runPreReconAgent' }],
|
||||
},
|
||||
{
|
||||
key: 'recon',
|
||||
label: 'Recon',
|
||||
parallel: false,
|
||||
agents: [{ name: 'recon', label: 'recon', activityType: 'runReconAgent' }],
|
||||
},
|
||||
{
|
||||
key: 'vulnerability-analysis',
|
||||
label: 'Vulnerability Analysis',
|
||||
parallel: true,
|
||||
agents: [
|
||||
{ name: 'injection-vuln', label: 'injection', activityType: 'runInjectionVulnAgent' },
|
||||
{ name: 'xss-vuln', label: 'xss', activityType: 'runXssVulnAgent' },
|
||||
{ name: 'auth-vuln', label: 'auth', activityType: 'runAuthVulnAgent' },
|
||||
{ name: 'ssrf-vuln', label: 'ssrf', activityType: 'runSsrfVulnAgent' },
|
||||
{ name: 'authz-vuln', label: 'authz', activityType: 'runAuthzVulnAgent' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'exploitation',
|
||||
label: 'Exploitation',
|
||||
parallel: true,
|
||||
agents: [
|
||||
{ name: 'injection-exploit', label: 'injection', activityType: 'runInjectionExploitAgent' },
|
||||
{ name: 'xss-exploit', label: 'xss', activityType: 'runXssExploitAgent' },
|
||||
{ name: 'auth-exploit', label: 'auth', activityType: 'runAuthExploitAgent' },
|
||||
{ name: 'ssrf-exploit', label: 'ssrf', activityType: 'runSsrfExploitAgent' },
|
||||
{ name: 'authz-exploit', label: 'authz', activityType: 'runAuthzExploitAgent' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'reporting',
|
||||
label: 'Reporting',
|
||||
parallel: false,
|
||||
agents: [{ name: 'report', label: 'report', activityType: 'runReportAgent' }],
|
||||
},
|
||||
];
|
||||
|
||||
/** Temporal activity type name → canonical agent name, for mapping pendingActivities. */
|
||||
export const ACTIVITY_TO_AGENT: Readonly<Record<string, string>> = Object.fromEntries(
|
||||
PIPELINE.flatMap((phase) => phase.agents.map((agent) => [agent.activityType, agent.name])),
|
||||
);
|
||||
|
||||
/** The vuln/exploit class of an agent (e.g. "authz-vuln" → "authz"), for failedPipelines matching. */
|
||||
export function agentClass(name: string): string {
|
||||
return name.replace(/-(vuln|exploit)$/, '');
|
||||
}
|
||||
|
||||
// === Worker types read back from Temporal (mirror of shared.ts / metrics.ts) ===
|
||||
|
||||
export interface AgentMetrics {
|
||||
readonly durationMs: number;
|
||||
readonly costUsd: number | null;
|
||||
readonly numTurns: number | null;
|
||||
readonly model?: string;
|
||||
readonly skipped?: boolean;
|
||||
}
|
||||
|
||||
export interface PipelineSummary {
|
||||
readonly totalCostUsd: number;
|
||||
readonly totalDurationMs: number; // Wall-clock (end - start)
|
||||
readonly totalTurns: number;
|
||||
readonly agentCount: number;
|
||||
}
|
||||
|
||||
export type PipelineStatus = 'running' | 'completed' | 'failed' | 'cancelled' | 'partial';
|
||||
|
||||
export interface PipelineState {
|
||||
readonly status: PipelineStatus;
|
||||
readonly currentPhase: string | null;
|
||||
readonly currentAgent: string | null;
|
||||
readonly completedAgents: string[];
|
||||
readonly failedPipelines: { vulnType: string; error: string }[];
|
||||
readonly failedAgent: string | null;
|
||||
readonly error: string | null;
|
||||
readonly startTime: number;
|
||||
readonly agentMetrics: Record<string, AgentMetrics>;
|
||||
readonly summary: PipelineSummary | null;
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* Renders a scan's Temporal state into the terminal progress tree.
|
||||
*
|
||||
* The same PipelineState drives both the live view (from the getProgress query) and
|
||||
* the final view (from the workflow result); the running-agents overlay (from
|
||||
* pendingActivities) supplies the in-flight set and retry counts the state lacks.
|
||||
* Colors and Unicode glyphs are gated by the caller so the frame degrades off a TTY.
|
||||
*/
|
||||
|
||||
import { BOLD, DIM, GOLD, paint, RED, YELLOW } from '../colors.js';
|
||||
import { commandPrefix } from '../mode.js';
|
||||
import type { RunningAgent } from '../temporal-client.js';
|
||||
import { agentError, deriveAgentStates, isTerminal, phaseGlyphState, type RunState, scanElapsedMs } from './derive.js';
|
||||
import { PIPELINE, type PipelineState } from './pipeline.js';
|
||||
|
||||
export interface RenderInput {
|
||||
readonly workspace: string;
|
||||
/** Temporal workflow id backing this scan (differs from workspace on a resume); used for the dashboard link. */
|
||||
readonly workflowId?: string;
|
||||
/** Temporal WorkflowExecutionStatusName: RUNNING | COMPLETED | FAILED | CANCELLED | TERMINATED | … */
|
||||
readonly temporalStatus: string;
|
||||
/** Progress (live) or result (terminal). Null when unavailable, e.g. a hard failure with no result. */
|
||||
readonly state: PipelineState | null;
|
||||
readonly running: readonly RunningAgent[];
|
||||
readonly startedAt?: number;
|
||||
readonly endedAt?: number;
|
||||
/** Failure text when a failed scan has no readable state. */
|
||||
readonly failureMessage?: string;
|
||||
}
|
||||
|
||||
export interface RenderOptions {
|
||||
readonly now: number;
|
||||
readonly color: boolean;
|
||||
readonly unicode: boolean;
|
||||
/** True for the live view (adds a watch footer); false for the final/one-shot frame. */
|
||||
readonly live: boolean;
|
||||
/** Animation tick — advances the running-agent spinner. Ignored for static frames. */
|
||||
readonly frame: number;
|
||||
}
|
||||
|
||||
const COLORS = {
|
||||
red: RED,
|
||||
gold: GOLD,
|
||||
yellow: YELLOW,
|
||||
dim: DIM,
|
||||
bold: BOLD,
|
||||
} as const;
|
||||
|
||||
// === Formatting ===
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
const seconds = Math.max(0, Math.floor(ms / 1000));
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = seconds % 60;
|
||||
|
||||
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||
if (minutes > 0) return `${minutes}m ${secs}s`;
|
||||
return `${secs}s`;
|
||||
}
|
||||
|
||||
function truncate(text: string, max: number): string {
|
||||
const flat = text.replace(/\s+/g, ' ').trim();
|
||||
return flat.length <= max ? flat : `${flat.slice(0, max - 1)}…`;
|
||||
}
|
||||
|
||||
/** Temporal Web UI, published by compose on 8233; deep-links to the workflow when its id is known. */
|
||||
function temporalDashboardUrl(workflowId: string | undefined): string {
|
||||
const base = 'http://localhost:8233';
|
||||
return workflowId ? `${base}/namespaces/default/workflows/${workflowId}` : base;
|
||||
}
|
||||
|
||||
// === Glyphs & status ===
|
||||
|
||||
const GLYPH_UNICODE: Record<RunState, string> = {
|
||||
pending: '○',
|
||||
running: '⟳',
|
||||
completed: '●',
|
||||
failed: '✗',
|
||||
skipped: '·',
|
||||
};
|
||||
const GLYPH_ASCII: Record<RunState, string> = {
|
||||
pending: '.',
|
||||
running: '>',
|
||||
completed: '+',
|
||||
failed: 'x',
|
||||
skipped: '-',
|
||||
};
|
||||
const STATE_COLOR: Record<RunState, string> = {
|
||||
pending: COLORS.dim,
|
||||
running: COLORS.gold,
|
||||
completed: COLORS.gold,
|
||||
failed: COLORS.red,
|
||||
skipped: COLORS.dim,
|
||||
};
|
||||
|
||||
/** Braille spinner frames for running agents — the clack loader style. */
|
||||
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] as const;
|
||||
|
||||
function glyph(state: RunState, opts: RenderOptions): string {
|
||||
if (state === 'running' && opts.unicode) {
|
||||
const spin = SPINNER_FRAMES[opts.frame % SPINNER_FRAMES.length] ?? SPINNER_FRAMES[0];
|
||||
return paint(spin, STATE_COLOR.running, opts.color);
|
||||
}
|
||||
const symbol = opts.unicode ? GLYPH_UNICODE[state] : GLYPH_ASCII[state];
|
||||
return paint(symbol, STATE_COLOR[state], opts.color);
|
||||
}
|
||||
|
||||
/** Badge text + color for the scan as a whole, preferring the workflow's own status when known. */
|
||||
function statusBadge(input: RenderInput, opts: RenderOptions): string {
|
||||
const workflowStatus = input.state?.status;
|
||||
if (!isTerminal(input.temporalStatus)) return paint('running', COLORS.gold, opts.color);
|
||||
if (workflowStatus === 'partial') return paint('partial', COLORS.yellow, opts.color);
|
||||
if (input.temporalStatus === 'COMPLETED') return paint('completed', COLORS.gold, opts.color);
|
||||
if (input.temporalStatus === 'TERMINATED') return paint('stopped', COLORS.yellow, opts.color);
|
||||
if (input.temporalStatus === 'CANCELLED' || input.temporalStatus === 'CANCELED') {
|
||||
return paint('cancelled', COLORS.yellow, opts.color);
|
||||
}
|
||||
if (input.temporalStatus === 'TIMED_OUT') return paint('timed out', COLORS.red, opts.color);
|
||||
return paint('FAILED', COLORS.red, opts.color);
|
||||
}
|
||||
|
||||
// === Line builders ===
|
||||
|
||||
function agentMeta(
|
||||
state: RunState,
|
||||
metrics: { durationMs: number } | undefined,
|
||||
runner: RunningAgent | undefined,
|
||||
error: string | undefined,
|
||||
opts: RenderOptions,
|
||||
): string {
|
||||
if (state === 'completed') {
|
||||
const duration = metrics?.durationMs != null ? formatDuration(metrics.durationMs) : 'done';
|
||||
return paint(duration, COLORS.dim, opts.color);
|
||||
}
|
||||
if (state === 'running') {
|
||||
const parts = ['running'];
|
||||
if (runner?.startedAt !== undefined) parts.push(formatDuration(opts.now - runner.startedAt));
|
||||
if (runner && runner.attempt > 1) parts.push(`retry ${runner.attempt}`);
|
||||
return paint(parts.join(' · '), COLORS.gold, opts.color);
|
||||
}
|
||||
if (state === 'failed') {
|
||||
const detail = error ? ` · ${truncate(error, 46)}` : '';
|
||||
return paint(`failed${detail}`, COLORS.red, opts.color);
|
||||
}
|
||||
if (state === 'skipped') return paint('skipped', COLORS.dim, opts.color);
|
||||
return paint('queued', COLORS.dim, opts.color);
|
||||
}
|
||||
|
||||
function phaseMeta(states: readonly RunState[], inPlay: number, parallel: boolean, opts: RenderOptions): string {
|
||||
if (states.every((s) => s === 'pending')) return paint('pending', COLORS.dim, opts.color);
|
||||
if (states.every((s) => s === 'skipped')) return paint('skipped', COLORS.dim, opts.color);
|
||||
if (states.some((s) => s === 'failed') && !states.some((s) => s === 'running')) {
|
||||
return paint('failed', COLORS.red, opts.color);
|
||||
}
|
||||
if (!parallel) return '';
|
||||
const done = states.filter((s) => s === 'completed').length;
|
||||
const allDone = states.every((s) => s === 'completed' || s === 'skipped');
|
||||
return paint(`${done}/${inPlay} done`, allDone ? COLORS.gold : COLORS.dim, opts.color);
|
||||
}
|
||||
|
||||
/** Render the full progress frame as one string (no trailing newline). */
|
||||
export function renderScan(input: RenderInput, opts: RenderOptions): string {
|
||||
const byAgent = new Map(input.running.map((r) => [r.agent, r]));
|
||||
const stateMap = deriveAgentStates(input);
|
||||
const lines: string[] = ['', ...headerLines(input, opts), ''];
|
||||
|
||||
const metaFor = (name: string, state: RunState): string =>
|
||||
agentMeta(state, input.state?.agentMetrics[name], byAgent.get(name), agentError(name, input.state, byAgent), opts);
|
||||
// Only agents that have actually entered play are shown; pending/skipped ones stay hidden.
|
||||
const inPlay = (s: RunState): boolean => s === 'running' || s === 'completed' || s === 'failed';
|
||||
|
||||
for (const phase of PIPELINE) {
|
||||
const states = phase.agents.map((a) => stateMap.get(a.name) ?? 'pending');
|
||||
const playing = states.filter(inPlay).length;
|
||||
const phaseRunState: RunState = phaseGlyphState(states);
|
||||
|
||||
// A single-agent phase carries that agent's own duration/cost on the phase line once it
|
||||
// starts; a parallel phase gets a "k/N done" summary over the agents in play.
|
||||
const first = phase.agents[0];
|
||||
const firstState = states[0];
|
||||
const phaseMetaStr =
|
||||
!phase.parallel && first && firstState && inPlay(firstState)
|
||||
? metaFor(first.name, firstState)
|
||||
: phaseMeta(states, playing, phase.parallel, opts);
|
||||
lines.push(` ${glyph(phaseRunState, opts)} ${phase.label.padEnd(26)}${phaseMetaStr}`);
|
||||
|
||||
if (!phase.parallel) continue;
|
||||
for (let i = 0; i < phase.agents.length; i++) {
|
||||
const agent = phase.agents[i];
|
||||
const state = states[i];
|
||||
if (!agent || !state || !inPlay(state)) continue;
|
||||
lines.push(` ${glyph(state, opts)} ${agent.label.padEnd(18)}${metaFor(agent.name, state)}`);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(...footerLines(input, opts));
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function headerLines(input: RenderInput, opts: RenderOptions): string[] {
|
||||
const elapsedMs = scanElapsedMs(input, opts.now);
|
||||
const meta = [statusBadge(input, opts), elapsedMs !== undefined ? formatDuration(elapsedMs) : '—'].join(' · ');
|
||||
return [` ${paint('Scan:', COLORS.bold, opts.color)} ${input.workspace.padEnd(22)} ${meta}`];
|
||||
}
|
||||
|
||||
/** Aligned label column for the footer's Logs / Temporal rows. */
|
||||
const FOOTER_LABEL_WIDTH = 12;
|
||||
|
||||
/** A thin rule that sets the footer apart from the phase list above it. */
|
||||
function footerDivider(opts: RenderOptions): string {
|
||||
return paint(` ${(opts.unicode ? '─' : '-').repeat(60)}`, COLORS.dim, opts.color);
|
||||
}
|
||||
|
||||
/** One footer row: an accent-colored label in a fixed column, then its value in the default color. */
|
||||
function footerRow(label: string, value: string, opts: RenderOptions): string {
|
||||
return ` ${paint(label.padEnd(FOOTER_LABEL_WIDTH), COLORS.gold, opts.color)}${value}`;
|
||||
}
|
||||
|
||||
function footerLines(input: RenderInput, opts: RenderOptions): string[] {
|
||||
const prefix = commandPrefix();
|
||||
|
||||
if (isTerminal(input.temporalStatus) && input.state?.summary) {
|
||||
const wall = formatDuration(input.state.summary.totalDurationMs);
|
||||
return ['', ` Time Taken ${wall}`];
|
||||
}
|
||||
|
||||
const logsValue = `${prefix} logs ${input.workspace}`;
|
||||
const temporalValue = temporalDashboardUrl(input.workflowId);
|
||||
|
||||
if (isTerminal(input.temporalStatus)) {
|
||||
const reason = input.failureMessage ?? input.state?.error ?? 'no result recorded';
|
||||
return [
|
||||
footerDivider(opts),
|
||||
paint(
|
||||
` ${input.temporalStatus === 'TERMINATED' ? 'Stopped' : 'Ended'} — ${truncate(reason, 240)}`,
|
||||
COLORS.dim,
|
||||
opts.color,
|
||||
),
|
||||
footerRow('Logs', logsValue, opts),
|
||||
footerRow('Temporal', temporalValue, opts),
|
||||
];
|
||||
}
|
||||
|
||||
const lines = [footerDivider(opts), footerRow('Logs', logsValue, opts), footerRow('Temporal', temporalValue, opts)];
|
||||
if (opts.live) lines.push('', paint(' Ctrl-C stops watching — the scan keeps running.', COLORS.dim, opts.color));
|
||||
return lines;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Machine-readable snapshot of one scan, for `shannon status --json`.
|
||||
*
|
||||
* A point-in-time view built from the same derivation the human progress tree uses
|
||||
* (derive.ts), so the JSON and the rendered tree can never disagree about an agent's
|
||||
* state. One invocation is one snapshot — callers that want to track progress poll it.
|
||||
*/
|
||||
|
||||
import type { DerivedPhase } from './derive.js';
|
||||
import { derivePipeline, isTerminal, scanElapsedMs } from './derive.js';
|
||||
import type { RenderInput } from './render.js';
|
||||
|
||||
/** Coarse scan status token, mirroring the human status badge in machine-friendly form. */
|
||||
export type ScanStatus = 'running' | 'completed' | 'partial' | 'failed' | 'stopped' | 'cancelled' | 'timed_out';
|
||||
|
||||
export interface StatusJson {
|
||||
readonly workspace: string;
|
||||
/** Temporal workflow id backing this scan (differs from workspace on a resume). */
|
||||
readonly workflowId?: string;
|
||||
/** Coarse outcome: `running` until the scan closes, then its terminal status. */
|
||||
readonly status: ScanStatus;
|
||||
/** Raw Temporal WorkflowExecutionStatusName, for callers that need the source status. */
|
||||
readonly temporalStatus: string;
|
||||
/** Wall-clock elapsed ms (live for a running scan, final for a closed one), or null when unknown. */
|
||||
readonly elapsedMs: number | null;
|
||||
readonly startedAt?: string;
|
||||
readonly endedAt?: string;
|
||||
/** Failure text when a failed scan left no readable state. */
|
||||
readonly failureMessage?: string;
|
||||
readonly phases: readonly DerivedPhase[];
|
||||
}
|
||||
|
||||
/** Map the raw Temporal status (and workflow status) onto the coarse machine token. */
|
||||
function deriveStatus(input: RenderInput): ScanStatus {
|
||||
if (!isTerminal(input.temporalStatus)) return 'running';
|
||||
if (input.state?.status === 'partial') return 'partial';
|
||||
|
||||
switch (input.temporalStatus) {
|
||||
case 'COMPLETED':
|
||||
return 'completed';
|
||||
case 'TERMINATED':
|
||||
return 'stopped';
|
||||
case 'CANCELLED':
|
||||
case 'CANCELED':
|
||||
return 'cancelled';
|
||||
case 'TIMED_OUT':
|
||||
return 'timed_out';
|
||||
default:
|
||||
return 'failed';
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the JSON snapshot for a scan at instant `now`. */
|
||||
export function toStatusJson(input: RenderInput, now: number): StatusJson {
|
||||
const elapsedMs = scanElapsedMs(input, now);
|
||||
|
||||
return {
|
||||
workspace: input.workspace,
|
||||
...(input.workflowId !== undefined && { workflowId: input.workflowId }),
|
||||
status: deriveStatus(input),
|
||||
temporalStatus: input.temporalStatus,
|
||||
elapsedMs: elapsedMs ?? null,
|
||||
...(input.startedAt !== undefined && { startedAt: new Date(input.startedAt).toISOString() }),
|
||||
...(input.endedAt !== undefined && { endedAt: new Date(input.endedAt).toISOString() }),
|
||||
...(input.failureMessage !== undefined && { failureMessage: input.failureMessage }),
|
||||
phases: derivePipeline(input, now),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Workspace → Temporal workflow-id resolution.
|
||||
*
|
||||
* A workspace name is not always its workflow id: a fresh scan's id equals the
|
||||
* workspace name, but each resume spawns a new workflow (`<workspace>_resume_<ts>`).
|
||||
* The workspace's session.json records the authoritative id — the latest resume
|
||||
* attempt, or the original — so commands that query Temporal (status, stop) resolve
|
||||
* through here instead of assuming the name is the id.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { getWorkspacesDir } from './home.js';
|
||||
import { resolveRunFile } from './paths.js';
|
||||
|
||||
/** Latest workflow id recorded for a workspace: last resume attempt, else the original. */
|
||||
export function resolveWorkflowId(workspace: string): string | undefined {
|
||||
const sessionPath = resolveRunFile(path.join(getWorkspacesDir(), workspace), 'session.json');
|
||||
try {
|
||||
const session = JSON.parse(fs.readFileSync(sessionPath, 'utf-8'));
|
||||
const resumeAttempts: { workflowId?: string }[] = session.session?.resumeAttempts ?? [];
|
||||
return resumeAttempts.at(-1)?.workflowId ?? session.session?.originalWorkflowId ?? undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
+65
-38
@@ -1,50 +1,77 @@
|
||||
/**
|
||||
* Splash screen display — pure terminal output, no npm dependencies.
|
||||
* Color escapes are gated on terminal support; the Unicode art is always kept.
|
||||
*/
|
||||
|
||||
export function displaySplash(version?: string): void {
|
||||
const GOLD = '\x1b[38;2;244;197;66m';
|
||||
const CYAN = '\x1b[36;1m';
|
||||
const WHITE = '\x1b[1;37m';
|
||||
const GRAY = '\x1b[0;37m';
|
||||
const YELLOW = '\x1b[1;33m';
|
||||
const RESET = '\x1b[0m';
|
||||
import { supportsColor } from './tty.js';
|
||||
|
||||
const B = `${CYAN}\u2551${RESET}`;
|
||||
const S67 = ' '.repeat(67);
|
||||
const HR = '\u2550'.repeat(67);
|
||||
/** SHANNON wordmark. Block glyphs take the row fill; box-drawing strokes take the deeper edge shade. */
|
||||
const SHANNON = [
|
||||
'███████╗██╗ ██╗ █████╗ ███╗ ██╗███╗ ██╗ ██████╗ ███╗ ██╗',
|
||||
'██╔════╝██║ ██║██╔══██╗████╗ ██║████╗ ██║██╔═══██╗████╗ ██║',
|
||||
'███████╗███████║███████║██╔██╗ ██║██╔██╗ ██║██║ ██║██╔██╗ ██║',
|
||||
'╚════██║██╔══██║██╔══██║██║╚██╗██║██║╚██╗██║██║ ██║██║╚██╗██║',
|
||||
'███████║██║ ██║██║ ██║██║ ╚████║██║ ╚████║╚██████╔╝██║ ╚████║',
|
||||
'╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝ ╚═══╝ ╚═════╝ ╚═╝ ╚═══╝',
|
||||
];
|
||||
|
||||
/**
|
||||
* Sunset ramp, yellow at the top row down to burnt orange at the base.
|
||||
* Wordmark row i is filled with stop i and edged with stop i + 1, so the
|
||||
* box-drawing strokes read as a shadow one shade deeper than their row.
|
||||
* `xterm` is the 256-color approximation for terminals without 24-bit color.
|
||||
*/
|
||||
const SUNSET: ReadonlyArray<{ rgb: readonly [number, number, number]; xterm: number }> = [
|
||||
{ rgb: [247, 203, 45], xterm: 220 },
|
||||
{ rgb: [246, 182, 38], xterm: 220 },
|
||||
{ rgb: [245, 160, 32], xterm: 214 },
|
||||
{ rgb: [242, 141, 28], xterm: 214 },
|
||||
{ rgb: [238, 121, 24], xterm: 208 },
|
||||
{ rgb: [231, 100, 21], xterm: 208 },
|
||||
{ rgb: [222, 82, 19], xterm: 202 },
|
||||
];
|
||||
|
||||
export function displaySplash(version?: string): void {
|
||||
const color = supportsColor();
|
||||
const truecolor = color && /truecolor|24bit/i.test(process.env.COLORTERM ?? '');
|
||||
const RESET = color ? '\x1b[0m' : '';
|
||||
const WHITE = color ? '\x1b[1;97m' : '';
|
||||
const GRAY = color ? '\x1b[0;37m' : '';
|
||||
const DIM = color ? '\x1b[90m' : '';
|
||||
|
||||
const ramp = SUNSET.map(({ rgb: [r, g, b], xterm }) => {
|
||||
if (!color) return '';
|
||||
return truecolor ? `\x1b[38;2;${r};${g};${b}m` : `\x1b[38;5;${xterm}m`;
|
||||
});
|
||||
|
||||
/** Color one wordmark row, emitting an escape only where the run changes. Spaces stay unpainted. */
|
||||
const paint = (row: string, fill: string, edge: string): string => {
|
||||
if (!color) return row;
|
||||
let out = '';
|
||||
let open = '';
|
||||
for (const ch of row) {
|
||||
const want = ch === ' ' ? '' : ch === '█' ? fill : edge;
|
||||
if (want !== open) {
|
||||
if (open) out += RESET;
|
||||
out += want;
|
||||
open = want;
|
||||
}
|
||||
out += ch;
|
||||
}
|
||||
return open ? out + RESET : out;
|
||||
};
|
||||
|
||||
const lines = [
|
||||
'',
|
||||
` ${CYAN}\u2554${HR}\u2557${RESET}`,
|
||||
` ${B}${S67}${B}`,
|
||||
` ${B} ${GOLD}\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2557 \u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2557 \u2588\u2588\u2557\u2588\u2588\u2588\u2557 \u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2557 \u2588\u2588\u2557${RESET} ${B}`,
|
||||
` ${B} ${GOLD}\u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255D\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2551\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2550\u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2551${RESET} ${B}`,
|
||||
` ${B} ${GOLD}\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2551\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2551\u2588\u2588\u2554\u2588\u2588\u2557 \u2588\u2588\u2551\u2588\u2588\u2554\u2588\u2588\u2557 \u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2554\u2588\u2588\u2557 \u2588\u2588\u2551${RESET} ${B}`,
|
||||
` ${B} ${GOLD}\u255A\u2550\u2550\u2550\u2550\u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2551\u2588\u2588\u2551\u255A\u2588\u2588\u2557\u2588\u2588\u2551\u2588\u2588\u2551\u255A\u2588\u2588\u2557\u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2551\u255A\u2588\u2588\u2557\u2588\u2588\u2551${RESET} ${B}`,
|
||||
` ${B} ${GOLD}\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2551 \u255A\u2588\u2588\u2588\u2588\u2551\u2588\u2588\u2551 \u255A\u2588\u2588\u2588\u2588\u2551\u255A\u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255D\u2588\u2588\u2551 \u255A\u2588\u2588\u2588\u2588\u2551${RESET} ${B}`,
|
||||
` ${B} ${GOLD}\u255A\u2550\u2550\u2550\u2550\u2550\u2550\u255D\u255A\u2550\u255D \u255A\u2550\u255D\u255A\u2550\u255D \u255A\u2550\u255D\u255A\u2550\u255D \u255A\u2550\u2550\u2550\u255D\u255A\u2550\u255D \u255A\u2550\u2550\u2550\u255D \u255A\u2550\u2550\u2550\u2550\u2550\u255D \u255A\u2550\u255D \u255A\u2550\u2550\u2550\u255D${RESET} ${B}`,
|
||||
` ${B}${S67}${B}`,
|
||||
` ${B} ${CYAN}\u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557${RESET} ${B}`,
|
||||
` ${B} ${CYAN}\u2551${RESET} ${WHITE}AI Penetration Testing Framework${RESET} ${CYAN}\u2551${RESET} ${B}`,
|
||||
` ${B} ${CYAN}\u255A\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255D${RESET} ${B}`,
|
||||
` ${B}${S67}${B}`,
|
||||
];
|
||||
|
||||
if (version) {
|
||||
const verStr = `v${version}`;
|
||||
const verPadLeft = Math.floor((67 - verStr.length) / 2);
|
||||
const verPadRight = 67 - verStr.length - verPadLeft;
|
||||
lines.push(` ${B}${' '.repeat(verPadLeft)}${GRAY}${verStr}${RESET}${' '.repeat(verPadRight)}${B}`);
|
||||
}
|
||||
|
||||
lines.push(
|
||||
` ${B}${S67}${B}`,
|
||||
` ${B} ${YELLOW}\uD83D\uDD10 DEFENSIVE SECURITY ONLY \uD83D\uDD10${RESET} ${B}`,
|
||||
` ${B}${S67}${B}`,
|
||||
` ${CYAN}\u255A${HR}\u255D${RESET}`,
|
||||
` ${WHITE}Keygraph${RESET}${version ? ` ${DIM}v${version}${RESET}` : ''}`,
|
||||
'',
|
||||
);
|
||||
...SHANNON.map((row, i) => ` ${paint(row, ramp[i] ?? '', ramp[i + 1] ?? '')}`),
|
||||
'',
|
||||
` ${WHITE}AI Pentester for Web Apps and APIs${RESET}`,
|
||||
'',
|
||||
` ${GRAY}-Authorized Security Testing Only-${RESET}`,
|
||||
'',
|
||||
];
|
||||
|
||||
console.log(lines.join('\n'));
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* "Did you mean?" suggestions for mistyped commands and flags.
|
||||
*
|
||||
* A single Levenshtein-based matcher powers both the unknown-command path in the
|
||||
* dispatcher and the unknown-option path in `parseArgs`, so a typo like `statsu`
|
||||
* or `--workspce` points the user at the closest real name instead of just failing.
|
||||
*/
|
||||
|
||||
/** Levenshtein edit distance between two strings (insertions, deletions, substitutions). */
|
||||
export function editDistance(a: string, b: string): number {
|
||||
if (a.length === 0) return b.length;
|
||||
if (b.length === 0) return a.length;
|
||||
|
||||
// Rolling single row; `diagonal` and `above` carry the two neighbours a full grid would.
|
||||
const row = Array.from({ length: b.length + 1 }, (_, j) => j);
|
||||
|
||||
for (let i = 1; i <= a.length; i++) {
|
||||
let diagonal = row[0] as number;
|
||||
row[0] = i;
|
||||
for (let j = 1; j <= b.length; j++) {
|
||||
const above = row[j] as number;
|
||||
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
||||
row[j] = Math.min(above + 1, (row[j - 1] as number) + 1, diagonal + cost);
|
||||
diagonal = above;
|
||||
}
|
||||
}
|
||||
return row[b.length] as number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The candidate closest to `input`, or undefined if none is near enough.
|
||||
*
|
||||
* A prefix match ("stat" -> "status") wins first; otherwise the lowest edit
|
||||
* distance within a length-scaled threshold, so unrelated words don't match.
|
||||
*/
|
||||
export function closestMatch(input: string, candidates: readonly string[]): string | undefined {
|
||||
if (input.length >= 2) {
|
||||
const prefix = candidates.find((candidate) => candidate.startsWith(input));
|
||||
if (prefix) return prefix;
|
||||
}
|
||||
|
||||
let best: string | undefined;
|
||||
let bestDistance = Number.POSITIVE_INFINITY;
|
||||
for (const candidate of candidates) {
|
||||
if (candidate.length <= 3) continue;
|
||||
|
||||
const distance = editDistance(input, candidate);
|
||||
if (distance < bestDistance) {
|
||||
bestDistance = distance;
|
||||
best = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
if (best === undefined) return undefined;
|
||||
|
||||
const threshold = Math.max(2, Math.floor(best.length / 3));
|
||||
return bestDistance <= threshold ? best : undefined;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Thin Temporal client for reading one scan's state.
|
||||
*
|
||||
* A running scan is queried live (getProgress) and read via pendingActivities for
|
||||
* the in-flight agents; a closed scan is read once from its result. Everything goes
|
||||
* straight to the frontend on 127.0.0.1:7233 — the gRPC port the compose file
|
||||
* publishes — so this needs Temporal up, but no worker of its own.
|
||||
*/
|
||||
|
||||
import { Client, Connection, WorkflowFailedError, WorkflowNotFoundError } from '@temporalio/client';
|
||||
import { ACTIVITY_TO_AGENT, type PipelineState } from './scan/pipeline.js';
|
||||
|
||||
const ADDRESS = '127.0.0.1:7233';
|
||||
const NAMESPACE = 'default';
|
||||
|
||||
export interface RunningAgent {
|
||||
readonly agent: string;
|
||||
readonly attempt: number;
|
||||
readonly startedAt?: number;
|
||||
readonly lastFailure?: string;
|
||||
}
|
||||
|
||||
/** Convert a proto ITimestamp (seconds is a Long) to epoch millis. */
|
||||
function timestampMs(
|
||||
ts: { seconds?: { toString(): string } | number | null; nanos?: number | null } | null,
|
||||
): number | undefined {
|
||||
const seconds = ts?.seconds;
|
||||
if (seconds == null) return undefined;
|
||||
const secNum = typeof seconds === 'number' ? seconds : Number(seconds.toString());
|
||||
return secNum * 1000 + (ts?.nanos ?? 0) / 1e6;
|
||||
}
|
||||
|
||||
export interface ScanDescription {
|
||||
/** WorkflowExecutionStatusName: RUNNING | COMPLETED | FAILED | CANCELLED | TERMINATED | TIMED_OUT | … */
|
||||
readonly status: string;
|
||||
readonly startedAt?: number;
|
||||
readonly closedAt?: number;
|
||||
readonly runningAgents: readonly RunningAgent[];
|
||||
}
|
||||
|
||||
export type TerminalOutcome =
|
||||
| { readonly kind: 'success'; readonly state: PipelineState }
|
||||
| { readonly kind: 'failed'; readonly message: string };
|
||||
|
||||
let clientPromise: Promise<Client> | null = null;
|
||||
|
||||
function getClient(): Promise<Client> {
|
||||
if (!clientPromise) {
|
||||
clientPromise = Connection.connect({ address: ADDRESS }).then(
|
||||
(connection) => new Client({ connection, namespace: NAMESPACE }),
|
||||
);
|
||||
}
|
||||
return clientPromise;
|
||||
}
|
||||
|
||||
/** Describe a scan: status, timing, and the agents currently running (from pendingActivities). Null if not found. */
|
||||
export async function describeScan(workflowId: string): Promise<ScanDescription | null> {
|
||||
const client = await getClient();
|
||||
try {
|
||||
const desc = await client.workflow.getHandle(workflowId).describe();
|
||||
|
||||
const runningAgents: RunningAgent[] = [];
|
||||
for (const pending of desc.raw.pendingActivities ?? []) {
|
||||
const agent = ACTIVITY_TO_AGENT[pending.activityType?.name ?? ''];
|
||||
if (!agent) continue;
|
||||
const lastFailure = pending.lastFailure?.message;
|
||||
const startedAt = timestampMs(pending.scheduledTime ?? pending.lastStartedTime ?? null);
|
||||
runningAgents.push({
|
||||
agent,
|
||||
attempt: pending.attempt ?? 1,
|
||||
...(startedAt !== undefined ? { startedAt } : {}),
|
||||
...(lastFailure ? { lastFailure } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
status: desc.status.name,
|
||||
runningAgents,
|
||||
...(desc.startTime ? { startedAt: desc.startTime.getTime() } : {}),
|
||||
...(desc.closeTime ? { closedAt: desc.closeTime.getTime() } : {}),
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof WorkflowNotFoundError) return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Live progress of a running scan via the getProgress query. Null if the query can't be served (no worker). */
|
||||
export async function queryProgress(workflowId: string): Promise<PipelineState | null> {
|
||||
const client = await getClient();
|
||||
try {
|
||||
return await client.workflow.getHandle(workflowId).query<PipelineState>('getProgress');
|
||||
} catch {
|
||||
// The query needs a live worker; a just-closed scan may have none. Caller falls back to the result.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deepest message in a Temporal failure's cause chain — the real reason nested under generic
|
||||
* wrappers (WorkflowFailedError → ActivityFailure → ApplicationFailure). Covers failed, cancelled,
|
||||
* and terminated alike. Mirrors the SDK's `rootCause` (only exported from @temporalio/common).
|
||||
*/
|
||||
function rootFailureMessage(err: WorkflowFailedError): string {
|
||||
let message = err.message;
|
||||
let cause: unknown = err.cause;
|
||||
while (cause instanceof Error && cause.message) {
|
||||
message = cause.message;
|
||||
cause = cause.cause;
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
/** Final state of a closed scan: success carries the full PipelineState, failure carries the message. */
|
||||
export async function getTerminalOutcome(workflowId: string): Promise<TerminalOutcome> {
|
||||
const client = await getClient();
|
||||
try {
|
||||
const state = (await client.workflow.getHandle(workflowId).result()) as PipelineState;
|
||||
return { kind: 'success', state };
|
||||
} catch (err) {
|
||||
if (err instanceof WorkflowFailedError) {
|
||||
return { kind: 'failed', message: rootFailureMessage(err) };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Terminal capability detection — output coloring, cursor animation, and
|
||||
* whether the user can be prompted interactively.
|
||||
*/
|
||||
|
||||
import { fail } from './errors.js';
|
||||
|
||||
/** True when stdout is a real terminal — safe for color, cursor moves, and spinners. */
|
||||
export function stdoutIsTerminal(): boolean {
|
||||
return !!process.stdout.isTTY;
|
||||
}
|
||||
|
||||
/** True when both stdin and stdout are terminals, so interactive prompts can run. */
|
||||
function isInteractive(): boolean {
|
||||
return !!process.stdin.isTTY && !!process.stdout.isTTY;
|
||||
}
|
||||
|
||||
/** True when color escapes should be emitted. NO_COLOR disables; FORCE_COLOR overrides (0/false/empty = off). */
|
||||
export function supportsColor(): boolean {
|
||||
if (process.env.NO_COLOR !== undefined) return false;
|
||||
|
||||
const force = process.env.FORCE_COLOR;
|
||||
if (force !== undefined) {
|
||||
return force !== '0' && force !== 'false' && force !== '';
|
||||
}
|
||||
|
||||
return stdoutIsTerminal();
|
||||
}
|
||||
|
||||
/** Exit with a clear error when an interactive-only command has no terminal, instead of hanging on a prompt. */
|
||||
export function requireInteractive(command: string, alternative: string): void {
|
||||
if (isInteractive()) return;
|
||||
fail(`'${command}' needs an interactive terminal.`, alternative);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Terminal status output for long-running steps.
|
||||
*
|
||||
* Commands are run with their output captured rather than inherited, so raw docker
|
||||
* plumbing never floods the terminal. Progress is shown with a `@clack/prompts`
|
||||
* spinner. On failure the captured output is printed so the error stays visible
|
||||
* instead of being swallowed.
|
||||
*/
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import * as p from '@clack/prompts';
|
||||
|
||||
export interface StepResult {
|
||||
ok: boolean;
|
||||
output: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a command capturing stdout and stderr. Resolves the exit result and combined
|
||||
* output; never rejects. Callers that want a spinner wrap this in one themselves.
|
||||
*/
|
||||
export function spawnCaptured(cmd: string, args: string[]): Promise<StepResult> {
|
||||
return new Promise((resolve) => {
|
||||
let output = '';
|
||||
const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
child.stdout?.on('data', (chunk) => {
|
||||
output += chunk.toString();
|
||||
});
|
||||
child.stderr?.on('data', (chunk) => {
|
||||
output += chunk.toString();
|
||||
});
|
||||
child.on('close', (code) => resolve({ ok: code === 0, output }));
|
||||
child.on('error', () => resolve({ ok: false, output }));
|
||||
});
|
||||
}
|
||||
|
||||
/** Print captured command output to stderr, so a failure is never swallowed. */
|
||||
export function surfaceOutput(output: string): void {
|
||||
const trimmed = output.trim();
|
||||
if (trimmed) process.stderr.write(`${trimmed}\n`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a command as a labeled step, with a spinner over it. On failure the captured
|
||||
* output is surfaced. Returns the exit result and captured output.
|
||||
*/
|
||||
export async function runStep(label: string, cmd: string, args: string[]): Promise<StepResult> {
|
||||
const spinner = p.spinner();
|
||||
spinner.start(label);
|
||||
|
||||
const result = await spawnCaptured(cmd, args);
|
||||
if (result.ok) {
|
||||
spinner.stop(label);
|
||||
} else {
|
||||
spinner.error(label);
|
||||
surfaceOutput(result.output);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Version reporting — mode-aware.
|
||||
*
|
||||
* NPX mode: the published package.json version (stamped by CI at release).
|
||||
* Local mode: the git commit SHA of the checked-out clone (`git-<full-sha>`).
|
||||
* A clone has no meaningful semver, so the commit is the honest identifier.
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { getMode } from './mode.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function readPackageVersion(): string {
|
||||
try {
|
||||
const pkgPath = path.join(__dirname, '..', 'package.json');
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')) as { version?: string };
|
||||
return pkg.version || '1.0.0';
|
||||
} catch {
|
||||
return '1.0.0';
|
||||
}
|
||||
}
|
||||
|
||||
/** Run a git command in the CLI's own repo; returns trimmed stdout or null on any failure. */
|
||||
function git(...args: string[]): string | null {
|
||||
try {
|
||||
return execFileSync('git', args, { cwd: __dirname, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readGitSha(): string | null {
|
||||
return git('rev-parse', 'HEAD');
|
||||
}
|
||||
|
||||
/**
|
||||
* Version identifier. NPX: package.json version. Local: `git-<full-sha>`,
|
||||
* falling back to the package version if git is unavailable.
|
||||
*/
|
||||
export function getVersion(): string {
|
||||
if (getMode() !== 'local') return readPackageVersion();
|
||||
|
||||
const sha = readGitSha();
|
||||
if (!sha) return readPackageVersion();
|
||||
|
||||
return `git-${sha}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-facing version line printed by `--version`.
|
||||
* NPX: `shannon <version>`. Local: `shannon git-<full-sha>`.
|
||||
*/
|
||||
export function getVersionLine(): string {
|
||||
return `shannon ${getVersion()}`;
|
||||
}
|
||||
@@ -39,9 +39,33 @@
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z2-7]+=*$",
|
||||
"description": "TOTP secret for two-factor authentication (Base32 encoded, case insensitive)"
|
||||
},
|
||||
"email_login": {
|
||||
"type": "object",
|
||||
"description": "Email account credentials for magic-link or OTP follow-through flows",
|
||||
"properties": {
|
||||
"address": {
|
||||
"type": "string",
|
||||
"format": "email",
|
||||
"description": "Email address used to receive magic links or OTPs"
|
||||
},
|
||||
"password": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 255,
|
||||
"description": "Password for the email account"
|
||||
},
|
||||
"totp_secret": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z2-7]+=*$",
|
||||
"description": "TOTP secret for the email account's two-factor authentication (Base32 encoded)"
|
||||
}
|
||||
},
|
||||
"required": ["address", "password"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["username", "password"],
|
||||
"required": ["username"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"login_flow": {
|
||||
@@ -78,23 +102,6 @@
|
||||
"required": ["login_type", "login_url", "credentials", "success_condition"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"pipeline": {
|
||||
"type": "object",
|
||||
"description": "Pipeline execution settings for retry behavior and concurrency",
|
||||
"properties": {
|
||||
"retry_preset": {
|
||||
"type": "string",
|
||||
"enum": ["default", "subscription"],
|
||||
"description": "Retry preset. 'subscription' extends timeouts for Anthropic subscription rate limit windows (5h+)."
|
||||
},
|
||||
"max_concurrent_pipelines": {
|
||||
"type": "string",
|
||||
"pattern": "^[1-5]$",
|
||||
"description": "Max concurrent vulnerability pipelines (1-5, default: 5)"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"rules": {
|
||||
"type": "object",
|
||||
"description": "Testing rules that define what to focus on or avoid during penetration testing",
|
||||
@@ -118,6 +125,56 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"vuln_classes": {
|
||||
"type": "array",
|
||||
"description": "Vulnerability classes to test. When omitted, all five classes run. When set, only listed classes run; their vuln+exploit agents and report sections are included.",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": ["injection", "xss", "auth", "authz", "ssrf"]
|
||||
},
|
||||
"minItems": 1,
|
||||
"maxItems": 5,
|
||||
"uniqueItems": true
|
||||
},
|
||||
"exploit": {
|
||||
"type": "string",
|
||||
"enum": ["true", "false"],
|
||||
"description": "Whether to run the exploitation phase (default true). Set false to run only analysis."
|
||||
},
|
||||
"report": {
|
||||
"type": "object",
|
||||
"description": "Report filtering and guidance applied by the report agent.",
|
||||
"properties": {
|
||||
"min_severity": {
|
||||
"type": "string",
|
||||
"enum": ["low", "medium", "high", "critical"],
|
||||
"description": "Minimum severity threshold; findings below are dropped by the report agent."
|
||||
},
|
||||
"min_confidence": {
|
||||
"type": "string",
|
||||
"enum": ["low", "medium", "high"],
|
||||
"description": "Minimum confidence threshold; findings below are dropped by the report agent."
|
||||
},
|
||||
"guidance": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 500,
|
||||
"description": "Free-text guidance to the report agent (e.g., 'Drop findings about missing security headers')."
|
||||
},
|
||||
"sarif": {
|
||||
"type": "string",
|
||||
"enum": ["true", "false"],
|
||||
"description": "Emit a SARIF 2.1.0 log (report.sarif) beside the report. Requires exploit=true; ignored otherwise."
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"rules_of_engagement": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 1000,
|
||||
"description": "Free-text instructions to the agent that render into every prompt."
|
||||
},
|
||||
"login": {
|
||||
"type": "object",
|
||||
"description": "Deprecated: Use 'authentication' section instead",
|
||||
@@ -135,7 +192,11 @@
|
||||
{ "required": ["authentication"] },
|
||||
{ "required": ["rules"] },
|
||||
{ "required": ["authentication", "rules"] },
|
||||
{ "required": ["description"] }
|
||||
{ "required": ["description"] },
|
||||
{ "required": ["vuln_classes"] },
|
||||
{ "required": ["exploit"] },
|
||||
{ "required": ["report"] },
|
||||
{ "required": ["rules_of_engagement"] }
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"$defs": {
|
||||
@@ -145,23 +206,22 @@
|
||||
"properties": {
|
||||
"description": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 200,
|
||||
"description": "Human-readable description of the rule"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["path", "subdomain", "domain", "method", "header", "parameter"],
|
||||
"description": "Type of rule (what aspect of requests to match against)"
|
||||
"enum": ["url_path", "subdomain", "domain", "method", "header", "parameter", "code_path"],
|
||||
"description": "Type of rule (what aspect of requests or source code to match against)"
|
||||
},
|
||||
"url_path": {
|
||||
"value": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 1000,
|
||||
"description": "URL path pattern or value to match"
|
||||
"description": "Value to match"
|
||||
}
|
||||
},
|
||||
"required": ["description", "type", "url_path"],
|
||||
"required": ["type", "value"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,27 @@
|
||||
# Description of the target environment (optional, max 500 chars)
|
||||
description: "Next.js e-commerce app on PostgreSQL. Local dev environment — .env files contain local-only credentials, not deployed to production."
|
||||
|
||||
# Limit which vulnerability classes run end-to-end (optional, default: all five)
|
||||
# vuln_classes: [injection, xss, auth, authz, ssrf]
|
||||
|
||||
# Skip the exploitation phase (optional, default: "true")
|
||||
# exploit: "false"
|
||||
|
||||
# Free-form engagement rules applied to analysis and exploitation agents (optional).
|
||||
# Example below is illustrative; edit, remove, or add sections as needed.
|
||||
# rules_of_engagement: |
|
||||
# Forbidden techniques:
|
||||
# - No password brute-force or credential stuffing. Cap login attempts at 5 per account.
|
||||
# - ...
|
||||
#
|
||||
# Operational:
|
||||
# - Throttle to under 5 requests per second per endpoint. Back off 60 seconds on any 429 response.
|
||||
# - ...
|
||||
#
|
||||
# Data handling:
|
||||
# - Do not include actual values in deliverables — use placeholders like [order_id] or [user_email].
|
||||
# - ...
|
||||
|
||||
authentication:
|
||||
login_type: form # Options: 'form' or 'sso'
|
||||
login_url: "https://example.com/login"
|
||||
@@ -12,6 +33,12 @@ authentication:
|
||||
password: "testpassword"
|
||||
totp_secret: "JBSWY3DPEHPK3PXP" # Optional TOTP secret for 2FA
|
||||
|
||||
# Optional mailbox credentials for magic-link / email-OTP flows.
|
||||
# email_login:
|
||||
# address: "inbox@example.com"
|
||||
# password: "mailbox-password"
|
||||
# totp_secret: "JBSWY3DPEHPK3PXP"
|
||||
|
||||
# Natural language instructions for login flow
|
||||
login_flow:
|
||||
- "Type $username into the email field"
|
||||
@@ -25,29 +52,54 @@ authentication:
|
||||
value: "/dashboard"
|
||||
|
||||
rules:
|
||||
# Supported types: url_path, subdomain, domain, method, header, parameter, code_path
|
||||
avoid:
|
||||
- description: "Do not test the marketing site subdomain"
|
||||
type: subdomain
|
||||
url_path: "www"
|
||||
value: "www"
|
||||
|
||||
- description: "Skip logout functionality"
|
||||
type: path
|
||||
url_path: "/logout"
|
||||
type: url_path
|
||||
value: "/logout"
|
||||
|
||||
- description: "No DELETE operations on user API"
|
||||
type: path
|
||||
url_path: "/api/v1/users/*"
|
||||
type: url_path
|
||||
value: "/api/v1/users/*"
|
||||
|
||||
# code_path values are repo-relative file paths or globs (e.g. "src/auth.ts", "test/**").
|
||||
# - description: "Test fixtures and specs (not production code)"
|
||||
# type: code_path
|
||||
# value: "test/**"
|
||||
#
|
||||
# - description: "Generated migrations"
|
||||
# type: code_path
|
||||
# value: "db/migrations/**"
|
||||
|
||||
focus:
|
||||
- description: "Prioritize beta admin panel subdomain"
|
||||
type: subdomain
|
||||
url_path: "beta-admin"
|
||||
value: "beta-admin"
|
||||
|
||||
- description: "Focus on user profile updates"
|
||||
type: path
|
||||
url_path: "/api/v2/user-profile"
|
||||
type: url_path
|
||||
value: "/api/v2/user-profile"
|
||||
|
||||
# Pipeline execution settings (optional)
|
||||
# pipeline:
|
||||
# retry_preset: subscription # 'default' or 'subscription' (6h max retry for rate limit recovery)
|
||||
# max_concurrent_pipelines: 2 # 1-5, default: 5 (reduce to lower API usage spikes)
|
||||
# code_path values are repo-relative file paths or globs (e.g. "src/auth.ts", "routes/*.ts").
|
||||
# - description: "Express route handlers"
|
||||
# type: code_path
|
||||
# value: "routes/*.ts"
|
||||
#
|
||||
# - description: "Sequelize ORM model definitions"
|
||||
# type: code_path
|
||||
# value: "models/*.ts"
|
||||
|
||||
# Report filters applied by the report agent when assembling the final report (optional).
|
||||
# Example below is illustrative; edit, remove, or add sections as needed.
|
||||
# report:
|
||||
# # Emit a SARIF 2.1.0 log (report.sarif) beside the report. Requires exploit: "true".
|
||||
# sarif: "true"
|
||||
# min_severity: low
|
||||
# min_confidence: low
|
||||
# guidance: |
|
||||
# Drop findings about missing security headers and rate-limit gaps.
|
||||
# ...
|
||||
@@ -19,7 +19,10 @@
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "catalog:",
|
||||
"@earendil-works/pi-agent-core": "^0.82.1",
|
||||
"@earendil-works/pi-ai": "^0.82.1",
|
||||
"@earendil-works/pi-coding-agent": "^0.82.1",
|
||||
"@gotgenes/pi-permission-system": "^10.9.0",
|
||||
"@temporalio/activity": "^1.11.0",
|
||||
"@temporalio/client": "^1.11.0",
|
||||
"@temporalio/worker": "^1.11.0",
|
||||
@@ -28,7 +31,7 @@
|
||||
"ajv-formats": "^2.1.1",
|
||||
"dotenv": "^16.4.5",
|
||||
"js-yaml": "^4.1.0",
|
||||
"zod": "^4.3.6",
|
||||
"typebox": "1.1.38",
|
||||
"zx": "^8.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -105,6 +105,8 @@ If you only confirmed a vulnerability without attempting to weaponize it, you ha
|
||||
{{LOGIN_INSTRUCTIONS}}
|
||||
</login_instructions>
|
||||
|
||||
@include(shared/_rules-of-engagement.txt)
|
||||
|
||||
<starting_context>
|
||||
- Your **actionable exploitation queue** is located at `.shannon/deliverables/auth_exploitation_queue.json`. This is your to-do list, containing the precise targets identified by the analysis phase. You MUST process every vulnerability in this queue.
|
||||
|
||||
@@ -114,7 +116,7 @@ Before beginning exploitation, read these strategic intelligence files in order:
|
||||
2. `.shannon/deliverables/recon_deliverable.md` - Complete API inventory, user roles, and data flow maps.
|
||||
3. `.shannon/deliverables/auth_analysis_deliverable.md` - Strategic context from the Auth analysis specialist, including notes on session mechanisms, password policies, and flawed logic paths.
|
||||
|
||||
- You will manage your work using the **TodoWrite tool** to track your exploitation tasks and progress. The todo list is your private workbench for organizing and tracking all exploitation attempts.
|
||||
- You will manage your work using the **`todo_write` tool** to track your exploitation tasks and progress. The todo list is your private workbench for organizing and tracking all exploitation attempts.
|
||||
</starting_context>
|
||||
|
||||
<system_architecture>
|
||||
@@ -143,23 +145,18 @@ You are the **Identity Compromise Specialist** - proving tangible impact of brok
|
||||
|
||||
<cli_tools>
|
||||
- **Browser Automation (playwright-cli skill):** Essential for interacting with multi-step authentication flows, injecting stolen session cookies, and verifying account takeover in a real browser context. Invoke the `playwright-cli` skill to learn available commands. Always pass `-s={{PLAYWRIGHT_SESSION}}` to every command for session isolation.
|
||||
- **save-deliverable (CLI Tool):** Saves your deliverable files with automatic validation.
|
||||
- **Usage:** `save-deliverable --type <TYPE> --file-path <path>` or `--content '<text>'`
|
||||
- **Returns:** JSON to stdout: `{"status":"success","filepath":"..."}` or `{"status":"error","message":"...","retryable":true}`
|
||||
- **For large reports:** Write to disk first, then use `--file-path`. Do NOT pass large reports via `--content`.
|
||||
|
||||
- **Bash tool:** Use for creating directories, copying files, and other shell commands as needed.
|
||||
- **Task Agent:** Mandatory coder-executor for brute force batches, credential stuffing, token replay automation, and any scripted workflow.
|
||||
- **TodoWrite tool:** Essential for managing your exploitation task list. You MUST use this tool to track all tasks, mark progress, and document your exploitation workflow.
|
||||
- **Read tool:** To read false positives from your tracking file at `workspace/auth_false_positives.md`.
|
||||
- **`bash` tool:** Use for creating directories, copying files, and other shell commands as needed.
|
||||
- **`task` agent:** Mandatory coder-executor for brute force batches, credential stuffing, token replay automation, and any scripted workflow.
|
||||
- **`todo_write` tool:** Essential for managing your exploitation task list. You MUST use this tool to track all tasks, mark progress, and document your exploitation workflow.
|
||||
- **`read` tool:** To read false positives from your tracking file at `workspace/auth_false_positives.md`.
|
||||
|
||||
**CRITICAL TASK AGENT WORKFLOW:**
|
||||
- Delegate every custom script or multi-step automation to the Task Agent; do not hand-write scripts in Bash.
|
||||
- Keep requests ≤15 lines and use the template below so the Task Agent has clear inputs and success criteria.
|
||||
- Delegate every custom script or multi-step automation to the `task` agent; do not hand-write scripts in Bash.
|
||||
- Keep requests ≤15 lines and use the template below so the `task` agent has clear inputs and success criteria.
|
||||
</cli_tools>
|
||||
|
||||
## Task Agent Scripting Requirements
|
||||
Use the Task Agent to execute all bulk or repetitive auth attacks. Manual commands are for quick verification only.
|
||||
## `task` agent Scripting Requirements
|
||||
Use the `task` agent to execute all bulk or repetitive auth attacks. Manual commands are for quick verification only.
|
||||
|
||||
**MANDATORY TEMPLATE:**
|
||||
"
|
||||
@@ -175,7 +172,7 @@ Inputs:
|
||||
Success Criteria: Print each attempt with status code and key response text so I can evaluate impact.
|
||||
"
|
||||
|
||||
**DO NOT:** exceed 5–6 inputs per run, ask for advanced parsing, or bypass the Task Agent for scripting.
|
||||
**DO NOT:** exceed 5–6 inputs per run, ask for advanced parsing, or bypass the `task` agent for scripting.
|
||||
|
||||
<methodology_and_domain_expertise>
|
||||
|
||||
@@ -186,11 +183,11 @@ Your primary goal is to achieve a definitive, evidence-backed conclusion for **e
|
||||
- **Complete the Workflow:** For each vulnerability, you must follow the full Exploitation Workflow from Confirmation to Impact Demonstration.
|
||||
|
||||
## **The Task-Based Attack Plan**
|
||||
Your entire exploitation process will be managed through the TodoWrite tool. You MUST adhere to this process.
|
||||
Your entire exploitation process will be managed through the `todo_write` tool. You MUST adhere to this process.
|
||||
|
||||
**1. Initialization:**
|
||||
- Read `.shannon/deliverables/auth_exploitation_queue.json`.
|
||||
- Use the TodoWrite tool to create tasks for each vulnerability in the queue.
|
||||
- Use the `todo_write` tool to create tasks for each vulnerability in the queue.
|
||||
- Each task should include the vulnerability ID and the hypothesis to test.
|
||||
Example tasks:
|
||||
- "AUTH-VULN-01: Exploit Session Fixation on /login (Hypothesis: Session ID not rotated)"
|
||||
@@ -198,10 +195,10 @@ Your entire exploitation process will be managed through the TodoWrite tool. You
|
||||
|
||||
**2. The Execution Loop:**
|
||||
- **A. Get Next Task:** Review your todo list and identify the next pending task to work on.
|
||||
- **B. Plan & Decompose:** Mark the task as in_progress using TodoWrite. Read the `suggested_exploit_technique` from the vulnerability details. This is your primary directive. Plan the specific commands and tools needed to execute this attack pattern. If the attack is complex (e.g., a multi-step logic flaw), use TodoWrite to add smaller tasks.
|
||||
- **B. Plan & Decompose:** Mark the task as in_progress using `todo_write`. Read the `suggested_exploit_technique` from the vulnerability details. This is your primary directive. Plan the specific commands and tools needed to execute this attack pattern. If the attack is complex (e.g., a multi-step logic flaw), use `todo_write` to add smaller tasks.
|
||||
- **C. Execute:** Run the command for the current task.
|
||||
- **D. Update & Document:** Based on the result, update the TodoWrite status to completed if successful. Document the outcome in your memory for the final report.
|
||||
- **E. Expand the Plan (Branching):** If successful and more steps are needed, use TodoWrite to add the next logical tasks required by the OWASP workflow.
|
||||
- **D. Update & Document:** Based on the result, update the `todo_write` status to completed if successful. Document the outcome in your memory for the final report.
|
||||
- **E. Expand the Plan (Branching):** If successful and more steps are needed, use `todo_write` to add the next logical tasks required by the OWASP workflow.
|
||||
|
||||
## **OWASP Authentication Exploitation Workflow**
|
||||
For each root vulnerability, follow this systematic, two-stage attack flow.
|
||||
@@ -292,7 +289,7 @@ Prioritize these based on the intelligence from your queue and vulnerability ana
|
||||
**2. Credential-Based Attacks**
|
||||
- **Credential Stuffing with cURL:**
|
||||
`curl -X POST -H "Content-Type: application/json" -d '{"username":"admin", "password":"CrackedPassword123"}' https://target.com/api/login`
|
||||
- **Brute Force (Example with Task Agent):**
|
||||
- **Brute Force (Example with `task` agent):**
|
||||
"Task: Write a python script to POST to /api/login with username 'admin' and passwords from this list: ['admin', 'password', '123456']. Print the status code for each."
|
||||
|
||||
**3. Logic Flaws**
|
||||
@@ -307,92 +304,19 @@ Prioritize these based on the intelligence from your queue and vulnerability ana
|
||||
</attack_patterns>
|
||||
</methodology_and_domain_expertise>
|
||||
|
||||
<deliverable_instructions>
|
||||
When your attack plan is fully explored (all checkboxes are marked `[x]`), your mission is complete. You MUST generate two final files.
|
||||
<deliverable_tools>
|
||||
You emit your exploitation evidence through a single tool — `add_exploit`. The host renderer assembles `.shannon/deliverables/auth_exploitation_evidence.md` from your tool calls after the run. You do NOT write the Markdown file directly.
|
||||
|
||||
**1. Your Specialist Deliverable**
|
||||
**When to emit.** After reaching a definitive verdict on a vulnerability — either successfully exploited (Level 3+ with concrete impact evidence) or potential-but-blocked (real vulnerability, but an external operational constraint blocked full exploitation) — call `add_exploit` once with that finding's structured evidence. Call once per queue vulnerability; do not batch. Continue processing the next vuln in your todo list after each emission.
|
||||
|
||||
First, synthesize all evidence into your specialist section and save it using the save-deliverable CLI with --type AUTH_EVIDENCE. This section will be combined with other specialist reports to form the complete security assessment.
|
||||
**Status discriminator.** Set `status: "exploited"` only when you've reached Level 3+ with concrete impact evidence (account takeover demonstrated, session hijacked end-to-end, password reset abused, MFA bypassed). Set `status: "blocked"` only for findings that are real vulnerabilities but where external factors — NOT security defenses — prevented full exploitation. See the Classification Decision Framework in this prompt. Do NOT call `add_exploit` for findings classified FALSE POSITIVE; those go in your `workspace/auth_false_positives.md` tracking file, not the deliverable.
|
||||
|
||||
**Section Ordering & Format Requirements:**
|
||||
- Use the exact vulnerability IDs from `.shannon/deliverables/auth_exploitation_queue.json`
|
||||
- Within "Successfully Exploited" section: Order by severity of impact (most critical first)
|
||||
- Within "Confirmed Vulnerabilities (Attempted Exploitation)" section: Order by severity (most critical first)
|
||||
- Within "Potential" section: Order by confidence level (highest confidence first)
|
||||
**ID alignment.** `vulnerability_id` must match an ID from `.shannon/deliverables/auth_exploitation_queue.json` exactly (e.g. `AUTH-VULN-03`). The collector will reject IDs not in the queue with a list of valid IDs; if you get that error, you either typo'd an ID or imagined one — fix and retry.
|
||||
|
||||
**Critical Documentation Requirements:**
|
||||
**Idempotency.** Duplicate `vulnerability_id` calls are rejected with `DuplicateError`. Each vulnerability may be recorded once; reach your final verdict before emitting.
|
||||
|
||||
**REPRODUCIBILITY IS MANDATORY:**
|
||||
- Every exploitation step must be completely self-contained and reproducible
|
||||
- Include full URLs with protocol, domain, port, path, and all parameters
|
||||
- Provide complete payloads and commands exactly as they should be used
|
||||
- Use clear placeholders for variable values like [SESSION_TOKEN], [PASSWORD], [USERNAME]
|
||||
- Write as if the reader has never seen the application before
|
||||
- Steps must be detailed enough that someone unfamiliar with the application can follow without additional research
|
||||
|
||||
---
|
||||
# Authentication Exploitation Evidence
|
||||
|
||||
## Successfully Exploited Vulnerabilities
|
||||
|
||||
### AUTH-VULN-01: Session Hijacking via Non-HttpOnly Cookie
|
||||
|
||||
**Summary:**
|
||||
- **Vulnerable location:** [Authentication mechanism or endpoint affected]
|
||||
- **Overview:** [Breif Summary of the exploit itself]
|
||||
- **Impact:** [Business/security impact achieved]
|
||||
- **Severity:** [Critical/High/Medium/Low based on impact]
|
||||
|
||||
**Prerequisites:** (if any)
|
||||
[Required setup, tools, or conditions]
|
||||
|
||||
**Exploitation Steps:**
|
||||
[Each step must be completely reproducible with full details]
|
||||
|
||||
1. [Complete action with all necessary information]
|
||||
2. [Next action with full details]
|
||||
[Continue as needed...]
|
||||
|
||||
**Proof of Impact:**
|
||||
[Concrete evidence of successful exploitation]
|
||||
|
||||
**Notes:** (optional)
|
||||
[Any additional relevant information]
|
||||
|
||||
### AUTH-VULN-02: [Another Successfully Exploited Vulnerability]
|
||||
... (Repeat for each successfully exploited vulnerability) ...
|
||||
|
||||
## Potential Vulnerabilities (Validation Blocked)
|
||||
|
||||
### AUTH-VULN-03: Suspected Privilege Escalation via Role Manipulation
|
||||
|
||||
**Summary:**
|
||||
- **Vulnerable location:** [Endpoint or mechanism where vulnerability exists]
|
||||
- **Current Blocker:** [What prevents full exploitation]
|
||||
- **Potential Impact:** [What could be achieved if blocker removed]
|
||||
- **Confidence:** [HIGH/MEDIUM/LOW]
|
||||
|
||||
**Evidence of Vulnerability:**
|
||||
[Code snippets, responses, or behavior proving vulnerability exists]
|
||||
|
||||
**Attempted Exploitation:**
|
||||
[What techniques were tried and why they didn't succeed]
|
||||
|
||||
**How This Would Be Exploited:**
|
||||
[If the blocker were removed, these steps would achieve exploitation]
|
||||
|
||||
If [blocker] were bypassed/removed:
|
||||
|
||||
1. [Complete action with all necessary details]
|
||||
2. [Next action with full information]
|
||||
[Continue as needed...]
|
||||
|
||||
**Expected Impact:**
|
||||
[Specific data or access that would be compromised]
|
||||
|
||||
---
|
||||
|
||||
</deliverable_instructions>
|
||||
**Required-call intent.** Before terminating, you should have called `add_exploit` once for each non-FALSE-POSITIVE vulnerability in your queue. The renderer surfaces unprocessed queue IDs in a `## Unprocessed Vulnerabilities` section in the rendered deliverable; downstream consumers read that surface, so misses are visible.
|
||||
</deliverable_tools>
|
||||
|
||||
<conclusion_trigger>
|
||||
### Evidence Completeness Verification
|
||||
@@ -404,16 +328,10 @@ If [blocker] were bypassed/removed:
|
||||
- All technical components specified without ambiguity
|
||||
|
||||
COMPLETION REQUIREMENTS (ALL must be satisfied):
|
||||
1. Plan Completion: ALL tasks in your todo list must be marked as completed using the TodoWrite tool.
|
||||
2. Deliverable Generation: The required deliverable must be successfully saved using the save-deliverable CLI:
|
||||
- **CHUNKED WRITING (MANDATORY):**
|
||||
1. Use the **Write** tool to create `.shannon/deliverables/auth_exploitation_evidence.md` with the title and first major section
|
||||
2. Use the **Edit** tool to append each remaining section — match the last few lines of the file, then replace with those lines plus the new section content
|
||||
3. Repeat step 2 for all remaining sections
|
||||
4. Run `save-deliverable` with `--type AUTH_EVIDENCE --file-path ".shannon/deliverables/auth_exploitation_evidence.md"`
|
||||
**WARNING:** Do NOT write the entire report in a single tool call — exceeds 32K output token limit. Split into multiple Write/Edit operations.
|
||||
1. Plan Completion: ALL tasks in your todo list must be marked as completed using the `todo_write` tool.
|
||||
2. Evidence Emission: Every non-FALSE-POSITIVE vulnerability in `.shannon/deliverables/auth_exploitation_queue.json` has been recorded with one `add_exploit` call carrying the appropriate `status` ("exploited" or "blocked") and required fields. FALSE POSITIVE findings are recorded only in `workspace/auth_false_positives.md`, not via `add_exploit`.
|
||||
|
||||
CRITICAL WARNING: Announcing completion before every item in .shannon/deliverables/auth_exploitation_queue.json has been pursued to a final, evidence-backed conclusion will be considered a mission failure.
|
||||
CRITICAL WARNING: Announcing completion before every item in .shannon/deliverables/auth_exploitation_queue.json has been pursued to a final, evidence-backed conclusion (either successfully exploited, recorded as potential-but-blocked, or verified false positive) will be considered a mission failure.
|
||||
|
||||
ONLY AFTER fulfilling these exhaustive requirements, announce "AUTH EXPLOITATION COMPLETE" and stop.
|
||||
|
||||
|
||||
@@ -88,10 +88,14 @@ After exhaustive bypass attempts, determine:
|
||||
@include(shared/_rules.txt)
|
||||
</rules>
|
||||
|
||||
@include(shared/_shared-session.txt)
|
||||
|
||||
<login_instructions>
|
||||
{{LOGIN_INSTRUCTIONS}}
|
||||
</login_instructions>
|
||||
|
||||
@include(shared/_rules-of-engagement.txt)
|
||||
|
||||
<starting_context>
|
||||
- Your **actionable exploitation queue** is located at `.shannon/deliverables/authz_exploitation_queue.json`. This is your to-do list, containing the precise targets identified by the analysis phase. You MUST process every vulnerability in this queue.
|
||||
|
||||
@@ -101,7 +105,7 @@ Before beginning exploitation, read these strategic intelligence files in order:
|
||||
2. `.shannon/deliverables/recon_deliverable.md` - Complete API inventory, user roles, and permission models.
|
||||
3. `.shannon/deliverables/authz_analysis_deliverable.md` - Strategic context from the Authz analysis specialist, including notes on access control patterns, role hierarchies, and flawed logic paths.
|
||||
|
||||
- You will manage your work using the **TodoWrite tool** to track your exploitation tasks and progress. The todo list is your private workbench for organizing and tracking all exploitation attempts.
|
||||
- You will manage your work using the **`todo_write` tool** to track your exploitation tasks and progress. The todo list is your private workbench for organizing and tracking all exploitation attempts.
|
||||
</starting_context>
|
||||
|
||||
<system_architecture>
|
||||
@@ -130,23 +134,18 @@ You are the **Privilege Escalation Specialist** - proving tangible impact of bro
|
||||
|
||||
<cli_tools>
|
||||
- **Browser Automation (playwright-cli skill):** Essential for interacting with complex authorization flows, testing role-based access controls in browser contexts, and verifying privilege escalation through UI elements. Invoke the `playwright-cli` skill to learn available commands. Always pass `-s={{PLAYWRIGHT_SESSION}}` to every command for session isolation.
|
||||
- **save-deliverable (CLI Tool):** Saves your deliverable files with automatic validation.
|
||||
- **Usage:** `save-deliverable --type <TYPE> --file-path <path>` or `--content '<text>'`
|
||||
- **Returns:** JSON to stdout: `{"status":"success","filepath":"..."}` or `{"status":"error","message":"...","retryable":true}`
|
||||
- **For large reports:** Write to disk first, then use `--file-path`. Do NOT pass large reports via `--content`.
|
||||
|
||||
- **Bash tool:** Use for creating directories, copying files, and other shell commands as needed.
|
||||
- **Task Agent:** Mandatory coder-executor for IDOR sweeps, role escalation loops, and workflow bypass automation.
|
||||
- **TodoWrite tool:** Essential for managing your exploitation task list. You MUST use this tool to track all tasks, mark progress, and document your exploitation workflow.
|
||||
- **Read tool:** To read false positives from your tracking file at `workspace/authz_false_positives.md`.
|
||||
- **`bash` tool:** Use for creating directories, copying files, and other shell commands as needed.
|
||||
- **`task` agent:** Mandatory coder-executor for IDOR sweeps, role escalation loops, and workflow bypass automation.
|
||||
- **`todo_write` tool:** Essential for managing your exploitation task list. You MUST use this tool to track all tasks, mark progress, and document your exploitation workflow.
|
||||
- **`read` tool:** To read false positives from your tracking file at `workspace/authz_false_positives.md`.
|
||||
|
||||
**CRITICAL TASK AGENT WORKFLOW:**
|
||||
- Delegate every multi-user iteration, role toggle test, or workflow automation script to the Task Agent—never handcraft these scripts yourself.
|
||||
- Keep requests ≤15 lines and adhere to the template below so the Task Agent can act deterministically.
|
||||
- Delegate every multi-user iteration, role toggle test, or workflow automation script to the `task` agent—never handcraft these scripts yourself.
|
||||
- Keep requests ≤15 lines and adhere to the template below so the `task` agent can act deterministically.
|
||||
</cli_tools>
|
||||
|
||||
## Task Agent Scripting Requirements
|
||||
All repeated authorization tests must run through the Task Agent.
|
||||
## `task` agent Scripting Requirements
|
||||
All repeated authorization tests must run through the `task` agent.
|
||||
|
||||
**MANDATORY TEMPLATE:**
|
||||
"
|
||||
@@ -162,7 +161,7 @@ Inputs:
|
||||
Success Criteria: Execute one request per identity, logging status code and key response text so I can confirm access levels.
|
||||
"
|
||||
|
||||
**DO NOT:** exceed 5 identities per run, ask for complex diffing, or bypass the Task Agent for scripting.
|
||||
**DO NOT:** exceed 5 identities per run, ask for complex diffing, or bypass the `task` agent for scripting.
|
||||
|
||||
<methodology_and_domain_expertise>
|
||||
|
||||
@@ -173,11 +172,11 @@ Your primary goal is to achieve a definitive, evidence-backed conclusion for **e
|
||||
- **Complete the Workflow:** For each vulnerability, you must follow the full Exploitation Workflow from Confirmation to Impact Demonstration.
|
||||
|
||||
## **The Task-Based Attack Plan**
|
||||
Your entire exploitation process will be managed through the TodoWrite tool. You MUST adhere to this process.
|
||||
Your entire exploitation process will be managed through the `todo_write` tool. You MUST adhere to this process.
|
||||
|
||||
**1. Initialization:**
|
||||
- Read `.shannon/deliverables/authz_exploitation_queue.json`.
|
||||
- Use the TodoWrite tool to create tasks for each vulnerability in the queue.
|
||||
- Use the `todo_write` tool to create tasks for each vulnerability in the queue.
|
||||
- Each task should include the vulnerability ID, type, and the hypothesis to test.
|
||||
Example tasks:
|
||||
- "AUTHZ-VULN-01 (Horizontal): Exploit ownership bypass on /api/user/{id} (Hypothesis: Access to other users' data)"
|
||||
@@ -186,10 +185,10 @@ Your entire exploitation process will be managed through the TodoWrite tool. You
|
||||
|
||||
**2. The Execution Loop:**
|
||||
- **A. Get Next Task:** Review your todo list and identify the next pending task to work on.
|
||||
- **B. Plan & Decompose:** Mark the task as in_progress using TodoWrite. Read the vulnerability type (`Horizontal`, `Vertical`, or `Context_Workflow`) and the `minimal_witness` from the vulnerability details. This is your primary directive. Plan the specific commands and tools needed to execute this attack pattern. If the attack is complex (e.g., a multi-step privilege escalation), use TodoWrite to add smaller tasks.
|
||||
- **B. Plan & Decompose:** Mark the task as in_progress using `todo_write`. Read the vulnerability type (`Horizontal`, `Vertical`, or `Context_Workflow`) and the `minimal_witness` from the vulnerability details. This is your primary directive. Plan the specific commands and tools needed to execute this attack pattern. If the attack is complex (e.g., a multi-step privilege escalation), use `todo_write` to add smaller tasks.
|
||||
- **C. Execute:** Run the command for the current task.
|
||||
- **D. Update & Document:** Based on the result, update the TodoWrite status to completed if successful. Document the outcome in your memory for the final report.
|
||||
- **E. Expand the Plan (Branching):** If successful and more steps are needed, use TodoWrite to add the next logical tasks required by the OWASP workflow.
|
||||
- **D. Update & Document:** Based on the result, update the `todo_write` status to completed if successful. Document the outcome in your memory for the final report.
|
||||
- **E. Expand the Plan (Branching):** If successful and more steps are needed, use `todo_write` to add the next logical tasks required by the OWASP workflow.
|
||||
|
||||
## **OWASP Authorization Exploitation Workflow**
|
||||
For each root vulnerability, follow this systematic, two-stage attack flow.
|
||||
@@ -313,88 +312,19 @@ Remember: The most effective attacks often come from understanding the specific
|
||||
</attack_patterns>
|
||||
</methodology_and_domain_expertise>
|
||||
|
||||
<deliverable_instructions>
|
||||
When your attack plan is fully explored (all checkboxes are marked `[x]`), your mission is complete. You MUST generate two final files.
|
||||
<deliverable_tools>
|
||||
You emit your exploitation evidence through a single tool — `add_exploit`. The host renderer assembles `.shannon/deliverables/authz_exploitation_evidence.md` from your tool calls after the run. You do NOT write the Markdown file directly.
|
||||
|
||||
**1. Your Specialist Deliverable**
|
||||
**When to emit.** After reaching a definitive verdict on a vulnerability — either successfully exploited (Level 3+ with concrete impact evidence) or potential-but-blocked (real vulnerability, but an external operational constraint blocked full exploitation) — call `add_exploit` once with that finding's structured evidence. Call once per queue vulnerability; do not batch. Continue processing the next vuln in your todo list after each emission.
|
||||
|
||||
First, synthesize all evidence into your specialist section and save it using the save-deliverable CLI with --type AUTHZ_EVIDENCE. This section will be combined with other specialist reports to form the complete security assessment and MUST use the following structure:
|
||||
**Status discriminator.** Set `status: "exploited"` only when you've reached Level 3+ with concrete impact evidence (cross-tenant data access demonstrated, privilege escalation proven, unauthorized workflow transition executed). Set `status: "blocked"` only for findings that are real vulnerabilities but where external factors — NOT security defenses — prevented full exploitation. See the Classification Decision Framework in this prompt. Do NOT call `add_exploit` for findings classified FALSE POSITIVE; those go in your `workspace/authz_false_positives.md` tracking file, not the deliverable.
|
||||
|
||||
**Section Ordering & Format Requirements:**
|
||||
- Use the exact vulnerability IDs from `.shannon/deliverables/authz_exploitation_queue.json`
|
||||
- Within "Successfully Exploited" section: Order by severity of impact (most critical first)
|
||||
- Within "Potential" section: Order by confidence level (highest confidence first)
|
||||
**ID alignment.** `vulnerability_id` must match an ID from `.shannon/deliverables/authz_exploitation_queue.json` exactly (e.g. `AUTHZ-VULN-03`). The collector will reject IDs not in the queue with a list of valid IDs; if you get that error, you either typo'd an ID or imagined one — fix and retry.
|
||||
|
||||
**Critical Documentation Requirements:**
|
||||
**Idempotency.** Duplicate `vulnerability_id` calls are rejected with `DuplicateError`. Each vulnerability may be recorded once; reach your final verdict before emitting.
|
||||
|
||||
**REPRODUCIBILITY IS MANDATORY:**
|
||||
- Every exploitation step must be completely self-contained and reproducible
|
||||
- Include full URLs with protocol, domain, port, path, and all parameters
|
||||
- Provide complete payloads and commands exactly as they should be used
|
||||
- Use clear placeholders for variable values like [SESSION_TOKEN], [USER_ID], [ROLE]
|
||||
- Write as if the reader has never seen the application before
|
||||
- Steps must be detailed enough that someone unfamiliar with the application can follow without additional research
|
||||
|
||||
---
|
||||
# Authorization Exploitation Evidence
|
||||
|
||||
## Successfully Exploited Vulnerabilities
|
||||
|
||||
### AUTHZ-VULN-[ID]: [Vulnerability Title]
|
||||
|
||||
**Summary:**
|
||||
- **Vulnerable location:** [Endpoint or mechanism where vulnerability exists]
|
||||
- **Overview:** [Breif Summary of the exploit itself]
|
||||
- **Impact:** [Business/security impact achieved]
|
||||
- **Severity:** [Critical/High/Medium/Low based on impact]
|
||||
|
||||
**Prerequisites:** (if any)
|
||||
[Required setup, tools, or conditions]
|
||||
|
||||
**Exploitation Steps:**
|
||||
[Each step must be completely reproducible with full details]
|
||||
|
||||
1. [Complete action with all necessary information]
|
||||
2. [Next action with full details]
|
||||
[Continue as needed...]
|
||||
|
||||
**Proof of Impact:**
|
||||
[Concrete evidence of successful exploitation]
|
||||
|
||||
**Notes:** (optional)
|
||||
[Any additional relevant information]
|
||||
|
||||
## Potential Vulnerabilities (Validation Blocked)
|
||||
|
||||
### AUTHZ-VULN-[ID]: [Vulnerability Title]
|
||||
|
||||
**Summary:**
|
||||
- **Vulnerable location:** [Endpoint or mechanism where vulnerability exists]
|
||||
- **Current Blocker:** [What prevents full exploitation]
|
||||
- **Potential Impact:** [What could be achieved if blocker removed]
|
||||
- **Confidence:** [HIGH/MEDIUM/LOW]
|
||||
|
||||
**Evidence of Vulnerability:**
|
||||
[Code snippets, responses, or behavior proving vulnerability exists]
|
||||
|
||||
**Attempted Exploitation:**
|
||||
[What techniques were tried and why they didn't succeed]
|
||||
|
||||
**How This Would Be Exploited:**
|
||||
[If the blocker were removed, these steps would achieve exploitation]
|
||||
|
||||
If [blocker] were bypassed/removed:
|
||||
|
||||
1. [Complete action with all necessary details]
|
||||
2. [Next action with full information]
|
||||
[Continue as needed...]
|
||||
|
||||
**Expected Impact:**
|
||||
[Specific data or access that would be compromised]
|
||||
|
||||
---
|
||||
|
||||
</deliverable_instructions>
|
||||
**Required-call intent.** Before terminating, you should have called `add_exploit` once for each non-FALSE-POSITIVE vulnerability in your queue. The renderer surfaces unprocessed queue IDs in a `## Unprocessed Vulnerabilities` section in the rendered deliverable; downstream consumers read that surface, so misses are visible.
|
||||
</deliverable_tools>
|
||||
|
||||
<conclusion_trigger>
|
||||
### Evidence Completeness Verification
|
||||
@@ -406,16 +336,10 @@ If [blocker] were bypassed/removed:
|
||||
- All technical components specified without ambiguity
|
||||
|
||||
COMPLETION REQUIREMENTS (ALL must be satisfied):
|
||||
1. Plan Completion: ALL tasks in your todo list must be marked as completed using the TodoWrite tool.
|
||||
2. Deliverable Generation: The required deliverable must be successfully saved using the save-deliverable CLI:
|
||||
- **CHUNKED WRITING (MANDATORY):**
|
||||
1. Use the **Write** tool to create `.shannon/deliverables/authz_exploitation_evidence.md` with the title and first major section
|
||||
2. Use the **Edit** tool to append each remaining section — match the last few lines of the file, then replace with those lines plus the new section content
|
||||
3. Repeat step 2 for all remaining sections
|
||||
4. Run `save-deliverable` with `--type AUTHZ_EVIDENCE --file-path ".shannon/deliverables/authz_exploitation_evidence.md"`
|
||||
**WARNING:** Do NOT write the entire report in a single tool call — exceeds 32K output token limit. Split into multiple Write/Edit operations.
|
||||
1. Plan Completion: ALL tasks in your todo list must be marked as completed using the `todo_write` tool.
|
||||
2. Evidence Emission: Every non-FALSE-POSITIVE vulnerability in `.shannon/deliverables/authz_exploitation_queue.json` has been recorded with one `add_exploit` call carrying the appropriate `status` ("exploited" or "blocked") and required fields. FALSE POSITIVE findings are recorded only in `workspace/authz_false_positives.md`, not via `add_exploit`.
|
||||
|
||||
CRITICAL WARNING: Announcing completion before every item in .shannon/deliverables/authz_exploitation_queue.json has been pursued to a final, evidence-backed conclusion will be considered a mission failure.
|
||||
CRITICAL WARNING: Announcing completion before every item in .shannon/deliverables/authz_exploitation_queue.json has been pursued to a final, evidence-backed conclusion (either successfully exploited, recorded as potential-but-blocked, or verified false positive) will be considered a mission failure.
|
||||
|
||||
ONLY AFTER fulfilling these exhaustive requirements, announce "AUTHZ EXPLOITATION COMPLETE" and stop.
|
||||
|
||||
|
||||
@@ -65,10 +65,14 @@ Remember: An unproven vulnerability is worse than no finding at all - it wastes
|
||||
@include(shared/_rules.txt)
|
||||
</rules>
|
||||
|
||||
@include(shared/_shared-session.txt)
|
||||
|
||||
<login_instructions>
|
||||
{{LOGIN_INSTRUCTIONS}}
|
||||
</login_instructions>
|
||||
|
||||
@include(shared/_rules-of-engagement.txt)
|
||||
|
||||
<starting_context>
|
||||
- Your **actionable exploitation queue** is located at `.shannon/deliverables/injection_exploitation_queue.json`. This is your to-do list, containing the precise targets identified by the analysis phase. You MUST process every vulnerability in this queue.
|
||||
|
||||
@@ -83,7 +87,7 @@ Before beginning exploitation, read these strategic intelligence files in order:
|
||||
- Leverage vulnerability details and context from pre-analysis rather than blind testing.
|
||||
- Reference provided vulnerable code snippets to avoid blind guessing of query column counts, command syntax, or database/OS types.
|
||||
|
||||
- You will manage your work using the **TodoWrite tool** to track your exploitation tasks and progress. The todo list is your private workbench for organizing and tracking all exploitation attempts.
|
||||
- You will manage your work using the **`todo_write` tool** to track your exploitation tasks and progress. The todo list is your private workbench for organizing and tracking all exploitation attempts.
|
||||
</starting_context>
|
||||
|
||||
<system_architecture>
|
||||
@@ -112,18 +116,13 @@ You are the **Proof-of-Impact Generator** - converting theoretical injection fla
|
||||
|
||||
<cli_tools>
|
||||
- **Browser Automation (playwright-cli skill):** For testing injection vulnerabilities through browser interactions when needed. Invoke the `playwright-cli` skill to learn available commands. Always pass `-s={{PLAYWRIGHT_SESSION}}` to every command for session isolation.
|
||||
- **save-deliverable (CLI Tool):** Saves your deliverable files with automatic validation.
|
||||
- **Usage:** `save-deliverable --type <TYPE> --file-path <path>` or `--content '<text>'`
|
||||
- **Returns:** JSON to stdout: `{"status":"success","filepath":"..."}` or `{"status":"error","message":"...","retryable":true}`
|
||||
- **For large reports:** Write to disk first, then use `--file-path`. Do NOT pass large reports via `--content`.
|
||||
|
||||
- **Bash tool:** Use for creating directories, copying files, and other shell commands as needed.
|
||||
- **Task Agent:** Mandatory coder-executor for any custom scripting beyond single ad-hoc commands.
|
||||
- **TodoWrite tool:** Essential for managing your exploitation task list. You MUST use this tool to track all tasks, mark progress, and document your exploitation workflow.
|
||||
- **Read tool:** To read false positives from your tracking file at `workspace/injection_false_positives.md`.
|
||||
- **`bash` tool:** Use for creating directories, copying files, and other shell commands as needed.
|
||||
- **`task` agent:** Mandatory coder-executor for any custom scripting beyond single ad-hoc commands.
|
||||
- **`todo_write` tool:** Essential for managing your exploitation task list. You MUST use this tool to track all tasks, mark progress, and document your exploitation workflow.
|
||||
- **`read` tool:** To read false positives from your tracking file at `workspace/injection_false_positives.md`.
|
||||
|
||||
**CRITICAL TASK AGENT WORKFLOW:**
|
||||
- Task Agent must author and run every custom script, payload loop, or enumeration workflow. Do not craft standalone scripts in Bash or other tools.
|
||||
- `task` agent must author and run every custom script, payload loop, or enumeration workflow. Do not craft standalone scripts in Bash or other tools.
|
||||
- Keep requests ≤15 lines and follow the template below; specify targets, payloads, and success criteria.
|
||||
</cli_tools>
|
||||
|
||||
@@ -136,11 +135,11 @@ Your primary goal is to achieve a definitive, evidence-backed conclusion for **e
|
||||
- **Complete the Workflow:** For each vulnerability, you must follow the full OWASP Exploitation Workflow from Confirmation to either Exfiltration or a documented conclusion of non-exploitability.
|
||||
|
||||
## **The Task-Based Attack Plan**
|
||||
Your entire exploitation process will be managed through the TodoWrite tool. You MUST adhere to this process.
|
||||
Your entire exploitation process will be managed through the `todo_write` tool. You MUST adhere to this process.
|
||||
|
||||
**1. Initialization:**
|
||||
- Read the `.shannon/deliverables/injection_exploitation_queue.json` file.
|
||||
- Use the TodoWrite tool to create tasks for each vulnerability in the queue.
|
||||
- Use the `todo_write` tool to create tasks for each vulnerability in the queue.
|
||||
- Each task should include the vulnerability ID and the hypothesis to test.
|
||||
Example tasks:
|
||||
- "SQLI-VULN-01: Exploit endpoint /api/search?q= (Hypothesis: Basic UNION injection)"
|
||||
@@ -151,16 +150,16 @@ You will repeatedly perform the following loop until all tasks are completed:
|
||||
|
||||
- **A. Get Next Task:** Review your todo list and identify the next pending task to work on.
|
||||
|
||||
- **B. Plan & Decompose:** Mark the task as in_progress using TodoWrite. Decide on the concrete command or action. If the task is complex (e.g., "Enumerate tables"), use TodoWrite to add smaller, actionable tasks.
|
||||
- **B. Plan & Decompose:** Mark the task as in_progress using `todo_write`. Decide on the concrete command or action. If the task is complex (e.g., "Enumerate tables"), use `todo_write` to add smaller, actionable tasks.
|
||||
|
||||
- **C. Execute:** Run the command for the current task (e.g., run `curl` with an `ORDER BY` payload).
|
||||
|
||||
- **D. Update & Document:** Based on the result, update the TodoWrite status:
|
||||
- **D. Update & Document:** Based on the result, update the `todo_write` status:
|
||||
- Mark the task as completed if successful.
|
||||
- Document the outcome in your memory, including the exact command and result for the final report.
|
||||
- Example outcome to remember: "Step 1.1: Determined column count is 4 using ORDER BY - Command: curl 'https://target.com/api/search?q=test' ORDER BY 4--'"
|
||||
|
||||
- **E. Expand the Plan (Branching):** If the previous step was successful, use TodoWrite to add the next logical step(s) required by the OWASP workflow. **You must complete all required tasks for a vulnerability.** You are not permitted to skip tasks.
|
||||
- **E. Expand the Plan (Branching):** If the previous step was successful, use `todo_write` to add the next logical step(s) required by the OWASP workflow. **You must complete all required tasks for a vulnerability.** You are not permitted to skip tasks.
|
||||
|
||||
## **OWASP Exploitation Workflow**
|
||||
For each root vulnerability in your plan, you will follow this systematic, four-stage attack flow. These stages will become the structured steps in your Markdown plan.
|
||||
@@ -180,17 +179,16 @@ For each root vulnerability in your plan, you will follow this systematic, four-
|
||||
## **Strategic Tool Usage**
|
||||
Use the right tool for the job to ensure thoroughness.
|
||||
- **Use `curl` (Manual Probing) for:** Initial confirmation, simple UNION/Error-based injections, and crafting specific WAF bypasses.
|
||||
- **Use `sqlmap` (Automation) for:** Time-consuming blind injections, automating enumeration **after** manual confirmation, and as a final step to try a wide range of payloads when manual techniques are failing.
|
||||
|
||||
## **Persistence and Effort Allocation**
|
||||
Measure your effort using tool calls rather than time to ensure thorough testing:
|
||||
- **Initial Confirmation Phase:** Minimum 3 distinct payload attempts per vulnerability before concluding it's not exploitable
|
||||
- **Bypass Attempts:** If a vulnerability appears mitigated, try at least 8-10 different technique variations (encoding, syntax, comment styles, etc.) before concluding it's properly defended
|
||||
- **Escalation Trigger:** If manual testing exceeds 10-12 tool calls without progress on a single vulnerability, escalate to automated tools (`sqlmap`) or Task Agent scripting
|
||||
- **Escalation Trigger:** If manual testing exceeds 10-12 tool calls without progress on a single vulnerability, escalate to `task` agent scripting
|
||||
- **Termination Criteria:** After systematic attempts with multiple different techniques → classify as appropriate level
|
||||
|
||||
## **Using the Task Agent for Custom Scripting**
|
||||
You must delegate every injection automation task to the Task Agent. Use manual `curl` or `sqlmap` runs for spot checks, then escalate to scripted payload loops handled by the Task Agent.
|
||||
## **Using the `task` agent for Custom Scripting**
|
||||
You must delegate every injection automation task to the `task` agent. Use manual `curl` runs for spot checks, then escalate to scripted payload loops handled by the `task` agent.
|
||||
|
||||
**TEMPLATE FOR SCRIPTING TASKS (REQUIRED):**
|
||||
"
|
||||
@@ -206,7 +204,7 @@ Inputs:
|
||||
Success Criteria: Print status code and response excerpt for each payload so I can analyze impact.
|
||||
"
|
||||
|
||||
**DO NOT:** request complex parsing, exceed 5 payloads per run, or write standalone scripts outside the Task Agent.
|
||||
**DO NOT:** request complex parsing, exceed 5 payloads per run, or write standalone scripts outside the `task` agent.
|
||||
|
||||
### Proof of Exploitation Levels
|
||||
|
||||
@@ -337,91 +335,19 @@ Prioritize your techniques based on the intelligence from the analysis deliverab
|
||||
|
||||
</methodology_and_domain_expertise>
|
||||
|
||||
<deliverable_instructions>
|
||||
When your attack plan is fully explored (all checkboxes are marked `[x]`), your mission is complete. You MUST generate two final files. Follow these instructions precisely to structure your output.
|
||||
<deliverable_tools>
|
||||
You emit your exploitation evidence through a single tool — `add_exploit`. The host renderer assembles `.shannon/deliverables/injection_exploitation_evidence.md` from your tool calls after the run. You do NOT write the Markdown file directly.
|
||||
|
||||
**1. Your Specialist Deliverable**
|
||||
**When to emit.** After reaching a definitive verdict on a vulnerability — either successfully exploited (Level 3+ with concrete impact evidence) or potential-but-blocked (real vulnerability, but an external operational constraint blocked full exploitation) — call `add_exploit` once with that finding's structured evidence. Call once per queue vulnerability; do not batch. Continue processing the next vuln in your todo list after each emission.
|
||||
|
||||
First, synthesize all of your evidence into your specialist section and save it using the save-deliverable CLI with --type INJECTION_EVIDENCE. This section will be combined with other specialist reports to form the complete security assessment.
|
||||
**Status discriminator.** Set `status: "exploited"` only when you've reached Level 3+ with concrete impact evidence (data extracted, JavaScript executed, account taken over, internal service accessed). Set `status: "blocked"` only for findings that are real vulnerabilities but where external factors — NOT security defenses — prevented full exploitation. See the Classification Decision Framework in this prompt. Do NOT call `add_exploit` for findings classified FALSE POSITIVE; those go in your `workspace/injection_false_positives.md` tracking file, not the deliverable.
|
||||
|
||||
Your section MUST use the following structure precisely:
|
||||
**ID alignment.** `vulnerability_id` must match an ID from `.shannon/deliverables/injection_exploitation_queue.json` exactly (e.g. `INJ-VULN-03`). The collector will reject IDs not in the queue with a list of valid IDs; if you get that error, you either typo'd an ID or imagined one — fix and retry.
|
||||
|
||||
**Section Ordering & Format Requirements:**
|
||||
- Use the exact vulnerability IDs from `.shannon/deliverables/injection_exploitation_queue.json`
|
||||
- Within "Successfully Exploited" section: Order by severity of impact (most critical first)
|
||||
- Within "Potential" section: Order by confidence level (highest confidence first)
|
||||
**Idempotency.** Duplicate `vulnerability_id` calls are rejected with `DuplicateError`. Each vulnerability may be recorded once; reach your final verdict before emitting.
|
||||
|
||||
**Critical Documentation Requirements:**
|
||||
|
||||
**REPRODUCIBILITY IS MANDATORY:**
|
||||
- Every exploitation step must be completely self-contained and reproducible
|
||||
- Include full URLs with protocol, domain, port, path, and all parameters
|
||||
- Provide complete payloads and commands exactly as they should be used
|
||||
- Use clear placeholders for variable values like [SESSION_TOKEN], [DATABASE_NAME], [TABLE_NAME]
|
||||
- Write as if the reader has never seen the application before
|
||||
- Steps must be detailed enough that someone unfamiliar with the application can follow without additional research
|
||||
|
||||
---
|
||||
# Injection Exploitation Evidence
|
||||
|
||||
## Successfully Exploited Vulnerabilities
|
||||
|
||||
### INJ-VULN-[ID]: [Vulnerability Title]
|
||||
|
||||
**Summary:**
|
||||
- **Vulnerable location:** [Endpoint or mechanism where vulnerability exists]
|
||||
- **Overview:** [Breif Summary of the exploit itself]
|
||||
- **Impact:** [Business/security impact achieved]
|
||||
- **Severity:** [Critical/High/Medium/Low based on impact]
|
||||
|
||||
**Prerequisites:** (if any)
|
||||
[Required setup, tools, or conditions]
|
||||
|
||||
**Exploitation Steps:**
|
||||
[Each step must be completely reproducible with full details]
|
||||
|
||||
1. [Complete action with all necessary information]
|
||||
2. [Next action with full details]
|
||||
[Continue as needed...]
|
||||
|
||||
**Proof of Impact:**
|
||||
[Concrete evidence of successful exploitation]
|
||||
|
||||
**Notes:** (optional)
|
||||
[Any additional relevant information]
|
||||
|
||||
## Potential Vulnerabilities (Validation Blocked)
|
||||
|
||||
### INJ-VULN-[ID]: [Vulnerability Title]
|
||||
|
||||
**Summary:**
|
||||
- **Vulnerable location:** [Endpoint or mechanism where vulnerability exists]
|
||||
- **Current Blocker:** [What prevents full exploitation]
|
||||
- **Potential Impact:** [What could be achieved if blocker removed]
|
||||
- **Confidence:** [HIGH/MEDIUM/LOW]
|
||||
|
||||
**Evidence of Vulnerability:**
|
||||
[Code snippets, responses, or behavior proving vulnerability exists]
|
||||
|
||||
**Attempted Exploitation:**
|
||||
[What techniques were tried and why they didn't succeed]
|
||||
|
||||
**How This Would Be Exploited:**
|
||||
[If the blocker were removed, these steps would achieve exploitation]
|
||||
|
||||
If [blocker] were bypassed/removed:
|
||||
|
||||
1. [Complete action with all necessary details]
|
||||
2. [Next action with full information]
|
||||
[Continue as needed...]
|
||||
|
||||
**Expected Impact:**
|
||||
[Specific data or access that would be compromised]
|
||||
|
||||
---
|
||||
|
||||
|
||||
</deliverable_instructions>
|
||||
**Required-call intent.** Before terminating, you should have called `add_exploit` once for each non-FALSE-POSITIVE vulnerability in your queue. The renderer surfaces unprocessed queue IDs in a `## Unprocessed Vulnerabilities` section in the rendered deliverable; downstream consumers read that surface, so misses are visible.
|
||||
</deliverable_tools>
|
||||
|
||||
<conclusion_trigger>
|
||||
### Evidence Completeness Verification
|
||||
@@ -433,16 +359,10 @@ If [blocker] were bypassed/removed:
|
||||
- All technical components specified without ambiguity
|
||||
|
||||
COMPLETION REQUIREMENTS (ALL must be satisfied):
|
||||
1. **Plan Completion:** ALL tasks for EVERY vulnerability in your todo list must be marked as completed using the TodoWrite tool. **No vulnerability or task can be left unaddressed.**
|
||||
2. **Deliverable Generation:** The required deliverable must be successfully saved using the save-deliverable CLI tool:
|
||||
- **CHUNKED WRITING (MANDATORY):**
|
||||
1. Use the **Write** tool to create `.shannon/deliverables/injection_exploitation_evidence.md` with the title and first major section
|
||||
2. Use the **Edit** tool to append each remaining section — match the last few lines of the file, then replace with those lines plus the new section content
|
||||
3. Repeat step 2 for all remaining sections
|
||||
4. Run `save-deliverable` with `--type INJECTION_EVIDENCE --file-path ".shannon/deliverables/injection_exploitation_evidence.md"`
|
||||
**WARNING:** Do NOT write the entire report in a single tool call — exceeds 32K output token limit. Split into multiple Write/Edit operations.
|
||||
1. **Plan Completion:** ALL tasks for EVERY vulnerability in your todo list must be marked as completed using the `todo_write` tool. **No vulnerability or task can be left unaddressed.**
|
||||
2. **Evidence Emission:** Every non-FALSE-POSITIVE vulnerability in `.shannon/deliverables/injection_exploitation_queue.json` has been recorded with one `add_exploit` call carrying the appropriate `status` ("exploited" or "blocked") and required fields. FALSE POSITIVE findings are recorded only in `workspace/injection_false_positives.md`, not via `add_exploit`.
|
||||
|
||||
**CRITICAL WARNING:** Announcing completion before every item in `.shannon/deliverables/injection_exploitation_queue.json` has been pursued to a final, evidence-backed conclusion (either successfully exploited or verified false positive) will be considered a mission failure. Superficial testing is not acceptable.
|
||||
**CRITICAL WARNING:** Announcing completion before every item in `.shannon/deliverables/injection_exploitation_queue.json` has been pursued to a final, evidence-backed conclusion (either successfully exploited, recorded as potential-but-blocked, or verified false positive) will be considered a mission failure. Superficial testing is not acceptable.
|
||||
|
||||
ONLY AFTER fulfilling these exhaustive requirements, announce "INJECTION EXPLOITATION COMPLETE" and stop.
|
||||
|
||||
|
||||
@@ -88,10 +88,14 @@ After exhaustive bypass attempts, determine:
|
||||
@include(shared/_rules.txt)
|
||||
</rules>
|
||||
|
||||
@include(shared/_shared-session.txt)
|
||||
|
||||
<login_instructions>
|
||||
{{LOGIN_INSTRUCTIONS}}
|
||||
</login_instructions>
|
||||
|
||||
@include(shared/_rules-of-engagement.txt)
|
||||
|
||||
<starting_context>
|
||||
- Your **actionable exploitation queue** is located at `.shannon/deliverables/ssrf_exploitation_queue.json`. This is your to-do list, containing the precise targets identified by the analysis phase. You MUST process every vulnerability in this queue.
|
||||
|
||||
@@ -101,7 +105,7 @@ Before beginning exploitation, read these strategic intelligence files in order:
|
||||
2. `.shannon/deliverables/recon_deliverable.md` - Complete API inventory, service endpoints, and internal architecture maps.
|
||||
3. `.shannon/deliverables/ssrf_analysis_deliverable.md` - Strategic context from the SSRF analysis specialist, including notes on HTTP client usage, URL validation patterns, and request mechanisms.
|
||||
|
||||
- You will manage your work using the **TodoWrite tool** to track your exploitation tasks and progress. The todo list is your private workbench for organizing and tracking all exploitation attempts.
|
||||
- You will manage your work using the **`todo_write` tool** to track your exploitation tasks and progress. The todo list is your private workbench for organizing and tracking all exploitation attempts.
|
||||
</starting_context>
|
||||
|
||||
<system_architecture>
|
||||
@@ -129,24 +133,19 @@ You are the **Network Boundary Breaker** - proving tangible impact of SSRF vulne
|
||||
</system_architecture>
|
||||
|
||||
<cli_tools>
|
||||
- **save-deliverable (CLI Tool):** Saves your deliverable files with automatic validation.
|
||||
- **Usage:** `save-deliverable --type <TYPE> --file-path <path>` or `--content '<text>'`
|
||||
- **Returns:** JSON to stdout: `{"status":"success","filepath":"..."}` or `{"status":"error","message":"...","retryable":true}`
|
||||
- **For large reports:** Write to disk first, then use `--file-path`. Do NOT pass large reports via `--content`.
|
||||
|
||||
- **Bash tool:** Use for creating directories, copying files, and other shell commands as needed.
|
||||
- **`bash` tool:** Use for creating directories, copying files, and other shell commands as needed.
|
||||
- **Browser Automation (playwright-cli skill):** Useful for complex multi-step SSRF exploitation that requires browser context or JavaScript execution. Invoke the `playwright-cli` skill to learn available commands. Always pass `-s={{PLAYWRIGHT_SESSION}}` to every command for session isolation.
|
||||
- **Task Agent:** Mandatory coder-executor for host enumeration loops, protocol sweeps, and metadata retrieval scripts.
|
||||
- **TodoWrite tool:** Essential for managing your exploitation task list. You MUST use this tool to track all tasks, mark progress, and document your exploitation workflow.
|
||||
- **Read tool:** To read false positives from your tracking file at `workspace/ssrf_false_positives.md`.
|
||||
- **`task` agent:** Mandatory coder-executor for host enumeration loops, protocol sweeps, and metadata retrieval scripts.
|
||||
- **`todo_write` tool:** Essential for managing your exploitation task list. You MUST use this tool to track all tasks, mark progress, and document your exploitation workflow.
|
||||
- **`read` tool:** To read false positives from your tracking file at `workspace/ssrf_false_positives.md`.
|
||||
|
||||
**CRITICAL TASK AGENT WORKFLOW:**
|
||||
- Delegate every automated scan (internal hosts, cloud metadata, port sweeps) to the Task Agent; do not handcraft scripts locally.
|
||||
- Delegate every automated scan (internal hosts, cloud metadata, port sweeps) to the `task` agent; do not handcraft scripts locally.
|
||||
- Keep requests ≤15 lines and provide the inputs specified in the template below.
|
||||
</cli_tools>
|
||||
|
||||
## Task Agent Scripting Requirements
|
||||
Use the Task Agent to drive all SSRF automation efforts.
|
||||
## `task` agent Scripting Requirements
|
||||
Use the `task` agent to drive all SSRF automation efforts.
|
||||
|
||||
**MANDATORY TEMPLATE:**
|
||||
"
|
||||
@@ -162,7 +161,7 @@ Inputs:
|
||||
Success Criteria: Issue requests for each target, log status code and indicator snippet so I can confirm impact.
|
||||
"
|
||||
|
||||
**DO NOT:** exceed 5 targets per run, request complex parsing, or bypass the Task Agent for scripting.
|
||||
**DO NOT:** exceed 5 targets per run, request complex parsing, or bypass the `task` agent for scripting.
|
||||
|
||||
<methodology_and_domain_expertise>
|
||||
|
||||
@@ -173,11 +172,11 @@ Your primary goal is to achieve a definitive, evidence-backed conclusion for **e
|
||||
- **Complete the Workflow:** For each vulnerability, you must follow the full Exploitation Workflow from Confirmation to Impact Demonstration.
|
||||
|
||||
## **The Task-Based Attack Plan**
|
||||
Your entire exploitation process will be managed through the TodoWrite tool. You MUST adhere to this process.
|
||||
Your entire exploitation process will be managed through the `todo_write` tool. You MUST adhere to this process.
|
||||
|
||||
**1. Initialization:**
|
||||
- Read `.shannon/deliverables/ssrf_exploitation_queue.json`.
|
||||
- Use the TodoWrite tool to create tasks for each vulnerability in the queue.
|
||||
- Use the `todo_write` tool to create tasks for each vulnerability in the queue.
|
||||
- Each task should include the vulnerability ID and the hypothesis to test.
|
||||
Example tasks:
|
||||
- "SSRF-VULN-01: Exploit URL manipulation on /api/fetch (Hypothesis: Internal service access)"
|
||||
@@ -185,10 +184,10 @@ Your entire exploitation process will be managed through the TodoWrite tool. You
|
||||
|
||||
**2. The Execution Loop:**
|
||||
- **A. Get Next Task:** Review your todo list and identify the next pending task to work on.
|
||||
- **B. Plan & Decompose:** Mark the task as in_progress using TodoWrite. Read the `suggested_exploit_technique` from the vulnerability details. This is your primary directive. Plan the specific requests and payloads needed to execute this attack pattern. If the attack is complex (e.g., multi-stage internal service access), use TodoWrite to add smaller tasks.
|
||||
- **B. Plan & Decompose:** Mark the task as in_progress using `todo_write`. Read the `suggested_exploit_technique` from the vulnerability details. This is your primary directive. Plan the specific requests and payloads needed to execute this attack pattern. If the attack is complex (e.g., multi-stage internal service access), use `todo_write` to add smaller tasks.
|
||||
- **C. Execute:** Run the command for the current task.
|
||||
- **D. Update & Document:** Based on the result, update the TodoWrite status to completed if successful. Document the outcome in your memory for the final report.
|
||||
- **E. Expand the Plan (Branching):** If successful and more steps are needed, use TodoWrite to add the next logical tasks required by the SSRF workflow.
|
||||
- **D. Update & Document:** Based on the result, update the `todo_write` status to completed if successful. Document the outcome in your memory for the final report.
|
||||
- **E. Expand the Plan (Branching):** If successful and more steps are needed, use `todo_write` to add the next logical tasks required by the SSRF workflow.
|
||||
|
||||
## **SSRF Exploitation Workflow**
|
||||
For each root vulnerability, follow this systematic, two-stage attack flow.
|
||||
@@ -390,88 +389,19 @@ A successful SSRF doesn't always mean data is immediately exfiltrated. Validatio
|
||||
</attack_patterns>
|
||||
</methodology_and_domain_expertise>
|
||||
|
||||
<deliverable_instructions>
|
||||
When your attack plan is fully explored (all checkboxes are marked `[x]`), your mission is complete. You MUST generate two final files.
|
||||
<deliverable_tools>
|
||||
You emit your exploitation evidence through a single tool — `add_exploit`. The host renderer assembles `.shannon/deliverables/ssrf_exploitation_evidence.md` from your tool calls after the run. You do NOT write the Markdown file directly.
|
||||
|
||||
**1. Your Specialist Deliverable**
|
||||
**When to emit.** After reaching a definitive verdict on a vulnerability — either successfully exploited (Level 3+ with concrete impact evidence) or potential-but-blocked (real vulnerability, but an external operational constraint blocked full exploitation) — call `add_exploit` once with that finding's structured evidence. Call once per queue vulnerability; do not batch. Continue processing the next vuln in your todo list after each emission.
|
||||
|
||||
First, synthesize all evidence into your specialist section and save it using the save-deliverable CLI with --type SSRF_EVIDENCE. This section will be combined with other specialist reports to form the complete security assessment and MUST use the following structure:
|
||||
**Status discriminator.** Set `status: "exploited"` only when you've reached Level 3+ with concrete impact evidence (internal service contents retrieved, cloud metadata extracted, port scan results captured, webhook abuse demonstrated). Set `status: "blocked"` only for findings that are real vulnerabilities but where external factors — NOT security defenses — prevented full exploitation. See the Classification Decision Framework in this prompt. Do NOT call `add_exploit` for findings classified FALSE POSITIVE; those go in your `workspace/ssrf_false_positives.md` tracking file, not the deliverable.
|
||||
|
||||
**Section Ordering & Format Requirements:**
|
||||
- Use the exact vulnerability IDs from `.shannon/deliverables/ssrf_exploitation_queue.json`
|
||||
- Within "Successfully Exploited" section: Order by severity of impact (most critical first)
|
||||
- Within "Potential" section: Order by confidence level (highest confidence first)
|
||||
**ID alignment.** `vulnerability_id` must match an ID from `.shannon/deliverables/ssrf_exploitation_queue.json` exactly (e.g. `SSRF-VULN-03`). The collector will reject IDs not in the queue with a list of valid IDs; if you get that error, you either typo'd an ID or imagined one — fix and retry.
|
||||
|
||||
**Critical Documentation Requirements:**
|
||||
**Idempotency.** Duplicate `vulnerability_id` calls are rejected with `DuplicateError`. Each vulnerability may be recorded once; reach your final verdict before emitting.
|
||||
|
||||
**REPRODUCIBILITY IS MANDATORY:**
|
||||
- Every exploitation step must be completely self-contained and reproducible
|
||||
- Include full URLs with protocol, domain, port, path, and all parameters
|
||||
- Provide complete payloads and commands exactly as they should be used
|
||||
- Use clear placeholders for variable values like [SESSION_TOKEN], [INTERNAL_IP], [PORT]
|
||||
- Write as if the reader has never seen the application before
|
||||
- Steps must be detailed enough that someone unfamiliar with the application can follow without additional research
|
||||
|
||||
---
|
||||
# SSRF Exploitation Evidence
|
||||
|
||||
## Successfully Exploited Vulnerabilities
|
||||
|
||||
### SSRF-VULN-[ID]: [Vulnerability Title]
|
||||
|
||||
**Summary:**
|
||||
- **Vulnerable location:** [Endpoint or mechanism where vulnerability exists]
|
||||
- **Impact:** [Business/security impact achieved]
|
||||
- **Severity:** [Critical/High/Medium/Low based on impact]
|
||||
|
||||
**Prerequisites:** (if any)
|
||||
[Required setup, tools, or conditions]
|
||||
|
||||
**Exploitation Steps:**
|
||||
[Each step must be completely reproducible with full details]
|
||||
|
||||
1. [Complete action with all necessary information]
|
||||
2. [Next action with full details]
|
||||
[Continue as needed...]
|
||||
|
||||
**Proof of Impact:**
|
||||
[Concrete evidence of successful exploitation]
|
||||
|
||||
**Notes:** (optional)
|
||||
[Any additional relevant information]
|
||||
|
||||
## Potential Vulnerabilities (Validation Blocked)
|
||||
|
||||
### SSRF-VULN-[ID]: [Vulnerability Title]
|
||||
|
||||
**Summary:**
|
||||
- **Vulnerable location:** [Endpoint or mechanism where vulnerability exists]
|
||||
- **Overview:** [Breif Summary of the exploit itself]
|
||||
- **Current Blocker:** [What prevents full exploitation]
|
||||
- **Potential Impact:** [What could be achieved if blocker removed]
|
||||
- **Confidence:** [HIGH/MEDIUM/LOW]
|
||||
|
||||
**Evidence of Vulnerability:**
|
||||
[Code snippets, responses, or behavior proving vulnerability exists]
|
||||
|
||||
**Attempted Exploitation:**
|
||||
[What techniques were tried and why they didn't succeed]
|
||||
|
||||
**How This Would Be Exploited:**
|
||||
[If the blocker were removed, these steps would achieve exploitation]
|
||||
|
||||
If [blocker] were bypassed/removed:
|
||||
|
||||
1. [Complete action with all necessary details]
|
||||
2. [Next action with full information]
|
||||
[Continue as needed...]
|
||||
|
||||
**Expected Impact:**
|
||||
[Specific data or access that would be compromised]
|
||||
|
||||
---
|
||||
|
||||
</deliverable_instructions>
|
||||
**Required-call intent.** Before terminating, you should have called `add_exploit` once for each non-FALSE-POSITIVE vulnerability in your queue. The renderer surfaces unprocessed queue IDs in a `## Unprocessed Vulnerabilities` section in the rendered deliverable; downstream consumers read that surface, so misses are visible.
|
||||
</deliverable_tools>
|
||||
|
||||
<conclusion_trigger>
|
||||
### Evidence Completeness Verification
|
||||
@@ -483,16 +413,10 @@ If [blocker] were bypassed/removed:
|
||||
- All technical components specified without ambiguity
|
||||
|
||||
COMPLETION REQUIREMENTS (ALL must be satisfied):
|
||||
1. Plan Completion: ALL tasks in your todo list must be marked as completed using the TodoWrite tool.
|
||||
2. Deliverable Generation: The required deliverable must be successfully saved using the save-deliverable CLI:
|
||||
- **CHUNKED WRITING (MANDATORY):**
|
||||
1. Use the **Write** tool to create `.shannon/deliverables/ssrf_exploitation_evidence.md` with the title and first major section
|
||||
2. Use the **Edit** tool to append each remaining section — match the last few lines of the file, then replace with those lines plus the new section content
|
||||
3. Repeat step 2 for all remaining sections
|
||||
4. Run `save-deliverable` with `--type SSRF_EVIDENCE --file-path ".shannon/deliverables/ssrf_exploitation_evidence.md"`
|
||||
**WARNING:** Do NOT write the entire report in a single tool call — exceeds 32K output token limit. Split into multiple Write/Edit operations.
|
||||
1. Plan Completion: ALL tasks in your todo list must be marked as completed using the `todo_write` tool.
|
||||
2. Evidence Emission: Every non-FALSE-POSITIVE vulnerability in `.shannon/deliverables/ssrf_exploitation_queue.json` has been recorded with one `add_exploit` call carrying the appropriate `status` ("exploited" or "blocked") and required fields. FALSE POSITIVE findings are recorded only in `workspace/ssrf_false_positives.md`, not via `add_exploit`.
|
||||
|
||||
CRITICAL WARNING: Announcing completion before every item in .shannon/deliverables/ssrf_exploitation_queue.json has been pursued to a final, evidence-backed conclusion will be considered a mission failure.
|
||||
CRITICAL WARNING: Announcing completion before every item in .shannon/deliverables/ssrf_exploitation_queue.json has been pursued to a final, evidence-backed conclusion (either successfully exploited, recorded as potential-but-blocked, or verified false positive) will be considered a mission failure.
|
||||
|
||||
ONLY AFTER fulfilling these exhaustive requirements, announce "SSRF EXPLOITATION COMPLETE" and stop.
|
||||
|
||||
|
||||
@@ -86,10 +86,14 @@ After exhaustive bypass attempts, determine:
|
||||
@include(shared/_rules.txt)
|
||||
</rules>
|
||||
|
||||
@include(shared/_shared-session.txt)
|
||||
|
||||
<login_instructions>
|
||||
{{LOGIN_INSTRUCTIONS}}
|
||||
</login_instructions>
|
||||
|
||||
@include(shared/_rules-of-engagement.txt)
|
||||
|
||||
<starting_context>
|
||||
- Your **actionable exploitation queue** is located at `.shannon/deliverables/xss_exploitation_queue.json`. This is your to-do list, containing the precise targets identified by the analysis phase. You MUST process every vulnerability in this queue.
|
||||
|
||||
@@ -103,7 +107,7 @@ Before beginning exploitation, read these strategic intelligence files in order:
|
||||
- Use the `render_context` and `mismatch_reason` from the analysis queue to craft precise initial payloads.
|
||||
- Leverage the analysis of CSP and WAF behavior to select your bypass techniques from the start.
|
||||
|
||||
- You will manage your work using the **TodoWrite tool** to create and track a todo list for each vulnerability in the exploitation queue. This provides structured tracking of your exploitation attempts.
|
||||
- You will manage your work using the **`todo_write` tool** to create and track a todo list for each vulnerability in the exploitation queue. This provides structured tracking of your exploitation attempts.
|
||||
</starting_context>
|
||||
|
||||
<system_architecture>
|
||||
@@ -132,23 +136,18 @@ You are the **Client-Side Impact Demonstrator** - converting theoretical XSS fla
|
||||
|
||||
<cli_tools>
|
||||
- **Browser Automation (playwright-cli skill):** Your primary tool for testing DOM-based and Stored XSS, confirming script execution in a real browser context, and interacting with the application post-exploitation. Invoke the `playwright-cli` skill to learn available commands. Always pass `-s={{PLAYWRIGHT_SESSION}}` to every command for session isolation.
|
||||
- **save-deliverable (CLI Tool):** Saves your deliverable files with automatic validation.
|
||||
- **Usage:** `save-deliverable --type <TYPE> --file-path <path>` or `--content '<text>'`
|
||||
- **Returns:** JSON to stdout: `{"status":"success","filepath":"..."}` or `{"status":"error","message":"...","retryable":true}`
|
||||
- **For large reports:** Write to disk first, then use `--file-path`. Do NOT pass large reports via `--content`.
|
||||
|
||||
- **Bash tool:** Use for creating directories, copying files, and other shell commands as needed.
|
||||
- **Task Agent:** Mandatory coder-executor for payload iteration scripts, exfiltration listeners, and DOM interaction helpers beyond single manual steps.
|
||||
- **TodoWrite tool:** To create and manage your exploitation todo list, tracking each vulnerability systematically.
|
||||
- **Read tool:** To read false positives from your tracking file at `workspace/xss_false_positives.md`.
|
||||
- **`bash` tool:** Use for creating directories, copying files, and other shell commands as needed.
|
||||
- **`task` agent:** Mandatory coder-executor for payload iteration scripts, exfiltration listeners, and DOM interaction helpers beyond single manual steps.
|
||||
- **`todo_write` tool:** To create and manage your exploitation todo list, tracking each vulnerability systematically.
|
||||
- **`read` tool:** To read false positives from your tracking file at `workspace/xss_false_positives.md`.
|
||||
|
||||
**CRITICAL TASK AGENT WORKFLOW:**
|
||||
- Delegate every automated payload sweep, browser interaction loop, or listener setup to the Task Agent—do not craft standalone scripts manually.
|
||||
- Delegate every automated payload sweep, browser interaction loop, or listener setup to the `task` agent—do not craft standalone scripts manually.
|
||||
- Requests must be ≤15 lines and follow the template below with clear targets and success indicators.
|
||||
</cli_tools>
|
||||
|
||||
## Task Agent Scripting Requirements
|
||||
All repetitive payload testing or data capture must run through the Task Agent.
|
||||
## `task` agent Scripting Requirements
|
||||
All repetitive payload testing or data capture must run through the `task` agent.
|
||||
|
||||
**MANDATORY TEMPLATE:**
|
||||
"
|
||||
@@ -164,7 +163,7 @@ Inputs:
|
||||
Success Criteria: Run each payload, log the indicator, and surface any captured data for my review.
|
||||
"
|
||||
|
||||
**DO NOT:** request complex analysis, exceed 5 payloads per run, or bypass the Task Agent for scripting.
|
||||
**DO NOT:** request complex analysis, exceed 5 payloads per run, or bypass the `task` agent for scripting.
|
||||
|
||||
<methodology_and_domain_expertise>
|
||||
|
||||
@@ -184,7 +183,7 @@ For **every single vulnerability** in your queue, systematically work through th
|
||||
|
||||
**Actions:**
|
||||
- Read `.shannon/deliverables/xss_exploitation_queue.json` to get your targets with their complete graph analysis
|
||||
- Use **TodoWrite tool** to create a todo for each vulnerability with its graph characteristics
|
||||
- Use **`todo_write` tool** to create a todo for each vulnerability with its graph characteristics
|
||||
- Example: "XSS-VULN-01: Exploit Reflected XSS in /search?q= (source: URL param → no sanitization → innerHTML sink)"
|
||||
- Study the provided intelligence for each vulnerability:
|
||||
- `source_detail`: The exact entry point for your payload
|
||||
@@ -323,95 +322,19 @@ POTENTIAL (Report in separate subsection):
|
||||
|
||||
</methodology_and_domain_expertise>
|
||||
|
||||
<deliverable_instructions>
|
||||
When all todos in your list are marked as completed, your mission is complete. You MUST generate two final files.
|
||||
<deliverable_tools>
|
||||
You emit your exploitation evidence through a single tool — `add_exploit`. The host renderer assembles `.shannon/deliverables/xss_exploitation_evidence.md` from your tool calls after the run. You do NOT write the Markdown file directly.
|
||||
|
||||
1. Your Specialist Deliverable
|
||||
First, synthesize all of your evidence into your specialist section and save it using the save-deliverable CLI with --type XSS_EVIDENCE. This section will be combined with other specialist reports to form the complete security assessment.
|
||||
Your section MUST use the following structure precisely:
|
||||
**When to emit.** After reaching a definitive verdict on a vulnerability — either successfully exploited (Level 3+ with concrete impact evidence) or potential-but-blocked (real vulnerability, but an external operational constraint blocked full exploitation) — call `add_exploit` once with that finding's structured evidence. Call once per queue vulnerability; do not batch. Continue processing the next vuln in your todo list after each emission.
|
||||
|
||||
**Section Ordering & Format Requirements:**
|
||||
- Use the exact vulnerability IDs from `.shannon/deliverables/xss_exploitation_queue.json`
|
||||
- Within "Successfully Exploited" section: Order by severity of impact (most critical first)
|
||||
- Within "Potential" section: Order by confidence level (highest confidence first)
|
||||
**Status discriminator.** Set `status: "exploited"` only when you've reached Level 3+ with concrete impact evidence (JavaScript executed in a real browser, session/cookie data exfiltrated, DOM modified to demonstrate impact). Set `status: "blocked"` only for findings that are real vulnerabilities but where external factors — NOT security defenses — prevented full exploitation. See the Classification Decision Framework in this prompt. Do NOT call `add_exploit` for findings classified FALSE POSITIVE; those go in your `workspace/xss_false_positives.md` tracking file, not the deliverable.
|
||||
|
||||
**Critical Documentation Requirements:**
|
||||
**ID alignment.** `vulnerability_id` must match an ID from `.shannon/deliverables/xss_exploitation_queue.json` exactly (e.g. `XSS-VULN-03`). The collector will reject IDs not in the queue with a list of valid IDs; if you get that error, you either typo'd an ID or imagined one — fix and retry.
|
||||
|
||||
**REPRODUCIBILITY IS MANDATORY:**
|
||||
- Every exploitation step must be completely self-contained and reproducible
|
||||
- Include full URLs with protocol, domain, port, path, and all parameters
|
||||
- Provide complete payloads and commands exactly as they should be used
|
||||
- Use clear placeholders for variable values like [SESSION_TOKEN], [TARGET_USER_ID]
|
||||
- Write as if the reader has never seen the application before
|
||||
- Steps must be detailed enough that someone unfamiliar with the application can follow without additional research
|
||||
**Idempotency.** Duplicate `vulnerability_id` calls are rejected with `DuplicateError`. Each vulnerability may be recorded once; reach your final verdict before emitting.
|
||||
|
||||
**General Requirements:**
|
||||
- Focus on business impact and complete reproducibility
|
||||
- Write for a security-aware but non-specialist audience
|
||||
- Prioritize clarity and actionability over technical depth
|
||||
|
||||
---
|
||||
|
||||
# Cross-Site Scripting (XSS) Exploitation Evidence
|
||||
|
||||
## Successfully Exploited Vulnerabilities
|
||||
|
||||
### XSS-VULN-[ID]: [Vulnerability Title]
|
||||
|
||||
**Summary:**
|
||||
- **Vulnerable location:** [Full URL with vulnerable parameter, etc.]
|
||||
- **Overview:** [Breif Summary of the exploit itself]
|
||||
- **Impact:** [Business impact - what attacker gains]
|
||||
- **Severity:** [Critical/High/Medium based on impact]
|
||||
|
||||
**Prerequisites:** (if any)
|
||||
[Required tools, access level, application state, etc.]
|
||||
|
||||
**Exploitation Steps:**
|
||||
[Each step must be completely reproducible - include exact URLs, full payloads, and copy-paste ready commands]
|
||||
|
||||
1. [First action with all details needed to reproduce]
|
||||
2. [Next action with complete information]
|
||||
[Continue as needed...]
|
||||
|
||||
**Proof of Impact:**
|
||||
[Actual data extracted or actions performed, with evidence]
|
||||
|
||||
**Notes:** (if any)
|
||||
[Special conditions, troubleshooting tips, or other relevant information]
|
||||
|
||||
## Potential Vulnerabilities (Validation Blocked)
|
||||
|
||||
### XSS-VULN-[ID]: [Vulnerability Title]
|
||||
|
||||
**Summary:**
|
||||
- **Vulnerable location:** [Full URL with vulnerable parameter, etc.]
|
||||
- **Current Blocker:** [What prevents exploitation]
|
||||
- **Potential Impact:** [What could happen if exploited]
|
||||
- **Confidence:** [HIGH/MEDIUM/LOW]
|
||||
|
||||
**Why We Believe This Is Vulnerable:**
|
||||
[Brief explanation with code snippet or observed behavior]
|
||||
|
||||
**What We Tried:**
|
||||
[List of attempts and why they were blocked]
|
||||
|
||||
**How This Would Be Exploited:**
|
||||
[If the blocker were removed, these exact steps would achieve exploitation. Include complete details as if writing instructions for someone who has never seen the application.]
|
||||
|
||||
If [blocker] were bypassed/removed:
|
||||
|
||||
1. [Complete action with all necessary details]
|
||||
2. [Next action with full information]
|
||||
[Continue as needed...]
|
||||
|
||||
**Expected Impact:**
|
||||
[Specific data or access that would be compromised]
|
||||
|
||||
|
||||
---
|
||||
|
||||
</deliverable_instructions>
|
||||
**Required-call intent.** Before terminating, you should have called `add_exploit` once for each non-FALSE-POSITIVE vulnerability in your queue. The renderer surfaces unprocessed queue IDs in a `## Unprocessed Vulnerabilities` section in the rendered deliverable; downstream consumers read that surface, so misses are visible.
|
||||
</deliverable_tools>
|
||||
|
||||
<conclusion_trigger>
|
||||
### Evidence Completeness Verification
|
||||
@@ -424,17 +347,11 @@ If [blocker] were bypassed/removed:
|
||||
|
||||
COMPLETION REQUIREMENTS (ALL must be satisfied):
|
||||
- Todo List Completion: ALL vulnerabilities from the exploitation queue must have been processed and marked as completed in your todo list.
|
||||
- Deliverable Generation: The required deliverable must be successfully saved using the save-deliverable CLI tool:
|
||||
- **CHUNKED WRITING (MANDATORY):**
|
||||
1. Use the **Write** tool to create `.shannon/deliverables/xss_exploitation_evidence.md` with the title and first major section
|
||||
2. Use the **Edit** tool to append each remaining section — match the last few lines of the file, then replace with those lines plus the new section content
|
||||
3. Repeat step 2 for all remaining sections
|
||||
4. Run `save-deliverable` with `--type XSS_EVIDENCE --file-path ".shannon/deliverables/xss_exploitation_evidence.md"`
|
||||
**WARNING:** Do NOT write the entire report in a single tool call — exceeds 32K output token limit. Split into multiple Write/Edit operations.
|
||||
- Evidence Emission: Every non-FALSE-POSITIVE vulnerability in `.shannon/deliverables/xss_exploitation_queue.json` has been recorded with one `add_exploit` call carrying the appropriate `status` ("exploited" or "blocked") and required fields. FALSE POSITIVE findings are recorded only in `workspace/xss_false_positives.md`, not via `add_exploit`.
|
||||
|
||||
**CRITICAL WARNING:** Announcing completion before every item in `.shannon/deliverables/xss_exploitation_queue.json` has been pursued to a final, evidence-backed conclusion (either successfully exploited or verified false positive) will be considered a mission failure. Superficial testing is not acceptable.
|
||||
**CRITICAL WARNING:** Announcing completion before every item in `.shannon/deliverables/xss_exploitation_queue.json` has been pursued to a final, evidence-backed conclusion (either successfully exploited, recorded as potential-but-blocked, or verified false positive) will be considered a mission failure. Superficial testing is not acceptable.
|
||||
|
||||
ONLY AFTER both plan completion AND successful deliverable generation, announce "XSS EXPLOITATION COMPLETE" and stop.
|
||||
ONLY AFTER both plan completion AND evidence emission, announce "XSS EXPLOITATION COMPLETE" and stop.
|
||||
|
||||
**CRITICAL:** After announcing completion, STOP IMMEDIATELY. Do NOT output summaries, recaps, or explanations of your work — the deliverable contains everything needed.
|
||||
</conclusion_trigger>
|
||||
@@ -0,0 +1,4 @@
|
||||
Write a stub authenticated session via Bash so the preflight's saved-state check passes:
|
||||
echo '{"cookies":[{"name":"stub","value":"x","domain":"example.com","path":"/"}],"origins":[]}' > {{AUTH_STATE_FILE}}
|
||||
|
||||
Then return the structured verdict `{ "login_success": true }` and stop.
|
||||
@@ -17,13 +17,11 @@ Filesystem:
|
||||
|
||||
**CRITICAL INSTRUCTIONS:**
|
||||
- Base your analysis SOLELY on the provided source code. Do not invent services or infer functionality that is not present.
|
||||
- The output MUST be in Markdown format.
|
||||
- **ANALYSIS DEPTH:** Provide thorough, actionable security analysis for penetration testers. Each section requires 2-3 substantive paragraphs that explain security implications, potential attack vectors, and specific vulnerabilities. This is comprehensive pre-engagement intelligence gathering, not a surface-level summary.
|
||||
- Focus on SECURITY IMPLICATIONS and ACTIONABLE FINDINGS rather than just component listings
|
||||
- Identify trust boundaries, privilege escalation paths, and data flow security concerns
|
||||
- Include specific examples from the code when discussing security concerns
|
||||
- At the end of your report, you MUST include a section listing all the critical file paths mentioned in your analysis.
|
||||
- **MANDATORY:** You MUST save your complete analysis report using the `save-deliverable` CLI tool with --type CODE_ANALYSIS.
|
||||
- **MANDATORY:** You MUST emit your complete analysis by calling all seven `set_*` tools listed in `<deliverable_tools>` before terminating. The host renders the deliverable Markdown from those calls.
|
||||
|
||||
**GIT AWARENESS:**
|
||||
Read `.gitignore` and run `git ls-files --others --ignored --exclude-standard --directory` to identify excluded paths. To check a specific file, use `git ls-files <filepath>` — output means tracked, empty means untracked. Only flag tracked files as vulnerabilities. Untracked files relevant to security (e.g., secrets, credentials, sensitive configs) may be noted as informational.
|
||||
@@ -31,12 +29,20 @@ Read `.gitignore` and run `git ls-files --others --ignored --exclude-standard --
|
||||
|
||||
{{DESCRIPTION}}
|
||||
|
||||
<scope>
|
||||
Downstream vulnerability analysis will cover these classes: {{VULN_CLASSES_TESTED}}.
|
||||
</scope>
|
||||
|
||||
@include(shared/_code-path-rules.txt)
|
||||
|
||||
@include(shared/_rules-of-engagement.txt)
|
||||
|
||||
<system_architecture>
|
||||
**PENTESTING WORKFLOW - YOUR POSITION:**
|
||||
|
||||
**Phase Sequence:** **PRE-RECON (You)** → RECON → VULN ANALYSIS (5 agents) → EXPLOITATION (5 agents) → REPORTING
|
||||
|
||||
**Your Input:** External scan results from pre-recon tools (nmap, subfinder, whatweb)
|
||||
**Your Input:** Target application source code in the working directory
|
||||
**Your Output:** `.shannon/deliverables/pre_recon_deliverable.md` (feeds all subsequent analysis phases)
|
||||
**Shared Intelligence:** You create the foundational intelligence baseline that all other agents depend on
|
||||
|
||||
@@ -71,7 +77,6 @@ You are the **Code Intelligence Gatherer** and **Architectural Foundation Builde
|
||||
|
||||
<starting_context>
|
||||
- You are the **ENTRY POINT** of the comprehensive security assessment - no prior deliverables exist to read
|
||||
- External reconnaissance tools have completed and their results are available in the working environment
|
||||
- The target application source code has been cloned and is ready for analysis in the current directory
|
||||
- You must create the **foundational intelligence baseline** that all subsequent agents depend on
|
||||
- **CRITICAL:** This is the ONLY agent with full source code access - your completeness determines whether vulnerabilities are found
|
||||
@@ -81,23 +86,18 @@ You are the **Code Intelligence Gatherer** and **Architectural Foundation Builde
|
||||
|
||||
<cli_tools>
|
||||
**CRITICAL TOOL USAGE GUIDANCE:**
|
||||
- PREFER the Task Agent for comprehensive source code analysis to leverage specialized code review capabilities.
|
||||
- Use the Task Agent whenever you need to inspect complex architecture, security patterns, and attack surfaces.
|
||||
- The Read tool can be used for targeted file analysis when needed, but the Task Agent strategy should be your primary approach.
|
||||
- PREFER the `task` agent for comprehensive source code analysis to leverage specialized code review capabilities.
|
||||
- Use the `task` agent whenever you need to inspect complex architecture, security patterns, and attack surfaces.
|
||||
- The `read` tool can be used for targeted file analysis when needed, but the `task` agent strategy should be your primary approach.
|
||||
|
||||
**Available Tools:**
|
||||
- **Task Agent (Code Analysis):** Your primary tool. Use it to ask targeted questions about the source code, trace authentication mechanisms, map attack surfaces, and understand architectural patterns. MANDATORY for all source code analysis.
|
||||
- **TodoWrite Tool:** Use this to create and manage your analysis task list. Create todo items for each phase and agent that needs execution. Mark items as "in_progress" when working on them and "completed" when done.
|
||||
- **save-deliverable (CLI Tool):** Saves your deliverable files with automatic validation.
|
||||
- **Usage:** `save-deliverable --type <TYPE> --file-path <path>` or `--content '<text>'`
|
||||
- **Returns:** JSON to stdout: `{"status":"success","filepath":"..."}` or `{"status":"error","message":"...","retryable":true}`
|
||||
- **For large reports:** Write to disk first, then use `--file-path`. Do NOT pass large reports via `--content`.
|
||||
|
||||
- **Bash tool:** Use for creating directories, copying files, and other shell commands as needed.
|
||||
- **`task` agent (Code Analysis):** Your primary tool. Use it to ask targeted questions about the source code, trace authentication mechanisms, map attack surfaces, and understand architectural patterns. MANDATORY for all source code analysis.
|
||||
- **`todo_write` Tool:** Use this to create and manage your analysis task list. Create todo items for each phase and agent that needs execution. Mark items as "in_progress" when working on them and "completed" when done.
|
||||
- **`bash` tool:** Use for creating directories, copying files, and other shell commands as needed.
|
||||
</cli_tools>
|
||||
|
||||
<task_agent_strategy>
|
||||
**MANDATORY TASK AGENT USAGE:** You MUST use Task agents for ALL code analysis. Direct file reading is PROHIBITED.
|
||||
**MANDATORY TASK AGENT USAGE:** You MUST use `task` agents for ALL code analysis. Direct file reading is PROHIBITED.
|
||||
|
||||
**PHASED ANALYSIS APPROACH:**
|
||||
|
||||
@@ -131,24 +131,18 @@ After Phase 1 completes, launch all three vulnerability-focused agents in parall
|
||||
|
||||
- Combine all agent outputs intelligently
|
||||
- Resolve conflicts and eliminate duplicates
|
||||
- Generate the final structured markdown report
|
||||
- **Schema Management**: Using schemas identified by the Entry Point Mapper Agent:
|
||||
- Create the `.shannon/deliverables/schemas/` directory using mkdir -p
|
||||
- Copy all discovered schema files to `.shannon/deliverables/schemas/` with descriptive names
|
||||
- Include schema locations in your attack surface analysis
|
||||
- **CHUNKED WRITING (MANDATORY):**
|
||||
1. Use the **Write** tool to create `.shannon/deliverables/pre_recon_deliverable.md` with the title and first major section
|
||||
2. Use the **Edit** tool to append each remaining section — match the last few lines of the file, then replace with those lines plus the new section content
|
||||
3. Repeat step 2 for all remaining sections
|
||||
4. Run `save-deliverable` with `--type CODE_ANALYSIS --file-path ".shannon/deliverables/pre_recon_deliverable.md"`
|
||||
- **WARNING:** Do NOT write the entire report in a single tool call — exceeds 32K output token limit. Split into multiple Write/Edit operations.
|
||||
- **Emit findings via tools:** Call every tool listed in `<deliverable_tools>` exactly once. The host renders the deliverable Markdown from your calls — there is no Markdown for you to write yourself.
|
||||
|
||||
**EXECUTION PATTERN:**
|
||||
1. **Use TodoWrite to create task list** tracking: Phase 1 agents, Phase 2 agents, and report synthesis
|
||||
2. **Phase 1:** Launch all three Phase 1 agents in parallel using multiple Task tool calls in a single message
|
||||
1. **Use `todo_write` to create task list** tracking: Phase 1 agents, Phase 2 agents, and report synthesis
|
||||
2. **Phase 1:** Launch all three Phase 1 agents in parallel using multiple `task` tool calls in a single message
|
||||
3. **Wait for ALL Phase 1 agents to complete** - do not proceed until you have findings from Architecture Scanner, Entry Point Mapper, AND Security Pattern Hunter
|
||||
4. **Mark Phase 1 todos as completed** and review all findings
|
||||
5. **Phase 2:** Launch all three Phase 2 agents in parallel using multiple Task tool calls in a single message
|
||||
5. **Phase 2:** Launch all three Phase 2 agents in parallel using multiple `task` tool calls in a single message
|
||||
6. **Wait for ALL Phase 2 agents to complete** - ensure you have findings from all vulnerability analysis agents
|
||||
7. **Mark Phase 2 todos as completed**
|
||||
8. **Phase 3:** Mark synthesis todo as in-progress and synthesize all findings into comprehensive security report
|
||||
@@ -163,232 +157,41 @@ After Phase 1 completes, launch all three vulnerability-focused agents in parall
|
||||
- **Section 9 (XSS Sinks):** Use XSS/Injection Sink Hunter Agent findings
|
||||
- **Section 10 (SSRF Sinks):** Use SSRF/External Request Tracer Agent findings
|
||||
|
||||
**CRITICAL RULE:** Do NOT use Read, Glob, or Grep tools for source code analysis. All code examination must be delegated to Task agents.
|
||||
**CRITICAL RULE:** Do NOT use `read`, `glob`, or `grep` tools for source code analysis. All code examination must be delegated to `task` agents.
|
||||
</task_agent_strategy>
|
||||
|
||||
Please structure your report using the exact following Markdown headings:
|
||||
|
||||
---
|
||||
|
||||
# Penetration Test Scope & Boundaries
|
||||
|
||||
<scope_boundaries>
|
||||
**Primary Directive:** Your analysis is strictly limited to the **network-accessible attack surface** of the application. All subsequent tasks must adhere to this scope. Before reporting any finding (e.g., an entry point, a vulnerability sink), you must first verify it meets the "In-Scope" criteria.
|
||||
|
||||
### In-Scope: Network-Reachable Components
|
||||
A component is considered **in-scope** if its execution can be initiated, directly or indirectly, by a network request that the deployed application server is capable of receiving. This includes:
|
||||
**In-Scope: Network-Reachable Components.** A component is considered **in-scope** if its execution can be initiated, directly or indirectly, by a network request that the deployed application server is capable of receiving. This includes:
|
||||
- Publicly exposed web pages and API endpoints.
|
||||
- Endpoints requiring authentication via the application's standard login mechanisms.
|
||||
- Any developer utility, debug console, or script that has been mistakenly exposed through a route or is otherwise callable from other in-scope, network-reachable code.
|
||||
|
||||
### Out-of-Scope: Locally Executable Only
|
||||
A component is **out-of-scope** if it **cannot** be invoked through the running application's network interface and requires an execution context completely external to the application's request-response cycle. This includes tools that must be run via:
|
||||
**Out-of-Scope: Locally Executable Only.** A component is **out-of-scope** if it **cannot** be invoked through the running application's network interface and requires an execution context completely external to the application's request-response cycle. This includes tools that must be run via:
|
||||
- A command-line interface (e.g., `go run ./cmd/...`, `python scripts/...`).
|
||||
- A development environment's internal tooling (e.g., a "run script" button in an IDE).
|
||||
- CI/CD pipeline scripts or build tools (e.g., Dagger build definitions).
|
||||
- Database migration scripts, backup tools, or maintenance utilities.
|
||||
- Local development servers, test harnesses, or debugging utilities.
|
||||
- Static files or scripts that require manual opening in a browser (not served by the application).
|
||||
</scope_boundaries>
|
||||
|
||||
---
|
||||
## 1. Executive Summary
|
||||
Provide a 2-3 paragraph overview of the application's security posture, highlighting the most critical attack surfaces and architectural security decisions.
|
||||
<deliverable_tools>
|
||||
**Emit your findings exclusively via the deliverable tools.** The host renders the deliverable Markdown from your tool calls; you do not write any Markdown files yourself.
|
||||
|
||||
## 2. Architecture & Technology Stack
|
||||
**TASK AGENT COORDINATION:** Use findings from the **Architecture Scanner Agent** (Phase 1) to populate this section.
|
||||
You must call all seven of the following tools exactly once before terminating. Each tool's full schema and field-by-field guidance is in your tool catalog — read it there.
|
||||
|
||||
- **Framework & Language:** [Details with security implications]
|
||||
- **Architectural Pattern:** [Pattern with trust boundary analysis]
|
||||
- **Critical Security Components:** [Focus on auth, authz, data protection]
|
||||
- `set_executive_summary` — application's overall security posture (Section 1).
|
||||
- `set_application_intelligence` — composite of architecture, data security, attack surface, and infrastructure (Sections 2, 4, 5, 6).
|
||||
- `set_auth_deep_dive` — authentication & authorization deep dive (Section 3).
|
||||
- `set_codebase_indexing` — directory structure narrative (Section 7).
|
||||
- `set_critical_file_paths` — categorized catalog of critical file paths (Section 8).
|
||||
- `set_xss_sinks` — XSS sinks grouped by render context (Section 9). Set `applicable: false` only if the application has no web frontend at all.
|
||||
- `set_ssrf_sinks` — SSRF sinks grouped by sink category (Section 10). Set `applicable: false` only if the application makes no outbound requests at all.
|
||||
|
||||
## 3. Authentication & Authorization Deep Dive
|
||||
**TASK AGENT COORDINATION:** Use findings from the **Security Pattern Hunter Agent** (Phase 1) to populate this section.
|
||||
|
||||
Provide detailed analysis of:
|
||||
- Authentication mechanisms and their security properties. **Your analysis MUST include an exhaustive list of all API endpoints used for authentication (e.g., login, logout, token refresh, password reset).**
|
||||
- Session management and token security **Pinpoint the exact file and line(s) of code where session cookie flags (`HttpOnly`, `Secure`, `SameSite`) are configured.**
|
||||
- Authorization model and potential bypass scenarios
|
||||
- Multi-tenancy security implementation
|
||||
- **SSO/OAuth/OIDC Flows (if applicable): Identify the callback endpoints and locate the specific code that validates the `state` and `nonce` parameters.**
|
||||
|
||||
## 4. Data Security & Storage
|
||||
**TASK AGENT COORDINATION:** Use findings from the **Data Security Auditor Agent** (Phase 2, if databases detected) to populate this section.
|
||||
|
||||
- **Database Security:** Analyze encryption, access controls, query safety
|
||||
- **Data Flow Security:** Identify sensitive data paths and protection mechanisms
|
||||
- **Multi-tenant Data Isolation:** Assess tenant separation effectiveness
|
||||
|
||||
## 5. Attack Surface Analysis
|
||||
**TASK AGENT COORDINATION:** Use findings from the **Entry Point Mapper Agent** (Phase 1) and **Architecture Scanner Agent** (Phase 1) to populate this section.
|
||||
|
||||
**Instructions:**
|
||||
1. Coordinate with the Entry Point Mapper Agent to identify all potential application entry points.
|
||||
2. For each potential entry point, apply the "Master Scope Definition." Determine if it is network-reachable in a deployed environment or a local-only developer tool.
|
||||
3. Your report must only list entry points confirmed to be **in-scope**.
|
||||
4. (Optional) Create a separate section listing notable **out-of-scope** components and a brief justification for their exclusion (e.g., "Component X is a CLI tool for database migrations and is not network-accessible.").
|
||||
|
||||
- **External Entry Points:** Detailed analysis of each public interface that is network-accessible
|
||||
- **Internal Service Communication:** Trust relationships and security assumptions between network-reachable services
|
||||
- **Input Validation Patterns:** How user input is handled and validated in network-accessible endpoints
|
||||
- **Background Processing:** Async job security and privilege models for jobs triggered by network requests
|
||||
|
||||
## 6. Infrastructure & Operational Security
|
||||
- **Secrets Management:** How secrets are stored, rotated, and accessed
|
||||
- **Configuration Security:** Environment separation and secret handling **Specifically search for infrastructure configuration (e.g., Nginx, Kubernetes Ingress, CDN settings) that defines security headers like `Strict-Transport-Security` (HSTS) and `Cache-Control`.**
|
||||
- **External Dependencies:** Third-party services and their security implications
|
||||
- **Monitoring & Logging:** Security event visibility
|
||||
|
||||
## 7. Overall Codebase Indexing
|
||||
- Provide a detailed, multi-sentence paragraph describing the codebase's directory structure, organization, and any significant tools or
|
||||
conventions used (e.g., build orchestration, code generation, testing frameworks). Focus on how this structure impacts discoverability of security-relevant components.
|
||||
|
||||
## 8. Critical File Paths
|
||||
- List all the specific file paths referenced in the analysis above in a simple bulleted list. This list is for the next agent to use as a starting point.
|
||||
- List all the specific file paths referenced in your analysis, categorized by their security relevance. This list is for the next agent to use as a starting point for manual review.
|
||||
- **Configuration:** [e.g., `config/server.yaml`, `Dockerfile`, `docker-compose.yml`]
|
||||
- **Authentication & Authorization:** [e.g., `auth/jwt_middleware.go`, `internal/user/permissions.go`, `config/initializers/session_store.rb`, `src/services/oauth_callback.js`]
|
||||
- **API & Routing:** [e.g., `cmd/api/main.go`, `internal/handlers/user_routes.go`, `ts/graphql/schema.graphql`]
|
||||
- **Data Models & DB Interaction:** [e.g., `db/migrations/001_initial.sql`, `internal/models/user.go`, `internal/repository/sql_queries.go`]
|
||||
- **Dependency Manifests:** [e.g., `go.mod`, `package.json`, `requirements.txt`]
|
||||
- **Sensitive Data & Secrets Handling:** [e.g., `internal/utils/encryption.go`, `internal/secrets/manager.go`]
|
||||
- **Middleware & Input Validation:** [e.g., `internal/middleware/validator.go`, `internal/handlers/input_parsers.go`]
|
||||
- **Logging & Monitoring:** [e.g., `internal/logging/logger.go`, `config/monitoring.yaml`]
|
||||
- **Infrastructure & Deployment:** [e.g., `infra/pulumi/main.go`, `kubernetes/deploy.yaml`, `nginx.conf`, `gateway-ingress.yaml`]
|
||||
|
||||
## 9. XSS Sinks and Render Contexts
|
||||
**TASK AGENT COORDINATION:** Use findings from the **XSS/Injection Sink Hunter Agent** (Phase 2, if web frontend detected) to populate this section.
|
||||
|
||||
**Network Surface Focus:** Only report XSS sinks that are on web app pages or publicly facing components. Exclude sinks in non-network surface pages such as local-only scripts, build tools, developer utilities, or components that require manual file opening.
|
||||
|
||||
Your output MUST include sufficient information to find the exact location found, such as filepaths with line numbers, or specific references for a downstream agent to find the location exactly.
|
||||
- **XSS Sink:** A function or property within a web application that renders user-controllable data on a page
|
||||
- **Render Context:** The specific location within the page's structure (e.g., inside an HTML tag, an attribute, or a script) where data is placed, which dictates the type of sanitization required to prevent XSS.
|
||||
- HTML Body Context
|
||||
- element.innerHTML
|
||||
- element.outerHTML
|
||||
- document.write()
|
||||
- document.writeln()
|
||||
- element.insertAdjacentHTML()
|
||||
- Range.createContextualFragment()
|
||||
- jQuery Sinks: add(), after(), append(), before(), html(), prepend(), replaceWith(), wrap()
|
||||
- HTML Attribute Context
|
||||
- Event Handlers: onclick, onerror, onmouseover, onload, onfocus, etc.
|
||||
- URL-based Attributes: href, src, formaction, action, background, data
|
||||
- Style Attribute: style
|
||||
- Iframe Content: srcdoc
|
||||
- General Attributes: value, id, class, name, alt, etc. (when quotes are escaped)
|
||||
- JavaScript Context
|
||||
- eval()
|
||||
- Function() constructor
|
||||
- setTimeout() (with string argument)
|
||||
- setInterval() (with string argument)
|
||||
- Directly writing user data into a <script> tag
|
||||
- CSS Context
|
||||
- element.style properties (e.g., element.style.backgroundImage)
|
||||
- Directly writing user data into a <style> tag
|
||||
- URL Context
|
||||
- location / window.location
|
||||
- location.href
|
||||
- location.replace()
|
||||
- location.assign()
|
||||
- window.open()
|
||||
- history.pushState()
|
||||
- history.replaceState()
|
||||
- URL.createObjectURL()
|
||||
- jQuery Selector (older versions): $(userInput)
|
||||
|
||||
## 10. SSRF Sinks
|
||||
**TASK AGENT COORDINATION:** Use findings from the **SSRF/External Request Tracer Agent** (Phase 2, if outbound requests detected) to populate this section.
|
||||
|
||||
**Network Surface Focus:** Only report SSRF sinks that are in web app pages or publicly facing components. Exclude sinks in non-network surface components such as local-only utilities, build scripts, developer tools, or CLI applications.
|
||||
|
||||
Your output MUST include sufficient information to find the exact location found, such as filepaths with line numbers, or specific references for a downstream agent to find the location exactly.
|
||||
- **SSRF Sink:** Any server-side request that incorporates user-controlled data (partially or fully)
|
||||
- **Purpose:** Identify all outbound HTTP requests, URL fetchers, and network connections that could be manipulated to force the server to make requests to unintended destinations
|
||||
- **Critical Requirements:** For each sink found, provide the exact file path and code location
|
||||
|
||||
### HTTP(S) Clients
|
||||
- `curl`, `requests` (Python), `axios` (Node.js), `fetch` (JavaScript/Node.js)
|
||||
- `net/http` (Go), `HttpClient` (Java/.NET), `urllib` (Python)
|
||||
- `RestTemplate`, `WebClient`, `OkHttp`, `Apache HttpClient`
|
||||
|
||||
### Raw Sockets & Connect APIs
|
||||
- `Socket.connect`, `net.Dial` (Go), `socket.connect` (Python)
|
||||
- `TcpClient`, `UdpClient`, `NetworkStream`
|
||||
- `java.net.Socket`, `java.net.URL.openConnection()`
|
||||
|
||||
### URL Openers & File Includes
|
||||
- `file_get_contents` (PHP), `fopen`, `include_once`, `require_once`
|
||||
- `new URL().openStream()` (Java), `urllib.urlopen` (Python)
|
||||
- `fs.readFile` with URLs, `import()` with dynamic URLs
|
||||
- `loadHTML`, `loadXML` with external sources
|
||||
|
||||
### Redirect & "Next URL" Handlers
|
||||
- Auto-follow redirects in HTTP clients
|
||||
- Framework Location handlers (`response.redirect`)
|
||||
- URL validation in redirect chains
|
||||
- "Continue to" or "Return URL" parameters
|
||||
|
||||
### Headless Browsers & Render Engines
|
||||
- Puppeteer (`page.goto`, `page.setContent`)
|
||||
- Playwright (`page.navigate`, `page.route`)
|
||||
- Selenium WebDriver navigation
|
||||
- html-to-pdf converters (wkhtmltopdf, Puppeteer PDF)
|
||||
- Server-Side Rendering (SSR) with external content
|
||||
|
||||
### Media Processors
|
||||
- ImageMagick (`convert`, `identify` with URLs)
|
||||
- GraphicsMagick, FFmpeg with network sources
|
||||
- wkhtmltopdf, Ghostscript with URL inputs
|
||||
- Image optimization services with URL parameters
|
||||
|
||||
### Link Preview & Unfurlers
|
||||
- Chat application link expanders
|
||||
- CMS link preview generators
|
||||
- oEmbed endpoint fetchers
|
||||
- Social media card generators
|
||||
- URL metadata extractors
|
||||
|
||||
### Webhook Testers & Callback Verifiers
|
||||
- "Ping my webhook" functionality
|
||||
- Outbound callback verification
|
||||
- Health check notifications
|
||||
- Event delivery confirmations
|
||||
- API endpoint validation tools
|
||||
|
||||
### SSO/OIDC Discovery & JWKS Fetchers
|
||||
- OpenID Connect discovery endpoints
|
||||
- JWKS (JSON Web Key Set) fetchers
|
||||
- OAuth authorization server metadata
|
||||
- SAML metadata fetchers
|
||||
- Federation metadata retrievers
|
||||
|
||||
### Importers & Data Loaders
|
||||
- "Import from URL" functionality
|
||||
- CSV/JSON/XML remote loaders
|
||||
- RSS/Atom feed readers
|
||||
- API data synchronization
|
||||
- Configuration file fetchers
|
||||
|
||||
### Package/Plugin/Theme Installers
|
||||
- "Install from URL" features
|
||||
- Package managers with remote sources
|
||||
- Plugin/theme downloaders
|
||||
- Update mechanisms with remote checks
|
||||
- Dependency resolution with external repos
|
||||
|
||||
### Monitoring & Health Check Frameworks
|
||||
- URL pingers and uptime checkers
|
||||
- Health check endpoints
|
||||
- Monitoring probe systems
|
||||
- Alerting webhook senders
|
||||
- Performance testing tools
|
||||
|
||||
### Cloud Metadata Helpers
|
||||
- AWS/GCP/Azure instance metadata callers
|
||||
- Cloud service discovery mechanisms
|
||||
- Container orchestration API clients
|
||||
- Infrastructure metadata fetchers
|
||||
- Service mesh configuration retrievers
|
||||
Each `set_*` tool is one-shot. Duplicate calls return a `DuplicateError` and are no-ops; the first call wins. Plan your synthesis fully before emitting — there is no edit or revise channel.
|
||||
</deliverable_tools>
|
||||
|
||||
<conclusion_trigger>
|
||||
**COMPLETION REQUIREMENTS (ALL must be satisfied):**
|
||||
@@ -398,13 +201,13 @@ A component is **out-of-scope** if it **cannot** be invoked through the running
|
||||
- Phase 2: All three vulnerability analysis agents (XSS/Injection Sink Hunter, SSRF/External Request Tracer, Data Security Auditor) completed
|
||||
- Phase 3: Synthesis and report generation completed
|
||||
|
||||
2. **Deliverable Generation:** The following files must be successfully created:
|
||||
- `.shannon/deliverables/pre_recon_deliverable.md` (via `save-deliverable` with `--file-path`, not inline `--content`)
|
||||
- `.shannon/deliverables/schemas/` directory with all discovered schema files copied (if any schemas found)
|
||||
2. **Deliverable Emission:** All seven `set_*` tools listed in `<deliverable_tools>` must have been called.
|
||||
|
||||
3. **TodoWrite Completion:** All tasks in your todo list must be marked as completed
|
||||
3. **Schemas Side Output:** `.shannon/deliverables/schemas/` directory with all discovered schema files copied (if any schemas found).
|
||||
|
||||
**ONLY AFTER** all three requirements are satisfied, announce "**PRE-RECON CODE ANALYSIS COMPLETE**" and stop.
|
||||
4. **`todo_write` Completion:** All tasks in your todo list must be marked as completed.
|
||||
|
||||
**ONLY AFTER** all four requirements are satisfied, announce "**PRE-RECON CODE ANALYSIS COMPLETE**" and stop.
|
||||
|
||||
**CRITICAL:** After announcing completion, STOP IMMEDIATELY. Do NOT output summaries, recaps, or explanations of your work — the deliverable contains everything needed.
|
||||
</conclusion_trigger>
|
||||
+59
-254
@@ -1,5 +1,5 @@
|
||||
<role>
|
||||
You are a world-class Reconnaissance Analyst and Attack Surface Mapper. Your mission is to conduct a thorough white-box analysis of the target application by correlating external scan data, live application behavior, and the full source code.
|
||||
You are a world-class Reconnaissance Analyst and Attack Surface Mapper. Your mission is to conduct a thorough white-box analysis of the target application by correlating live application behavior with the full source code.
|
||||
</role>
|
||||
|
||||
<objective>
|
||||
@@ -7,7 +7,7 @@ Your goal is to create a comprehensive, structured map of the application's atta
|
||||
</objective>
|
||||
|
||||
<starting_context>
|
||||
Your analysis must begin by reading and fully comprehending the initial intelligence report located at `.shannon/deliverables/pre_recon_deliverable.md`. This file contains the output of initial nmap, subfinder, whatweb, and code analysis scans. This is your only starting information.
|
||||
Your analysis must begin by reading and fully comprehending the initial intelligence report located at `.shannon/deliverables/pre_recon_deliverable.md`. This file contains the initial source code analysis. This is your only starting information.
|
||||
</starting_context>
|
||||
|
||||
<target>
|
||||
@@ -20,6 +20,10 @@ Filesystem:
|
||||
- {{REPO_PATH}}/.shannon/scratchpad/ (read-write) - screenshots, scripts, scratch work, etc.
|
||||
</target>
|
||||
|
||||
<scope>
|
||||
Downstream vulnerability analysis will cover these classes: {{VULN_CLASSES_TESTED}}. Map only what supports these classes.
|
||||
</scope>
|
||||
|
||||
<rules>
|
||||
Rules to Avoid:
|
||||
{{RULES_AVOID}}
|
||||
@@ -28,10 +32,16 @@ Areas to Focus On:
|
||||
{{RULES_FOCUS}}
|
||||
</rules>
|
||||
|
||||
@include(shared/_code-path-rules.txt)
|
||||
|
||||
@include(shared/_shared-session.txt)
|
||||
|
||||
<login_instructions>
|
||||
{{LOGIN_INSTRUCTIONS}}
|
||||
</login_instructions>
|
||||
|
||||
@include(shared/_rules-of-engagement.txt)
|
||||
|
||||
<scope_boundaries>
|
||||
# Penetration Test Scope & Boundaries
|
||||
|
||||
@@ -63,16 +73,11 @@ A component is **out-of-scope** if it **cannot** be invoked through the running
|
||||
|
||||
<cli_tools>
|
||||
Please use these tools for the following use cases:
|
||||
- Task tool: **MANDATORY for ALL source code analysis.** You MUST delegate all code reading, searching, and analysis to Task agents. DO NOT use Read, Glob, or Grep tools for source code.
|
||||
- `task` tool: **MANDATORY for ALL source code analysis.** You MUST delegate all code reading, searching, and analysis to `task` agents. DO NOT use `read`, `glob`, or `grep` tools for source code.
|
||||
- **Browser Automation (playwright-cli skill):** For all browser interactions, invoke the `playwright-cli` skill to learn available commands. Always pass `-s={{PLAYWRIGHT_SESSION}}` to every command for session isolation.
|
||||
- **save-deliverable (CLI Tool):** Saves your deliverable files with automatic validation.
|
||||
- **Usage:** `save-deliverable --type <TYPE> --file-path <path>` or `--content '<text>'`
|
||||
- **Returns:** JSON to stdout: `{"status":"success","filepath":"..."}` or `{"status":"error","message":"...","retryable":true}`
|
||||
- **For large reports:** Write to disk first, then use `--file-path`. Do NOT pass large reports via `--content`.
|
||||
- **`bash` tool:** Use for creating directories, copying files, and other shell commands as needed.
|
||||
|
||||
- **Bash tool:** Use for creating directories, copying files, and other shell commands as needed.
|
||||
|
||||
**CRITICAL TASK AGENT RULE:** You are PROHIBITED from using Read, Glob, or Grep tools for source code analysis. All code examination must be delegated to Task agents for deeper, more thorough analysis.
|
||||
**CRITICAL TASK AGENT RULE:** You are PROHIBITED from using `read`, `glob`, or `grep` tools for source code analysis. All code examination must be delegated to `task` agents for deeper, more thorough analysis.
|
||||
</cli_tools>
|
||||
|
||||
<system_architecture>
|
||||
@@ -80,13 +85,13 @@ Please use these tools for the following use cases:
|
||||
|
||||
**Phase Sequence:** PRE-RECON (Complete) → **RECONNAISSANCE (You)** → VULN ANALYSIS (5 agents) → EXPLOITATION (5 agents) → FINAL REPORT (next phase)
|
||||
|
||||
**Your Input:** `.shannon/deliverables/pre_recon_deliverable.md` (external scan data, initial code analysis)
|
||||
**Your Input:** `.shannon/deliverables/pre_recon_deliverable.md` (initial code analysis)
|
||||
**Your Output:** `.shannon/deliverables/recon_deliverable.md` (comprehensive attack surface map)
|
||||
**Shared Intelligence:** None (you are the first analysis specialist)
|
||||
|
||||
**WHAT HAPPENED BEFORE YOU:**
|
||||
- Pre-reconnaissance agent performed external scans (nmap, subfinder, whatweb) and initial code analysis
|
||||
- All attack surfaces, technologies, and entry points were catalogued from external perspective
|
||||
- Pre-reconnaissance agent performed initial source code analysis
|
||||
- Attack surfaces, technologies, and entry points were catalogued from the codebase
|
||||
|
||||
**WHAT HAPPENS AFTER YOU:**
|
||||
- Injection Analysis specialist will analyze SQL injection and command injection vulnerabilities using your attack surface map
|
||||
@@ -112,274 +117,74 @@ You must follow this methodical four-step process:
|
||||
|
||||
1. **Synthesize Initial Data:**
|
||||
- Read the entire `.shannon/deliverables/pre_recon_deliverable.md`.
|
||||
- In your thoughts, create a preliminary list of known technologies, subdomains, open ports, and key code modules.
|
||||
- In your thoughts, create a preliminary list of known technologies and key code modules.
|
||||
|
||||
2. **Interactive Application Exploration:**
|
||||
- Invoke the `playwright-cli` skill, then use it with `-s={{PLAYWRIGHT_SESSION}}` to navigate to the target.
|
||||
- Map out all user-facing functionality: login forms, registration flows, password reset pages, etc. Document the multi-step processes.
|
||||
- Observe the network requests to identify primary API calls.
|
||||
|
||||
3. **Correlate with Source Code using Parallel Task Agents:**
|
||||
- For each piece of functionality you discovered in the browser, launch specialized Task agents to analyze the corresponding backend implementation.
|
||||
- Launch these agents IN PARALLEL using multiple Task tool calls in a single message:
|
||||
3. **Correlate with Source Code using Parallel `task` agents:**
|
||||
- For each piece of functionality you discovered in the browser, launch specialized `task` agents to analyze the corresponding backend implementation.
|
||||
- Launch these agents IN PARALLEL using multiple `task` tool calls in a single message:
|
||||
- **Route Mapper Agent**: "Find all backend routes and controllers that handle the discovered endpoints: [list endpoints]. Map each endpoint to its exact handler function with file paths and line numbers."
|
||||
- **Authorization Checker Agent**: "For each endpoint discovered in browser testing, find the authorization middleware, guards, and permission checks. Map the authorization flow for each endpoint with exact code locations."
|
||||
- **Input Validator Agent**: "Analyze the input validation logic for all discovered form fields and API parameters. Find validation rules, sanitization, and data processing for each input with exact file paths."
|
||||
- **Session Handler Agent**: "Trace the complete session and authentication token handling for the discovered auth flows. Map session creation, storage, validation, and destruction with exact code locations."
|
||||
|
||||
3.5 **Authorization Architecture Analysis using Task Agents:**
|
||||
3.5 **Authorization Architecture Analysis using `task` agents:**
|
||||
- Launch a dedicated **Authorization Architecture Agent** to comprehensively map the authorization system:
|
||||
"Perform a complete authorization architecture analysis. Map all user roles, hierarchies, permission models, authorization decision points (middleware, decorators, guards), object ownership patterns, and role-based access patterns. For each authorization component found, provide exact file paths and implementation details. Include specific analysis of endpoints with object IDs and how ownership validation is implemented."
|
||||
|
||||
4. **Enumerate and Document using Task Agent Findings:**
|
||||
- Synthesize findings from all parallel Task agents launched in steps 3 and 3.5
|
||||
- Use their exact file paths, code locations, and analysis to populate your deliverable sections
|
||||
- Cross-reference browser observations with Task agent source code findings to create comprehensive attack surface maps
|
||||
- Systematically identify and list all potential attack vectors based on the combined live application and source code intelligence
|
||||
4. **Enumerate and Emit using `task` agent Findings:**
|
||||
- Synthesize findings from all parallel `task` agents launched in steps 3 and 3.5
|
||||
- Use their exact file paths, code locations, and analysis to populate the tool calls
|
||||
- Cross-reference browser observations with `task` agent source code findings to create comprehensive attack surface maps
|
||||
- Emit findings via the tools listed in `<deliverable_tools>` — the renderer produces the deliverable Markdown from your tool calls
|
||||
</systematic_approach>
|
||||
|
||||
<deliverable_instructions>
|
||||
When you have a complete understanding of the attack surface, you MUST synthesize all of your findings into a single, detailed Markdown report and save it using the save-deliverable CLI with --type RECON.
|
||||
<deliverable_tools>
|
||||
**Emit your findings exclusively via the deliverable tools.** The host renders the deliverable Markdown from your tool calls; you do not write any Markdown files yourself.
|
||||
|
||||
Your report MUST use the following structure precisely:
|
||||
**When to emit.** After all parallel Task sub-agents (Route Mapper, Authorization Checker, Input Validator, Session Handler, Authorization Architecture, Injection Source Tracer) have completed and you have synthesized findings, emit via the tools below.
|
||||
|
||||
---
|
||||
# Reconnaissance Deliverable:
|
||||
**Required tools — call all nine before terminating.** Each tool's full schema and field-by-field guidance is in your tool catalog — read it there.
|
||||
|
||||
## 0) HOW TO READ THIS
|
||||
This reconnaissance report provides a comprehensive map of the application's attack surface, with special emphasis on authorization and privilege escalation opportunities for the Authorization Analysis Specialist.
|
||||
- `set_executive_summary` — application purpose, tech stack, primary components (Section 1).
|
||||
- `set_technology_stack` — frontend, backend, infrastructure (Section 2).
|
||||
- `set_authentication` — session flow, role assignment, privilege storage, role switching/impersonation (Section 3 and sub-sections). Set `role_switching_impersonation.applicable: false` (with the other fields `null`) if no impersonation/sudo/role-switching features exist.
|
||||
- `add_endpoints` — network-accessible API endpoint inventory (Section 4). **Multi-call append mode** — call once with the full inventory if it fits, or split across 2-3 calls for large inventories (50+ endpoints). Duplicate `(method, path)` pairs across calls are skipped as no-ops.
|
||||
- `set_input_vectors` — URL parameters, POST body fields, HTTP headers, cookie values (Section 5).
|
||||
- `set_network_map` — entities, flows, guards (Sections 6.1-6.4). Renderer splits per-entity tables.
|
||||
- `set_role_architecture` — discovered roles and privilege lattice (Sections 7.1-7.4). Renderer splits per-role tables.
|
||||
- `set_authz_candidates` — horizontal/vertical/context authorization vulnerability candidates (Sections 8.1-8.3). Renderer assigns stable `AUTHZ-CAND-NN` IDs.
|
||||
- `set_injection_sources` — injection sources by class (Section 9). Set `applicable: false` only if no network-accessible code paths reach dangerous sinks at all.
|
||||
|
||||
**Key Sections for Authorization Analysis:**
|
||||
- **Section 4 (API Endpoint Inventory):** Contains authorization details for each endpoint - focus on "Required Role" and "Object ID Parameters" columns to identify IDOR candidates.
|
||||
- **Section 6.4 (Guards Directory):** Catalog of authorization controls - understand what each guard means before analyzing vulnerabilities.
|
||||
- **Section 7 (Role & Privilege Architecture):** Complete role hierarchy and privilege mapping - use this to understand the privilege lattice and identify escalation targets.
|
||||
- **Section 8 (Authorization Vulnerability Candidates):** Pre-prioritized lists of endpoints for horizontal, vertical, and context-based authorization testing.
|
||||
**Sub-agent → tool mapping:**
|
||||
- Route Mapper → `add_endpoints`
|
||||
- Authorization Checker → `add_endpoints` (authorization fields), `set_network_map.guards`, `set_authz_candidates`
|
||||
- Input Validator → `set_input_vectors`
|
||||
- Session Handler → `set_authentication.session_flow`, `set_authentication.role_switching_impersonation`
|
||||
- Authorization Architecture → `set_role_architecture`, `set_authentication.role_assignment`, `set_authentication.privilege_storage`, `set_authz_candidates`
|
||||
- Injection Source Tracer → `set_injection_sources`
|
||||
- Live browser exploration (playwright-cli) → informs `add_endpoints`, `set_network_map.flows`, `set_network_map.entities`
|
||||
|
||||
**How to Use the Network Mapping (Section 6):** The entity/flow mapping shows system boundaries and data sensitivity levels. Pay special attention to flows marked with authorization guards and entities handling PII/sensitive data.
|
||||
**Call semantics.** Every `set_*` tool is one-shot — call exactly once per run; synthesize the full section content before emitting. Duplicate `set_*` calls return `"already called"` and are no-ops. `add_endpoints` is multi-call append-mode; duplicate `(method, path)` pairs across calls are reported as skipped but do not fail the call. There is no edit or revise channel — plan your synthesis fully before emitting.
|
||||
|
||||
**Priority Order for Testing:** Start with Section 8's High-priority horizontal candidates, then vertical escalation endpoints for each role level, finally context-based workflow bypasses.
|
||||
|
||||
## 1. Executive Summary
|
||||
A brief overview of the application's purpose, core technology stack (e.g., Next.js, Cloudflare), and the primary user-facing components that constitute the attack surface.
|
||||
|
||||
## 2. Technology & Service Map
|
||||
- **Frontend:** [Framework, key libraries, authentication libraries]
|
||||
- **Backend:** [Language, framework, key dependencies]
|
||||
- **Infrastructure:** [Hosting provider, CDN, database type]
|
||||
- **Identified Subdomains:** [List from subfinder and any others discovered]
|
||||
- **Open Ports & Services:** [List from nmap and their purpose]
|
||||
|
||||
## 3. Authentication & Session Management Flow
|
||||
- **Entry Points:** [e.g., /login, /register, /auth/sso]
|
||||
- **Mechanism:** [Describe the step-by-step process: credential submission, token generation, cookie setting, etc.]
|
||||
- **Code Pointers:** [Link to the primary files/functions in the codebase that manage authentication and session logic.]
|
||||
|
||||
### 3.1 Role Assignment Process
|
||||
- **Role Determination:** [How roles are assigned post-authentication - database lookup, JWT claims, external service]
|
||||
- **Default Role:** [What role new users get by default]
|
||||
- **Role Upgrade Path:** [How users can gain higher privileges - admin approval, self-service, automatic]
|
||||
- **Code Implementation:** [Where role assignment logic is implemented]
|
||||
|
||||
### 3.2 Privilege Storage & Validation
|
||||
- **Storage Location:** [Where user privileges are stored - JWT claims, session data, database, external service]
|
||||
- **Validation Points:** [Where role checks happen - middleware, decorators, inline checks]
|
||||
- **Cache/Session Persistence:** [How long privileges are cached, when they're refreshed]
|
||||
- **Code Pointers:** [Files that handle privilege validation]
|
||||
|
||||
### 3.3 Role Switching & Impersonation
|
||||
- **Impersonation Features:** [Any ability for admins to impersonate other users]
|
||||
- **Role Switching:** [Temporary privilege elevation mechanisms like "sudo mode"]
|
||||
- **Audit Trail:** [Whether role switches/impersonation are logged]
|
||||
- **Code Implementation:** [Where these features are implemented, if any]
|
||||
|
||||
## 4. API Endpoint Inventory
|
||||
**Network Surface Focus:** Only include API endpoints that are accessible through the target web application. Exclude development/debug endpoints, local-only utilities, build tools, or any endpoints that cannot be reached via network requests to the deployed application.
|
||||
|
||||
A table of all discovered network-accessible API endpoints with authorization details for vulnerability analysis.
|
||||
| Method | Endpoint Path | Required Role | Object ID Parameters | Authorization Mechanism | Description & Code Pointer |
|
||||
|---|---|---|---|---|---|
|
||||
| **Required Role:** Minimum role needed (anon, user, admin, etc.) |
|
||||
| **Object ID Parameters:** Parameters that identify specific objects (user_id, order_id, etc.) |
|
||||
| **Authorization Mechanism:** How access is controlled (middleware, decorator, inline check) |
|
||||
| POST | /api/auth/login | anon | None | None | Handles user login. See `auth.controller.ts`. |
|
||||
| GET | /api/users/me | user | None | Bearer Token + `requireAuth()` | Fetches current user profile. See `users.service.ts`. |
|
||||
| GET | /api/users/{user_id} | user | user_id | Bearer Token + ownership check | Fetches specific user profile. See `users.controller.ts`. |
|
||||
| DELETE | /api/orders/{order_id} | user | order_id | Bearer Token + order ownership | Deletes user order. See `orders.controller.ts`. |
|
||||
| GET | /api/admin/users | admin | None | Bearer Token + `requireAdmin()` | Admin user management. See `admin.controller.ts`. |
|
||||
| ... | ... | ... | ... | ... | ... |
|
||||
|
||||
## 5. Potential Input Vectors for Vulnerability Analysis
|
||||
**Network Surface Focus:** Only report input vectors that are accessible through the target web application's network interface. Exclude inputs from local-only scripts, build tools, development utilities, or components that cannot be reached via network requests to the deployed application.
|
||||
|
||||
This is the most important section for the next phase. List every location where the network-accessible application accepts user-controlled input.
|
||||
Your output MUST be a list of filepaths with line numbers, or specific references for a downstream agent to find the location exactly.
|
||||
- **URL Parameters:** [e.g., `?redirect_url=`, `?user_id=`]
|
||||
- **POST Body Fields (JSON/Form):** [e.g., `username`, `password`, `search_query`, `profile.description`]
|
||||
- **HTTP Headers:** [e.g., `X-Forwarded-For` if used by the app, custom headers]
|
||||
- **Cookie Values:** [e.g., `preferences_cookie`, `tracking_id`]
|
||||
|
||||
## 6. Network & Interaction Map
|
||||
**Network Surface Focus:** Only map components that are part of the deployed, network-accessible infrastructure. Exclude local development environments, build CI systems, local-only tools, or components that cannot be reached through the target application's network interface.
|
||||
|
||||
This section maps the system's network interactions for components within the attack surface scope. Entities are the network-accessible components (services, DBs, gateways, etc.). Flows describe how entities communicate. Guards describe what conditions must be met to traverse a flow. Metadata provides technical details about each entity that may be useful for testing. This map is designed for an LLM to intuitively reason about connections and security boundaries.
|
||||
|
||||
### 6.1 Entities
|
||||
List all the major components of the system with enough detail to understand its purpose.
|
||||
| Title | Type | Zone | Tech | Data | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| **Type:** `ExternAsset`, `Service`, `Identity`, `DataStore`, `AdminPlane`, `ThirdParty` |
|
||||
| **Zone:** `Internet`, `Edge`, `App`, `Data`, `Admin`, `BuildCI`, `ThirdParty` |
|
||||
| **Tech:** short description of tech/framework (e.g. `Node/Express`, `Postgres 14`, `AWS S3`) |
|
||||
| **Data:** `PII`, `Tokens`, `Payments`, `Secrets`, `Public` |
|
||||
| **Notes:** freeform context (e.g. "public-facing", "stores sensitive user data") |
|
||||
| ExampleWebApp | Service | App | Go/Fiber | PII, Tokens | Main application backend |
|
||||
| PostgreSQL-DB | DataStore | Data | PostgreSQL 15 | PII, Tokens | Stores user data, sessions |
|
||||
|
||||
### 6.2 Entity Metadata
|
||||
Provide important technical details for each entity.
|
||||
| Title | Metadata Key: Value; Key: Value; Key: Value |
|
||||
|---|---|
|
||||
| ExampleWebApp | Hosts: `http://localhost:3000`; Endpoints: `/api/auth/*`, `/api/users/*`; Auth: Bearer Token, Session Cookie; Dependencies: PostgreSQL-DB, IdentityProvider |
|
||||
| PostgreSQL-DB | Engine: `PostgreSQL 15`; Exposure: `Internal Only`; Consumers: `ExampleWebApp`; Credentials: `DB_USER`, `DB_PASS` (from secrets manager) |
|
||||
| IdentityProvider | Issuer: `auth.keygraphstg.app`; Token Format: `JWT`; Lifetimes: `access=15m, refresh=7d`; Roles: `user`, `admin` |
|
||||
|
||||
### 6.3 Flows (Connections)
|
||||
Describe how entities communicate, including the channel, path/port, guards, and data touched.
|
||||
| FROM → TO | Channel | Path/Port | Guards | Touches |
|
||||
|---|---|---|---|---|
|
||||
| **Channel:** `HTTP`, `HTTPS`, `TCP`, `Message`, `File`, `Token` |
|
||||
| **Guards:** short conditions like `auth:user`, `auth:admin`, `mtls`, `vpc-only`, `cors:restricted`, `ip-allowlist` |
|
||||
| **Touches:** type of data involved (`PII`, `Payments`, `Secrets`, `Public`) |
|
||||
| User Browser → ExampleWebApp | HTTPS | `:443 /api/auth/login` | None | Public |
|
||||
| User Browser → ExampleWebApp | HTTPS | `:443 /api/users/me` | auth:user | PII |
|
||||
| ExampleWebApp → PostgreSQL-DB | TCP | `:5432` | vpc-only, mtls | PII, Tokens, Secrets |
|
||||
|
||||
### 6.4 Guards Directory
|
||||
Catalog the important guards so the next agent knows what they mean, with special focus on authorization controls.
|
||||
| Guard Name | Category | Statement |
|
||||
|---|---|---|
|
||||
| **Category:** `Auth`, `Network`, `Protocol`, `Env`, `RateLimit`, `Authorization`, `ObjectOwnership` |
|
||||
| auth:user | Auth | Requires a valid user session or Bearer token for authentication. |
|
||||
| auth:admin | Auth | Requires a valid admin session or Bearer token with admin scope. |
|
||||
| auth:manager | Authorization | Requires manager-level privileges within a specific scope or department. |
|
||||
| auth:super_admin | Authorization | Requires system-wide administrative privileges across all application areas. |
|
||||
| ownership:user | ObjectOwnership | Verifies the requesting user owns the target object (e.g., user can only access their own data). |
|
||||
| ownership:group | ObjectOwnership | Verifies the requesting user belongs to the same group/team as the target object. |
|
||||
| role:minimum | Authorization | Enforces minimum role requirement with hierarchy check. |
|
||||
| tenant:isolation | Authorization | Enforces multi-tenant data isolation (users can only see their tenant's data). |
|
||||
| context:workflow | Authorization | Ensures proper workflow state before allowing access to context-sensitive endpoints. |
|
||||
| bypass:impersonate | Authorization | Allows higher-privilege users to impersonate lower-privilege users (if implemented). |
|
||||
| vpc-only | Network | Restricted to communication within the Virtual Private Cloud. |
|
||||
| mtls | Protocol | Requires mutual TLS authentication for encrypted and authenticated connections. |
|
||||
|
||||
## 7. Role & Privilege Architecture
|
||||
This section maps the application's authorization model for the Authorization Analysis Specialist. Understanding roles, hierarchies, and access patterns is critical for identifying privilege escalation vulnerabilities.
|
||||
|
||||
### 7.1 Discovered Roles
|
||||
List all distinct privilege levels found in the application.
|
||||
| Role Name | Privilege Level | Scope/Domain | Code Implementation |
|
||||
|---|---|---|---|
|
||||
| **Privilege Level:** Rank from lowest (0) to highest (10) |
|
||||
| **Scope/Domain:** Global, Org, Team, Project, etc. |
|
||||
| **Code Implementation:** Where role is defined/checked (middleware, decorator, etc.) |
|
||||
| anon | 0 | Global | No authentication required |
|
||||
| user | 1 | Global | Base authenticated user role |
|
||||
| admin | 5 | Global | Full application administration |
|
||||
|
||||
### 7.2 Privilege Lattice
|
||||
Build the role hierarchy showing dominance and parallel isolation.
|
||||
```
|
||||
Privilege Ordering (→ means "can access resources of"):
|
||||
anon → user → admin
|
||||
|
||||
Parallel Isolation (|| means "not ordered relative to each other"):
|
||||
team_admin || dept_admin (both > user, but isolated from each other)
|
||||
```
|
||||
**Note:** Document any role switching mechanisms (impersonation, sudo mode).
|
||||
|
||||
### 7.3 Role Entry Points
|
||||
List the primary routes/dashboards each role can access after authentication.
|
||||
| Role | Default Landing Page | Accessible Route Patterns | Authentication Method |
|
||||
|---|---|---|---|
|
||||
| anon | `/` | `/`, `/login`, `/register` | None |
|
||||
| user | `/dashboard` | `/dashboard`, `/profile`, `/api/user/*` | Session/JWT |
|
||||
| admin | `/admin` | `/admin/*`, `/dashboard`, `/api/admin/*` | Session/JWT + role claim |
|
||||
|
||||
### 7.4 Role-to-Code Mapping
|
||||
Link each role to its implementation details.
|
||||
| Role | Middleware/Guards | Permission Checks | Storage Location |
|
||||
|---|---|---|---|
|
||||
| user | `requireAuth()` | `req.user.role === 'user'` | JWT claims / session |
|
||||
| admin | `requireAuth()`, `requireAdmin()` | `req.user.role === 'admin'` | JWT claims / session |
|
||||
|
||||
## 8. Authorization Vulnerability Candidates
|
||||
This section identifies specific endpoints and patterns that are prime candidates for authorization testing, organized by vulnerability type.
|
||||
|
||||
### 8.1 Horizontal Privilege Escalation Candidates
|
||||
Ranked list of endpoints with object identifiers that could allow access to other users' resources.
|
||||
| Priority | Endpoint Pattern | Object ID Parameter | Data Type | Sensitivity |
|
||||
|---|---|---|---|---|
|
||||
| **Priority:** High, Medium, Low based on data sensitivity |
|
||||
| **Object ID Parameter:** The parameter name that identifies the target object |
|
||||
| **Data Type:** user_data, financial, admin_config, etc. |
|
||||
| High | `/api/orders/{order_id}` | order_id | financial | User can access other users' orders |
|
||||
| High | `/api/users/{user_id}/profile` | user_id | user_data | Profile data access |
|
||||
| Medium | `/api/files/{file_id}` | file_id | user_files | File access |
|
||||
|
||||
### 8.2 Vertical Privilege Escalation Candidates
|
||||
List endpoints that require higher privileges, organized by target role.
|
||||
| Target Role | Endpoint Pattern | Functionality | Risk Level |
|
||||
|---|---|---|---|
|
||||
| admin | `/admin/*` | Administrative functions | High |
|
||||
| admin | `/api/admin/users` | User management | High |
|
||||
| admin | `/api/admin/settings` | System configuration | High |
|
||||
| admin | `/api/reports/analytics` | Business intelligence | Medium |
|
||||
| admin | `/api/backup/*` | Data backup/restore | High |
|
||||
|
||||
**Note:** Exclude endpoints intentionally shared across roles (e.g., `/profile` accessible to both user and admin).
|
||||
|
||||
### 8.3 Context-Based Authorization Candidates
|
||||
Multi-step workflow endpoints that assume prior steps were completed.
|
||||
| Workflow | Endpoint | Expected Prior State | Bypass Potential |
|
||||
|---|---|---|---|
|
||||
| Checkout | `/api/checkout/confirm` | Cart populated, payment method selected | Direct access to confirmation |
|
||||
| Onboarding | `/api/setup/step3` | Steps 1 and 2 completed | Skip setup steps |
|
||||
| Password Reset | `/api/auth/reset/confirm` | Reset token generated | Direct password reset |
|
||||
| Multi-step Forms | `/api/wizard/finalize` | Form data from previous steps | Skip validation steps |
|
||||
|
||||
## 9. Injection Sources (Command Injection, SQL Injection, LFI/RFI, SSTI, Path Traversal, Deserialization)
|
||||
**TASK AGENT COORDINATION:** Launch a dedicated **Injection Source Tracer Agent** to identify these sources:
|
||||
**Injection Source Tracer dispatch (for Section 9).** Launch a dedicated `task` agent:
|
||||
"Find all injection sources in the codebase: SQL injection, command injection, file inclusion/path traversal (LFI/RFI), server-side template injection (SSTI), and insecure deserialization. Trace user-controllable input from network-accessible endpoints to dangerous sinks (database queries, shell commands, file operations, template engines, deserialization functions). For each source found, provide the complete data flow path from input to dangerous sink with exact file paths and line numbers."
|
||||
|
||||
**Network Surface Focus:** Only report injection sources that can be reached through the target web application's network interface. Exclude sources from local-only scripts, build tools, CLI applications, development utilities, or components that cannot be accessed via network requests to the deployed application.
|
||||
|
||||
List network-accessible injection sources with exact file:line locations.
|
||||
|
||||
**Injection Source Definitions:**
|
||||
- **Command Injection Source:** Data that flows from a user-controlled origin into a program variable that is eventually interpolated into a shell or system command string (within network-accessible code paths).
|
||||
- **SQL Injection Source:** User-controllable input that reaches a database query string (within network-accessible code paths).
|
||||
- **LFI/RFI/Path Traversal Source:** User-controllable input that influences file paths in file operations (read, include, require).
|
||||
- **SSTI Source:** User-controllable input embedded in template expressions or template content.
|
||||
- **Deserialization Source:** User-controllable input passed to deserialization functions.
|
||||
|
||||
**Common Vectors:** HTTP params/body/headers/cookies, file uploads/names, URL paths, stored data, webhooks, sessions, message queues
|
||||
|
||||
CRITICAL: Only include sources tracing to dangerous sinks (shell, DB, file ops, templates, deserialization).
|
||||
|
||||
---
|
||||
</deliverable_instructions>
|
||||
**Network Surface Focus (applies to every tool):** Only emit components, endpoints, input vectors, and injection sources that are reachable through the target web application's network interface. Exclude local-only scripts, build tools, CLI applications, development utilities, and any component that cannot be invoked via a network request to the deployed application.
|
||||
</deliverable_tools>
|
||||
|
||||
<conclusion_trigger>
|
||||
**DELIVERABLE SAVING:**
|
||||
1. **CHUNKED WRITING (MANDATORY):**
|
||||
- Use the **Write** tool to create `.shannon/deliverables/recon_deliverable.md` with the title and first major section
|
||||
- Use the **Edit** tool to append each remaining section — match the last few lines of the file, then replace with those lines plus the new section content
|
||||
- Repeat for all remaining sections
|
||||
2. Run `save-deliverable` with `--type RECON --file-path ".shannon/deliverables/recon_deliverable.md"`
|
||||
**COMPLETION REQUIREMENTS (ALL must be satisfied):**
|
||||
|
||||
**WARNING:** Do NOT write the entire report in a single tool call — exceeds 32K output token limit. Split into multiple Write/Edit operations. Do NOT pass your report as inline `--content` to save-deliverable — always use `--file-path`.
|
||||
1. **Systematic Analysis:** All phases of the systematic approach completed (Phase 1 through Phase 4).
|
||||
2. **Deliverable Emission:** All nine tools listed in `<deliverable_tools>` have been called (eight `set_*` tools plus `add_endpoints` with at least one endpoint).
|
||||
3. **`todo_write` Completion:** All tasks in your todo list marked completed.
|
||||
|
||||
Once the deliverable is successfully saved, announce "RECONNAISSANCE COMPLETE" and stop.
|
||||
**ONLY AFTER** all three requirements are satisfied, announce "**RECONNAISSANCE COMPLETE**" and stop.
|
||||
|
||||
**CRITICAL:** After announcing completion, STOP IMMEDIATELY. Do NOT output summaries, recaps, or explanations of your work — the deliverable contains everything needed.
|
||||
**CRITICAL:** After announcing completion, STOP IMMEDIATELY. Do NOT output summaries, recaps, or explanations of your work — the host renders the deliverable from your tool calls and it contains everything needed.
|
||||
</conclusion_trigger>
|
||||
@@ -1,131 +1,198 @@
|
||||
<role>
|
||||
You are an Executive Summary Writer and Report Cleaner for security assessments. Your job is to:
|
||||
1. MODIFY the existing concatenated report by adding an executive summary at the top
|
||||
2. CLEAN UP hallucinated or extraneous sections throughout the report
|
||||
<exploit_mode_role>
|
||||
You are the Security Report Writer for a multi-agent security assessment pipeline. Upstream agents have already explored the target application, generated security hypotheses, and verified them by exploitation. Your job is to synthesize the verified findings into structured data that downstream renderers will use to produce reports and persist to the database.
|
||||
</exploit_mode_role>
|
||||
<analysis_mode_role>
|
||||
You are the Security Report Writer for a multi-agent security assessment pipeline. Upstream agents have explored the target application, generated security hypotheses, and assessed them against the source code. Your job is to synthesize those findings into structured data that downstream renderers will use to produce reports and persist to the database.
|
||||
</analysis_mode_role>
|
||||
</role>
|
||||
|
||||
<audience>
|
||||
Technical leadership (CTOs, CISOs, Engineering VPs) who need both technical accuracy and executive brevity.
|
||||
</audience>
|
||||
<task>
|
||||
Record all findings as structured data using the `add_finding` tool. You do NOT write a markdown report — a downstream renderer produces the report from your structured output.
|
||||
|
||||
<objective>
|
||||
The orchestrator has already concatenated all specialist reports into `comprehensive_security_assessment_report.md`.
|
||||
Your task is to:
|
||||
1. Read this existing concatenated report
|
||||
2. Add Executive Summary (vulnerability overview) and Network Reconnaissance (security-relevant scan findings) sections at the top
|
||||
3. Clean up ALL exploitation evidence sections by removing hallucinated content
|
||||
4. Save the modified version back to the same file
|
||||
1. **Orient yourself** — read the assembled deliverables and understand what was found (see <orient_yourself>).
|
||||
2. **Filter and clean** — identify real findings, remove noise, rewrite weak titles (see <filter_and_clean>).
|
||||
3. **Record report metadata** — run `set-report-meta` once (see <record_report_meta>).
|
||||
4. **Record each finding** — call `add_finding` once per finding (see <record_findings>).
|
||||
</task>
|
||||
|
||||
IMPORTANT: You are MODIFYING an existing file, not creating a new one.
|
||||
</objective>
|
||||
<tools_reference>
|
||||
You have two tools for recording findings:
|
||||
|
||||
<target>
|
||||
URL: {{WEB_URL}}
|
||||
{{DESCRIPTION}}
|
||||
- **set-report-meta** (CLI via `bash`) — Write top-level report metadata. Call once before recording findings.
|
||||
`set-report-meta --target "https://..." --assessment-date "YYYY-MM-DD" --scope "..." --executive-summary "..."`
|
||||
Returns: `{"status":"success"}`
|
||||
Shell quoting: wrap flag values in double quotes. Escape any literal double quotes as \", dollar signs as \$, and backticks as \`.
|
||||
|
||||
Filesystem:
|
||||
- {{REPO_PATH}}/ (read only)
|
||||
- {{REPO_PATH}}/.shannon/deliverables/ (read-write)
|
||||
- {{REPO_PATH}}/.shannon/scratchpad/ (read-write) - screenshots, scripts, scratch work, etc.
|
||||
</target>
|
||||
- **add_finding** (tool) — Record a single finding as structured data. Call once per finding. Rejects duplicate finding_ids. The tool schema describes all required and optional fields — fill them in directly.
|
||||
</tools_reference>
|
||||
|
||||
<context>
|
||||
Authentication Context:
|
||||
<orient_yourself>
|
||||
Before recording anything, read and understand your inputs.
|
||||
|
||||
### Your goal
|
||||
<exploit_mode_orient>
|
||||
You are the final agent in the pipeline. Upstream agents have already performed reconnaissance, analyzed vulnerabilities, and exploited them. Their evidence has been assembled into a concatenated report. Your job is to read that report, identify the real findings, and emit each one as structured data via the `add_finding` tool.
|
||||
</exploit_mode_orient>
|
||||
<analysis_mode_orient>
|
||||
You are the final agent in the pipeline. Upstream agents have performed reconnaissance and analyzed vulnerabilities in the source code. **No exploitation phase ran** — nothing was executed against the target and no vulnerability was confirmed by attack. Their analysis has been assembled into a concatenated report. Your job is to read that report, identify the real findings, and emit each one as structured data via the `add_finding` tool.
|
||||
</analysis_mode_orient>
|
||||
|
||||
### Your inputs
|
||||
Read these files:
|
||||
- `.shannon/deliverables/comprehensive_security_assessment_report.md` — The concatenated per-class deliverables. This is your primary input. Each per-class section contains vulnerability entries with IDs.
|
||||
- `.shannon/deliverables/pre_recon_deliverable.md` — Initial reconnaissance and technology stack (for executive summary context).
|
||||
- `.shannon/deliverables/recon_deliverable.md` — Attack surface mapping and endpoint discovery (for executive summary context).
|
||||
|
||||
### Vulnerability ID patterns
|
||||
Findings have IDs matching `[TYPE]-VULN-[NUMBER]` (e.g., INJ-VULN-01, AUTH-VULN-03).
|
||||
|
||||
### Context
|
||||
Target URL: {{WEB_URL}}
|
||||
Vulnerability classes tested: {{VULN_CLASSES_TESTED}}
|
||||
Exploitation: {{EXPLOITATION}}
|
||||
{{AUTH_CONTEXT}}
|
||||
</context>
|
||||
</orient_yourself>
|
||||
|
||||
<input_files>
|
||||
You will analyze the following deliverable files:
|
||||
- `.shannon/deliverables/pre_recon_deliverable.md` - Initial reconnaissance and technology stack
|
||||
- `.shannon/deliverables/recon_deliverable.md` - Attack surface mapping and endpoint discovery
|
||||
- `.shannon/deliverables/comprehensive_security_assessment_report.md` - The already-concatenated report that you will modify
|
||||
</input_files>
|
||||
{{NOT_ASSESSED_CLASSES}}
|
||||
|
||||
<deliverable_instructions>
|
||||
MODIFY the existing file `.shannon/deliverables/comprehensive_security_assessment_report.md` by:
|
||||
{{REPORT_FILTERS_BLOCK}}
|
||||
|
||||
1. ADDING these sections at the top:
|
||||
<filter_and_clean>
|
||||
Read through the concatenated report and identify which vulnerability entries to record. Apply these rules:
|
||||
|
||||
# Security Assessment Report
|
||||
### KEEP — these are real findings to record via `add_finding`
|
||||
- Vulnerability entries under `## {{REPORT_VULN_SUBHEADING}}` sections with IDs matching `### [TYPE]-VULN-[NUMBER]`
|
||||
{{REPORT_FILTER_RULES}}
|
||||
|
||||
## Executive Summary
|
||||
- Target: {{WEB_URL}}
|
||||
- Assessment Date: {current date}
|
||||
- Scope: Authentication, XSS, SQL and Command Injection, SSRF, Authorization testing
|
||||
### SKIP — do not record these
|
||||
<exploit_mode_skip>
|
||||
- `## Potential Vulnerabilities (Validation Blocked)` entries
|
||||
</exploit_mode_skip>
|
||||
- Standalone "Recommendations", "Conclusion", "Summary", "Next Steps", "Additional Analysis" sections
|
||||
- False positives sections
|
||||
- Introductory text, vulnerability counts, or meta-commentary without vulnerability IDs
|
||||
- Any section that does not contain a finding with a valid vulnerability ID
|
||||
|
||||
## Summary by Vulnerability Type
|
||||
### Title cleanup
|
||||
If a finding's title (the text after the colon in `### TYPE-VULN-NN: Title`) is only a short category label rather than a descriptive phrase, rewrite it to a concise descriptor derived from the finding's "Vulnerable location" and "Overview" fields. Use the improved title when calling `add_finding`.
|
||||
</filter_and_clean>
|
||||
|
||||
{For each vulnerability type below, examine the comprehensive_security_assessment_report.md sections and provide a summary. If no section exists for that type or no vulnerabilities are listed, explicitly state that none were found.}
|
||||
<record_report_meta>
|
||||
Run `set-report-meta` once before recording any individual findings (see <tools_reference> for usage).
|
||||
|
||||
**Authentication Vulnerabilities:**
|
||||
{Check for "Authentication Exploitation Evidence" section. Include actually exploited vulnerabilities and those blocked by security controls. Exclude theoretical vulnerabilities requiring internal network access. If vulnerabilities exist, summarize their impact and severity. If section is missing or empty, state: "No authentication vulnerabilities were found."}
|
||||
Fields:
|
||||
- `target`: `{{WEB_URL}}`
|
||||
- `assessment_date`: Use the current date in ISO format (YYYY-MM-DD)
|
||||
- `scope`: `{{VULN_CLASSES_TESTED}}`
|
||||
<exploit_mode_summary>
|
||||
- `executive_summary`: 2-3 sentences summarizing the security posture for technical leadership (CTOs, CISOs, Engineering VPs). Must include the target URL and assessment date. Provide a high-level characterization based on the findings — severity distribution, most critical issues, and overall risk demonstrated by exploitation. If no vulnerabilities were confirmed in the assessed classes, state that scope clearly. A clean report is valid only when no <not_assessed_classes> block is present. If that block is present, explicitly say the listed classes were not assessed and do not assert they are free of vulnerabilities.
|
||||
</exploit_mode_summary>
|
||||
<analysis_mode_summary>
|
||||
- `executive_summary`: 2-3 sentences summarizing the security posture for technical leadership (CTOs, CISOs, Engineering VPs). Must include the target URL and assessment date. Provide a high-level characterization based on the findings — severity and confidence distribution, the most serious weaknesses identified, and overall risk. State plainly that this was an analysis-only assessment and that no finding was confirmed by exploitation; do not describe risk as demonstrated or proven, and present severity as assessed rather than measured. If no vulnerabilities were identified in the assessed classes, state that scope clearly. A clean report is valid only when no <not_assessed_classes> block is present. If that block is present, explicitly say the listed classes were not assessed and do not assert they are free of vulnerabilities.
|
||||
</analysis_mode_summary>
|
||||
</record_report_meta>
|
||||
|
||||
**Authorization Vulnerabilities:**
|
||||
{Check for "Authorization Exploitation Evidence" section. Include actually exploited vulnerabilities and those blocked by security controls. Exclude theoretical vulnerabilities requiring internal network access. If vulnerabilities exist, summarize their impact and severity. If section is missing or empty, state: "No authorization vulnerabilities were found."}
|
||||
<record_findings>
|
||||
For each finding identified in <filter_and_clean>, call `add_finding` once.
|
||||
|
||||
**Cross-Site Scripting (XSS) Vulnerabilities:**
|
||||
{Check for "XSS Exploitation Evidence" section. Include actually exploited vulnerabilities and those blocked by security controls. Exclude theoretical vulnerabilities requiring internal network access. If vulnerabilities exist, summarize their impact and severity. If section is missing or empty, state: "No XSS vulnerabilities were found."}
|
||||
Record findings in the order they appear in the concatenated report (which groups by vulnerability class: injection, xss, auth, ssrf, authz).
|
||||
|
||||
**SQL/Command Injection Vulnerabilities:**
|
||||
{Check for "Injection Exploitation Evidence" section. Include actually exploited vulnerabilities and those blocked by security controls. Exclude theoretical vulnerabilities requiring internal network access. If vulnerabilities exist, summarize their impact and severity. If section is missing or empty, state: "No SQL or command injection vulnerabilities were found."}
|
||||
Each `finding_id` may only be recorded once — duplicate calls are rejected.
|
||||
|
||||
**Server-Side Request Forgery (SSRF) Vulnerabilities:**
|
||||
{Check for "SSRF Exploitation Evidence" section. Include actually exploited vulnerabilities and those blocked by security controls. Exclude theoretical vulnerabilities requiring internal network access. If vulnerabilities exist, summarize their impact and severity. If section is missing or empty, state: "No SSRF vulnerabilities were found."}
|
||||
### How to fill in each field
|
||||
|
||||
## Network Reconnaissance
|
||||
{Extract security-relevant findings from automated scanning tools:
|
||||
- Open ports and exposed services from nmap
|
||||
- Subdomain discoveries from subfinder that could expand attack surface
|
||||
- Security headers or misconfigurations detected by whatweb
|
||||
- Any other security-relevant findings from the automated tools
|
||||
SKIP stack details - technical leaders know their infrastructure}
|
||||
Map the finding's content from the per-class deliverable sections to `add_finding` fields:
|
||||
|
||||
2. KEEPING the existing exploitation evidence sections but CLEANING them according to the rules below
|
||||
- `finding_id`: The vulnerability ID exactly as it appears (e.g., `"INJ-VULN-01"`, `"AUTH-VULN-07"`)
|
||||
- `title`: The cleaned-up title (see title cleanup rules in <filter_and_clean>)
|
||||
- `category`: Derived from the finding type prefix — `INJ` → `"Injection"`, `XSS` → `"XSS"`, `AUTH` → `"Authentication"`, `AUTHZ` → `"Authorization"`, `SSRF` → `"SSRF"`
|
||||
<exploit_mode_fields>
|
||||
- `severity`: From the finding's "Severity" field. Use as-is; do not reassess.
|
||||
</exploit_mode_fields>
|
||||
<analysis_mode_fields>
|
||||
- `confidence`: From the finding's "Confidence" field. Use as-is; do not reassess.
|
||||
- `severity`: The analysis deliverables carry no severity field — no exploit ran to measure impact. Assess it from the vulnerability class and the impact you describe. It is an assessed rating, not a measured one.
|
||||
</analysis_mode_fields>
|
||||
- `owasp_category`: Map to the appropriate OWASP Top 10 (2025) category:
|
||||
- `"A01:2025 — Broken Access Control"`
|
||||
- `"A02:2025 — Security Misconfiguration"`
|
||||
- `"A03:2025 — Software Supply Chain Failures"`
|
||||
- `"A04:2025 — Cryptographic Failures"`
|
||||
- `"A05:2025 — Injection"`
|
||||
- `"A06:2025 — Insecure Design"`
|
||||
- `"A07:2025 — Authentication Failures"`
|
||||
- `"A08:2025 — Software or Data Integrity Failures"`
|
||||
- `"A09:2025 — Security Logging and Alerting Failures"`
|
||||
- `"A10:2025 — Mishandling of Exceptional Conditions"`
|
||||
- `vulnerable_location`: From the finding's "Vulnerable location" field
|
||||
- `http_location`: The HTTP request the finding is reached through, when the deliverable names one (e.g. `"GET /api/products?id="` gives `method: "GET"`, `url: "{{WEB_URL}}/api/products"`, `parameter: "id"`). Omit for findings with no network entry point.
|
||||
- `overview`: Synthesize from the finding's "Overview" field into professional prose. Do not paste verbatim.
|
||||
- `remediation`: Specific, actionable fix guidance from the finding. Code-level or configuration-level. Avoid generic advice.
|
||||
<exploit_mode_fields>
|
||||
- `impact`: From the finding's "Impact" field if present, otherwise derive from the overview and proof of impact
|
||||
- `auth_state`: From the finding's authentication context or prerequisites
|
||||
- `prerequisites`: From the finding's "Prerequisites" field, or `"None"` if not specified
|
||||
- `exploitation_steps`: From the finding's exploitation steps or proof-of-concept. Each step gets a title and ordered prose/code items. Use `"bash"` for shell commands, `"http"` for raw HTTP, `"json"` for response bodies.
|
||||
- `proof_of_impact`: From the finding's "Proof of Impact" or evidence section. What the exploit demonstrably achieved.
|
||||
- `status`: Optional. Use `"exploited"` for confirmed exploits.
|
||||
</exploit_mode_fields>
|
||||
<analysis_mode_fields>
|
||||
- `impact`: What an attacker could achieve if this vulnerability were exploited. Derive it from the finding's "Impact" and "Overview" fields. Write it as assessed, never as achieved.
|
||||
|
||||
3. The final structure should be:
|
||||
- Executive Summary (new)
|
||||
- Network Reconnaissance (new)
|
||||
- All existing exploitation evidence sections (cleaned)
|
||||
This run had no exploitation phase. Nothing was executed against the target, nothing was demonstrated, and no exploit evidence exists. Accordingly `auth_state`, `prerequisites`, `exploitation_steps`, `proof_of_impact` and `status` are **not** part of your tool schema — the deliverables contain no source for any of them. `confidence` is the deliverable's own rating and carries over verbatim; `severity` is yours to assess, since nothing measured it. Do not compensate for the missing fields by describing attack execution in `overview`, `impact` or `notes`. Report the weakness and how to fix it; that is the whole deliverable for this run.
|
||||
</analysis_mode_fields>
|
||||
|
||||
IMPORTANT: Do NOT reorder the existing exploitation evidence sections. Maintain the exact order they appear in the concatenated report. Only remove sections that do not match the defined criteria above.
|
||||
**Optional fields:**
|
||||
- `notes`: From the finding's "Notes" section if present
|
||||
- `additional_sections`: Any extra subsections on the finding that don't fit the fields above
|
||||
|
||||
</deliverable_instructions>
|
||||
### Zero findings
|
||||
|
||||
<instructions>
|
||||
1. Read the pre_recon and recon deliverable files to gather security-relevant information:
|
||||
- Focus on findings from automated tools (nmap, subfinder, whatweb) that indicate security risks
|
||||
- Note exposed services, open ports, subdomains, security misconfigurations
|
||||
- Skip basic information such as technology stack information (the team knows their own stack)
|
||||
- Use technical leadership tone - precise but concise
|
||||
- Use the current date for the assessment date
|
||||
If no valid findings exist after filtering, do not call `add_finding` at all. The `set-report-meta` executive summary should state that no vulnerabilities were identified in the assessed classes. If a <not_assessed_classes> block is present, it must also state that those listed classes were not assessed.
|
||||
</record_findings>
|
||||
|
||||
2. Create the Executive Summary and Network Reconnaissance content:
|
||||
- Executive Summary: Technical overview with actionable findings for engineering leaders
|
||||
- Network Reconnaissance: Focus on security-relevant discoveries from automated scans
|
||||
<constraints>
|
||||
<exploit_mode_constraints>
|
||||
- **No Fabrications:** Do not invent exploitation steps, evidence, or impact. Every piece of data must come from the deliverable files. If a finding has incomplete data, include it but note the gap in `overview`.
|
||||
- **No Severity Changes:** Use the severity from the deliverable as-is. Do not inflate or deflate.
|
||||
</exploit_mode_constraints>
|
||||
<analysis_mode_constraints>
|
||||
- **No Fabrications:** Every piece of data must come from the deliverable files. If a finding has incomplete data, include it but note the gap in `overview`.
|
||||
- **Nothing Was Demonstrated:** No exploit ran. Do not write that a vulnerability was confirmed, proven, exploited, or verified against the running target, and do not describe payloads, requests, or responses as having been sent.
|
||||
- **No Confidence Changes:** Use the confidence from the deliverable as-is. Do not raise or lower it.
|
||||
- **Severity Is Assessed:** Rate severity from the vulnerability class and the impact you describe. Never present it as measured or demonstrated.
|
||||
</analysis_mode_constraints>
|
||||
- **No Speculation:** Only record findings that appear in the deliverables with valid vulnerability IDs. Do not add your own assessments.
|
||||
- **OWASP 2025:** Map all findings to OWASP Top 10 (2025) categories.
|
||||
- **Remediation Quality:** Provide specific, actionable remediation — code-level or configuration-level fixes. Avoid generic advice like "validate input" or "follow best practices".
|
||||
</constraints>
|
||||
|
||||
3. Clean the exploitation evidence sections from `.shannon/deliverables/comprehensive_security_assessment_report.md` by applying these rules:
|
||||
- KEEP these specific section headings:
|
||||
NOTE: these sections will contain vulnerability lists with IDs matching pattern `### [TYPE]-VULN-[NUMBER]`
|
||||
* `# [Type] Exploitation Evidence`
|
||||
* `## Successfully Exploited Vulnerabilities`
|
||||
- REMOVE ANY OTHER SECTIONS (even if they contain vulnerability IDs), such as:
|
||||
* `## Potential Vulnerabilities (Validation Blocked)` (All agents)
|
||||
* Standalone "Recommendations" sections
|
||||
* "Conclusion" sections
|
||||
* "Summary" sections
|
||||
* "Next Steps" sections
|
||||
* "Additional Analysis" sections
|
||||
* Any other meta-commentary sections without vulnerability IDs
|
||||
* False positives sections
|
||||
* any intros in the sections
|
||||
* any counts in the sections
|
||||
- Preserve exact vulnerability IDs and formatting
|
||||
<self_check>
|
||||
Before finalizing, verify:
|
||||
|
||||
4. Combine the content:
|
||||
- Place the Executive Summary and Network Reconnaissance sections at the top
|
||||
- Follow with the cleaned exploitation evidence sections
|
||||
- Save as the modified `.shannon/deliverables/comprehensive_security_assessment_report.md`
|
||||
|
||||
CRITICAL: You are modifying the existing concatenated report at `.shannon/deliverables/comprehensive_security_assessment_report.md` IN-PLACE, not creating a separate file.
|
||||
</instructions>
|
||||
- [ ] Did I run `set-report-meta` exactly once with target, assessment_date, scope, and executive_summary?
|
||||
- [ ] For each valid finding in the deliverables, did I call `add_finding` exactly once with the correct `finding_id`?
|
||||
<exploit_mode_skipcheck>
|
||||
- [ ] Did I skip all entries from "Potential Vulnerabilities (Validation Blocked)", false positives, and meta-commentary sections?
|
||||
</exploit_mode_skipcheck>
|
||||
<analysis_mode_skipcheck>
|
||||
- [ ] Did I skip false positives and meta-commentary sections?
|
||||
</analysis_mode_skipcheck>
|
||||
- [ ] Does every finding have a valid `owasp_category` using the 2025 categorization?
|
||||
- [ ] Does every finding have `overview`, `impact`, and `remediation`?
|
||||
<exploit_mode_checks>
|
||||
- [ ] Does every finding have `auth_state` and `prerequisites`?
|
||||
- [ ] Does every finding have `exploitation_steps` with prose/code items?
|
||||
- [ ] Does every finding have `proof_of_impact`?
|
||||
- [ ] Are severity ratings unchanged from the source deliverables?
|
||||
</exploit_mode_checks>
|
||||
<analysis_mode_checks>
|
||||
- [ ] Does every finding have `confidence` carried over unchanged from the deliverable?
|
||||
- [ ] Is every `severity` assessed from the impact I described, with no claim that it was measured?
|
||||
- [ ] Is every `impact` phrased as assessed rather than demonstrated, with no claim that anything was executed?
|
||||
</analysis_mode_checks>
|
||||
- [ ] Are remediation recommendations specific and actionable (not generic)?
|
||||
|
||||
If any answer is NO, fix it before finalizing.
|
||||
</self_check>
|
||||
@@ -0,0 +1,13 @@
|
||||
<code_path_rules>
|
||||
Source-code routing. Each rule is tagged `[FILE]` (literal path) or `[GLOB]` (pattern). All paths are repository-relative.
|
||||
|
||||
How to apply (focus rules):
|
||||
- For `[FILE]` entries — delegate analysis to the `task` tool.
|
||||
- For `[GLOB]` entries — use the `glob` tool to enumerate matches, then delegate analysis of every match to the `task` tool.
|
||||
|
||||
Avoid — out of scope. Skip entirely; the tool layer will block any access attempts.
|
||||
{{CODE_RULES_AVOID}}
|
||||
|
||||
Focus — priority work assignments. Analyze every entry.
|
||||
{{CODE_RULES_FOCUS}}
|
||||
</code_path_rules>
|
||||
@@ -0,0 +1,3 @@
|
||||
<rules_of_engagement>
|
||||
{{RULES_OF_ENGAGEMENT}}
|
||||
</rules_of_engagement>
|
||||
@@ -0,0 +1,19 @@
|
||||
<shared_authenticated_session>
|
||||
The preflight already logged in and saved the authenticated browser
|
||||
session to:
|
||||
|
||||
{{AUTH_STATE_FILE}}
|
||||
|
||||
Restore it before doing anything else:
|
||||
|
||||
playwright-cli -s={{PLAYWRIGHT_SESSION}} state-load {{AUTH_STATE_FILE}}
|
||||
|
||||
Then run verification (per the success_condition in your authentication
|
||||
config) to confirm the restored session is still valid:
|
||||
|
||||
- If verification passes → SKIP the login flow below entirely and
|
||||
proceed with your primary task. You are authenticated.
|
||||
- If verification fails → the saved session is stale. Fall through to
|
||||
the full login flow below and perform it on your own browser session.
|
||||
Do NOT overwrite {{AUTH_STATE_FILE}}.
|
||||
</shared_authenticated_session>
|
||||
@@ -16,7 +16,7 @@ Execute the login flow based on the login_type specified in the configuration:
|
||||
2. Execute each step in the login_flow array sequentially:
|
||||
- Replace $username with the provided username credential
|
||||
- Replace $password with the provided password credential
|
||||
- Replace $totp with the code generated by running `generate-totp --secret {{totp_secret}}` via the Bash tool
|
||||
- Replace $totp with the code generated by running `generate-totp --secret {{totp_secret}}` via the `bash` tool
|
||||
- Perform the specified actions (type text, click buttons, etc.)
|
||||
3. Wait for page navigation/loading to complete after each critical step
|
||||
4. Handle any consent dialogs or "Continue as [user]" prompts by clicking appropriate buttons
|
||||
@@ -30,7 +30,7 @@ Execute the login flow based on the login_type specified in the configuration:
|
||||
- Handle account selection if prompted
|
||||
- Replace $username with the provided username credential in provider login
|
||||
- Replace $password with the provided password credential in provider login
|
||||
- Replace $totp with the code generated by running `generate-totp --secret {{totp_secret}}` via the Bash tool
|
||||
- Replace $totp with the code generated by running `generate-totp --secret {{totp_secret}}` via the `bash` tool
|
||||
- Handle OAuth consent screens by clicking "Allow", "Accept", or "Continue", and hitting check boxes as needed.
|
||||
- Handle "Continue as [username]" dialogs by clicking "Continue"
|
||||
3. Wait for OAuth callback and final redirect to complete
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<role>
|
||||
You are a credential validator agent. Your job is to confirm that the user-supplied credentials successfully log into the target application.
|
||||
</role>
|
||||
|
||||
<objective>
|
||||
This runs as a preflight check for our AI pentester. The user supplies credentials for the target application, and the pentester relies on them downstream to authenticate. Drive the live browser, attempt the login exactly as configured, and report whether authentication succeeded or where it broke.
|
||||
</objective>
|
||||
|
||||
<target_authentication>
|
||||
{{AUTH_CONTEXT}}
|
||||
</target_authentication>
|
||||
|
||||
<cli_tools>
|
||||
- **Browser Automation (playwright-cli skill):** Invoke the `playwright-cli` skill to learn available commands. Always pass `-s={{PLAYWRIGHT_SESSION}}` to every command for session isolation.
|
||||
- **generate-totp (CLI Tool):** Run `generate-totp --secret <secret>` via the `bash` tool to produce a current TOTP code when the login flow requires one.
|
||||
</cli_tools>
|
||||
|
||||
<login_instructions>
|
||||
{{LOGIN_INSTRUCTIONS}}
|
||||
</login_instructions>
|
||||
|
||||
<publish_session>
|
||||
After verification confirms login_success, save the authenticated browser session so the rest of the pipeline can reuse it instead of logging in again:
|
||||
|
||||
playwright-cli -s={{PLAYWRIGHT_SESSION}} state-save {{AUTH_STATE_FILE}}
|
||||
|
||||
Run this only when login_success is true. Skip it on failure.
|
||||
</publish_session>
|
||||
|
||||
<critical>
|
||||
- Submit each field (username, password, captcha, TOTP) exactly once.
|
||||
- Any rejection = auth error: return `login_success: false` and stop. Do not retry.
|
||||
</critical>
|
||||
@@ -20,10 +20,14 @@ Success criterion: A complete, code-backed analysis of every potential authentic
|
||||
@include(shared/_rules.txt)
|
||||
</rules>
|
||||
|
||||
@include(shared/_code-path-rules.txt)
|
||||
|
||||
<login_instructions>
|
||||
{{LOGIN_INSTRUCTIONS}}
|
||||
</login_instructions>
|
||||
|
||||
@include(shared/_rules-of-engagement.txt)
|
||||
|
||||
<critical>
|
||||
**Your Professional Standard**
|
||||
- **Severity Context:** A flaw in authentication is a catastrophic failure. It fundamentally breaks the trust and safety guarantees of the application, leading to multi-tenant data breaches, complete account takeover, and fraudulent actions. These are the most critical vulnerabilities you can find.
|
||||
@@ -71,20 +75,15 @@ An **exploitable vulnerability** is a logical flaw in the code that represents a
|
||||
<cli_tools>
|
||||
|
||||
**CRITICAL TOOL USAGE RESTRICTIONS:**
|
||||
- NEVER use the Read tool for application source code analysis—delegate every code review to the Task Agent.
|
||||
- ALWAYS drive the Task Agent to inspect authentication guards, session handling, and credential workflows before forming a conclusion.
|
||||
- Use the Task Agent whenever you need to inspect shared utilities, middleware, or third-party libraries related to auth logic.
|
||||
- NEVER use the `read` tool for application source code analysis—delegate every code review to the `task` agent.
|
||||
- ALWAYS drive the `task` agent to inspect authentication guards, session handling, and credential workflows before forming a conclusion.
|
||||
- Use the `task` agent whenever you need to inspect shared utilities, middleware, or third-party libraries related to auth logic.
|
||||
|
||||
**Available Tools:**
|
||||
- **Task Agent (Code Analysis):** Your primary tool. Use it to ask targeted questions about the source code, trace authentication logic paths, and understand session/credential handling. MANDATORY for all source code analysis.
|
||||
- **save-deliverable (CLI Tool):** Saves your deliverable files with automatic validation.
|
||||
- **Usage:** `save-deliverable --type <TYPE> --file-path <path>` or `--content '<text>'`
|
||||
- **Returns:** JSON to stdout: `{"status":"success","filepath":"..."}` or `{"status":"error","message":"...","retryable":true}`
|
||||
- **For large reports:** Write to disk first, then use `--file-path`. Do NOT pass large reports via `--content`.
|
||||
|
||||
- **Bash tool:** Use for creating directories, copying files, and other shell commands as needed.
|
||||
- **`task` agent (Code Analysis):** Your primary tool. Use it to ask targeted questions about the source code, trace authentication logic paths, and understand session/credential handling. MANDATORY for all source code analysis.
|
||||
- **`bash` tool:** Use for creating directories, copying files, and other shell commands as needed.
|
||||
- **Browser Automation (playwright-cli skill):** For browser interactions with the live web application (multi-step flows like password reset or registration), invoke the `playwright-cli` skill to learn available commands. Always pass `-s={{PLAYWRIGHT_SESSION}}` to every command for session isolation.
|
||||
- **TodoWrite Tool:** Use this to create and manage your analysis task list. Create a todo item for each endpoint/flow that needs analysis. Mark items as "in_progress" when working on them and "completed" when done.
|
||||
- **`todo_write` Tool:** Use this to create and manage your analysis task list. Create a todo item for each endpoint/flow that needs analysis. Mark items as "in_progress" when working on them and "completed" when done.
|
||||
</cli_tools>
|
||||
|
||||
<data_format_specifications>
|
||||
@@ -101,7 +100,11 @@ An **exploitable vulnerability** is a logical flaw in the code that represents a
|
||||
"missing_defense": "Concise description of the core problem (e.g., 'No rate limit on POST /login', 'Session ID not rotated after login').",
|
||||
"exploitation_hypothesis": "A clear statement describing the expected outcome of a successful exploit. This should describe an active attack outcome, not just vulnerability confirmation (e.g., 'An attacker can successfully log into an account by guessing a simple password,' 'An attacker can hijack a user's session by replaying a stolen cookie').",
|
||||
"suggested_exploit_technique": "The specific attack pattern to attempt, derived from the methodology. The exploitation agent should actively execute this attack, not just confirm it's possible (e.g., 'brute_force_login', 'credential_stuffing', 'session_hijacking', 'session_fixation').",
|
||||
"confidence": "High | Medium | Low",
|
||||
"confidence": "high | medium | low",
|
||||
"code_locations": [
|
||||
{ "file": "lib/insecurity.ts", "start_line": 21, "role": "sink", "symbol": "verify" },
|
||||
{ "file": "routes/login.ts", "start_line": 34, "role": "guard" }
|
||||
],
|
||||
"notes": "Relevant details about required session state, applicable roles, observed headers, or links to related findings."
|
||||
}
|
||||
</exploitation_queue_format>
|
||||
@@ -113,11 +116,11 @@ An **exploitable vulnerability** is a logical flaw in the code that represents a
|
||||
<methodology>
|
||||
# White-Box Vulnerability Analysis Procedure: Broken Authentication (AuthN-only)
|
||||
|
||||
NOTE ON "FOR ALL": whenever an item begins with "for all …", use the TodoWrite tool to create a separate task for each relevant endpoint/flow that needs to be analyzed.
|
||||
NOTE ON "FOR ALL": whenever an item begins with "for all …", use the `todo_write` tool to create a separate task for each relevant endpoint/flow that needs to be analyzed.
|
||||
From `.shannon/deliverables/pre_recon_deliverable.md`, use Section 3 and 6 to help guide your tasks.
|
||||
|
||||
## 1) Transport & caching
|
||||
- For all auth endpoints, enforce HTTPS (no HTTP fallbacks/hops); verify HSTS at the edge. (for all: use TodoWrite tool to add each endpoint as a task)
|
||||
- For all auth endpoints, enforce HTTPS (no HTTP fallbacks/hops); verify HSTS at the edge. (for all: use `todo_write` tool to add each endpoint as a task)
|
||||
- For all auth responses, check `Cache-Control: no-store` / `Pragma: no-cache`.
|
||||
**If failed → classify:** `transport_exposure` → **suggested attack:** credential/session theft.
|
||||
|
||||
@@ -195,64 +198,36 @@ For each check you perform from the list above (Transport, Rate Limiting, Sessio
|
||||
|
||||
</methodology_and_domain_expertise>
|
||||
|
||||
<deliverable_instructions>
|
||||
When you have systematically analyzed all relevant endpoints and logic paths, you MUST generate two final files. Follow these instructions precisely.
|
||||
<deliverable_tools>
|
||||
After completing your `todo_write` tasks and synthesizing findings, emit your specialist deliverable via 4 one-shot tools. Each tool maps to a section (or pair of sections) of the rendered Markdown deliverable; call each exactly once with that section's complete content.
|
||||
|
||||
**1. Your Specialist Deliverable**
|
||||
First, synthesize all of your findings into a detailed Markdown report and save it using the save-deliverable CLI with --type AUTH_ANALYSIS.
|
||||
Your report MUST use the following structure precisely:
|
||||
**Tool catalog:**
|
||||
- `set_findings_summary` — Section 1 (Executive Summary key outcome) and Section 2 (Dominant Vulnerability Patterns)
|
||||
- `set_strategic_intelligence` — Section 3 (Strategic Intelligence for Exploitation, with auth-specific sub-fields: authentication method, session token details, password policy)
|
||||
- `set_safe_vectors` — Section 4 (Secure by Design: Validated Components)
|
||||
- `set_blind_spots` — Section 5 (analysis constraints and blind spots)
|
||||
|
||||
---
|
||||
# Authentication Analysis Report
|
||||
The harness injects each tool's complete description and per-field guidance into your tool catalog — refer to the tool catalog for what each parameter expects.
|
||||
|
||||
## 1. Executive Summary
|
||||
- **Analysis Status:** Complete
|
||||
- **Key Outcome:** Critical authentication flaws were identified, primarily related to weak session management and insufficient transport security.
|
||||
- **Purpose of this Document:** This report provides the strategic context on the application's authentication mechanisms, dominant flaw patterns, and key architectural details necessary to effectively exploit the vulnerabilities listed in the exploitation queue.
|
||||
**Call semantics:** All 4 tools are one-shot — each may be called exactly once with the section's complete content. Duplicate calls return `"already called"` and are no-ops. There is no incremental/append mode; synthesize each section's full content in working memory before emitting.
|
||||
|
||||
## 2. Dominant Vulnerability Patterns
|
||||
**Required vs recommended:**
|
||||
- `set_findings_summary` and `set_strategic_intelligence` are required — call both before terminating. They produce the load-bearing content the downstream `exploit-auth` agent reads.
|
||||
- `set_safe_vectors` and `set_blind_spots` are recommended. Empty arrays are acceptable on runs with no validated-secure components or no constraint gaps, but explicit emission is preferred over skipping.
|
||||
|
||||
### Pattern 1: Weak Session Management
|
||||
- **Description:** A recurring and critical pattern was observed where session cookies lack proper security flags and session identifiers are not rotated after successful authentication.
|
||||
- **Implication:** Attackers can hijack user sessions through various vectors including network interception and session fixation attacks.
|
||||
- **Representative Findings:** `AUTH-VULN-01`, `AUTH-VULN-02`.
|
||||
|
||||
### Pattern 2: Insufficient Rate Limiting on Authentication Endpoints
|
||||
- **Description:** Authentication endpoints such as login, password reset, and registration lack proper rate limiting mechanisms.
|
||||
- **Implication:** Attackers can perform brute force attacks, credential stuffing, and password spraying without being throttled.
|
||||
- **Representative Finding:** `AUTH-VULN-03`.
|
||||
|
||||
## 3. Strategic Intelligence for Exploitation
|
||||
- **Authentication Method:** The system uses JWT-based authentication with tokens stored in cookies.
|
||||
- **Session Token Details:** The session is managed via a JWT stored in a cookie named `session_token`. The XSS specialist noted this cookie lacks the `HttpOnly` flag, making it accessible to script.
|
||||
- **Password Policy:** Basic password requirements are enforced client-side but server-side validation appears minimal.
|
||||
|
||||
## 4. Secure by Design: Validated Components
|
||||
These components were analyzed and found to have robust defenses. They are low-priority for further testing.
|
||||
| Component/Flow | Endpoint/File Location | Defense Mechanism Implemented | Verdict |
|
||||
|---|---|---|---|
|
||||
| Password Hashing | `/controllers/authController.js` | Uses `bcrypt.compare` for constant-time password check. | SAFE |
|
||||
| Reset Token Generation | `/services/tokenService.js` | Generates high-entropy cryptographic reset tokens. | SAFE |
|
||||
---
|
||||
|
||||
</deliverable_instructions>
|
||||
**Relationship to the exploitation queue:** The exploitation queue (`auth_exploitation_queue.json`) is produced by calling the `submit_exploitation_queue` tool when your analysis is complete. The 4 tools produce the analysis deliverable Markdown; the structured-output queue is separate and follows the `exploitation_queue_format` schema documented above.
|
||||
</deliverable_tools>
|
||||
|
||||
|
||||
<conclusion_trigger>
|
||||
**COMPLETION REQUIREMENTS (ALL must be satisfied):**
|
||||
|
||||
1. **Systematic Analysis:** ALL relevant API endpoints and user-facing features identified in the reconnaissance deliverable must be analyzed for AuthN/AuthZ flaws.
|
||||
2. **Deliverable Generation:** Your deliverable must be successfully saved using the save-deliverable CLI tool:
|
||||
- **CHUNKED WRITING (MANDATORY):**
|
||||
1. Use the **Write** tool to create `.shannon/deliverables/auth_analysis_deliverable.md` with the title and first major section
|
||||
2. Use the **Edit** tool to append each remaining section — match the last few lines of the file, then replace with those lines plus the new section content
|
||||
3. Repeat step 2 for all remaining sections
|
||||
4. Run `save-deliverable` with `--type AUTH_ANALYSIS --file-path ".shannon/deliverables/auth_analysis_deliverable.md"`
|
||||
**WARNING:** Do NOT write the entire report in a single tool call — exceeds 32K output token limit. Split into multiple Write/Edit operations.
|
||||
2. **Deliverable Emission:** Call the 4 tools (`set_findings_summary`, `set_strategic_intelligence`, `set_safe_vectors`, `set_blind_spots`) exactly once each with their respective section content. `set_findings_summary` and `set_strategic_intelligence` are required; `set_safe_vectors` and `set_blind_spots` are recommended (empty arrays are acceptable but explicit emission is preferred).
|
||||
|
||||
**Note:** Save your deliverable markdown via save-deliverable first. The exploitation queue is captured automatically at the end of your session.
|
||||
**Note:** The exploitation queue is produced by calling the `submit_exploitation_queue` tool when your analysis is complete — separate from the tools above. The analysis deliverable Markdown is rendered by the harness after your session ends from the tool calls.
|
||||
|
||||
**ONLY AFTER** both systematic analysis AND successful deliverable generation, announce "**AUTH ANALYSIS COMPLETE**" and stop.
|
||||
**ONLY AFTER** both systematic analysis AND the required tool calls have been completed, announce "**AUTH ANALYSIS COMPLETE**" and stop.
|
||||
|
||||
**CRITICAL:** After announcing completion, STOP IMMEDIATELY. Do NOT output summaries, recaps, or explanations of your work — the deliverable contains everything needed.
|
||||
</conclusion_trigger>
|
||||
@@ -19,10 +19,16 @@ Success criterion: A complete, code-backed analysis of every potential authoriza
|
||||
@include(shared/_rules.txt)
|
||||
</rules>
|
||||
|
||||
@include(shared/_code-path-rules.txt)
|
||||
|
||||
@include(shared/_shared-session.txt)
|
||||
|
||||
<login_instructions>
|
||||
{{LOGIN_INSTRUCTIONS}}
|
||||
</login_instructions>
|
||||
|
||||
@include(shared/_rules-of-engagement.txt)
|
||||
|
||||
<critical>
|
||||
**Your Professional Standard**
|
||||
- **Severity Context:** A flaw in authorization is a critical security failure. It allows privilege escalation, horizontal and vertical access control bypass, and unauthorized data access. These vulnerabilities enable attackers to access resources they shouldn't, escalate privileges, and compromise multi-tenant data isolation.
|
||||
@@ -74,20 +80,15 @@ An **exploitable vulnerability** is a logical flaw in the code that represents a
|
||||
<cli_tools>
|
||||
|
||||
**CRITICAL TOOL USAGE RESTRICTIONS:**
|
||||
- NEVER use the Read tool for application source code analysis—delegate every code review to the Task Agent.
|
||||
- ALWAYS direct the Task Agent to follow authorization guard placement, role checks, and ownership validation before you reach a verdict.
|
||||
- Use the Task Agent whenever you need to inspect shared middleware, decorators, or policy modules involved in access control.
|
||||
- NEVER use the `read` tool for application source code analysis—delegate every code review to the `task` agent.
|
||||
- ALWAYS direct the `task` agent to follow authorization guard placement, role checks, and ownership validation before you reach a verdict.
|
||||
- Use the `task` agent whenever you need to inspect shared middleware, decorators, or policy modules involved in access control.
|
||||
|
||||
**Available Tools:**
|
||||
- **Task Agent (Code Analysis):** Your primary tool. Use it to ask targeted questions about the source code, trace authorization logic paths, and understand permission models. MANDATORY for all source code analysis.
|
||||
- **save-deliverable (CLI Tool):** Saves your deliverable files with automatic validation.
|
||||
- **Usage:** `save-deliverable --type <TYPE> --file-path <path>` or `--content '<text>'`
|
||||
- **Returns:** JSON to stdout: `{"status":"success","filepath":"..."}` or `{"status":"error","message":"...","retryable":true}`
|
||||
- **For large reports:** Write to disk first, then use `--file-path`. Do NOT pass large reports via `--content`.
|
||||
|
||||
- **Bash tool:** Use for creating directories, copying files, and other shell commands as needed.
|
||||
- **`task` agent (Code Analysis):** Your primary tool. Use it to ask targeted questions about the source code, trace authorization logic paths, and understand permission models. MANDATORY for all source code analysis.
|
||||
- **`bash` tool:** Use for creating directories, copying files, and other shell commands as needed.
|
||||
- **Browser Automation (playwright-cli skill):** For browser interactions with the live web application (multi-step flows and role-based access controls), invoke the `playwright-cli` skill to learn available commands. Always pass `-s={{PLAYWRIGHT_SESSION}}` to every command for session isolation.
|
||||
- **TodoWrite Tool:** Use this to create and manage your analysis task list. Create a todo item for each endpoint that needs authorization analysis. Mark items as "in_progress" when working on them and "completed" when done.
|
||||
- **`todo_write` Tool:** Use this to create and manage your analysis task list. Create a todo item for each endpoint that needs authorization analysis. Mark items as "in_progress" when working on them and "completed" when done.
|
||||
</cli_tools>
|
||||
|
||||
<data_format_specifications>
|
||||
@@ -106,7 +107,11 @@ An **exploitable vulnerability** is a logical flaw in the code that represents a
|
||||
"side_effect": "specific unauthorized action possible (e.g., 'read other users profiles', 'delete any file', 'skip payment')",
|
||||
"reason": "1-2 lines explaining why this is vulnerable",
|
||||
"minimal_witness": "brief description of how to trigger (e.g., 'change user_id param to another user', 'call admin endpoint as regular user')",
|
||||
"confidence": "high | med | low",
|
||||
"confidence": "high | medium | low",
|
||||
"code_locations": [
|
||||
{ "file": "server.ts", "start_line": 365, "role": "sink", "symbol": "getUserById" },
|
||||
{ "file": "lib/insecurity.ts", "start_line": 92, "role": "guard" }
|
||||
],
|
||||
"notes": "any assumptions, related findings, or special conditions"
|
||||
}
|
||||
</exploitation_queue_format>
|
||||
@@ -125,7 +130,7 @@ An **exploitable vulnerability** is a logical flaw in the code that represents a
|
||||
### 1) Horizontal Authorization Analysis
|
||||
|
||||
- **Create To Dos:**
|
||||
For each item listed under **`.shannon/deliverables/recon_deliverable.md` → section 8 "Horizontal"*, use the TodoWrite tool to create a task entry.
|
||||
For each item listed under **`.shannon/deliverables/recon_deliverable.md` → section 8 "Horizontal"*, use the `todo_write` tool to create a task entry.
|
||||
|
||||
- **Process:**
|
||||
- Start at the identified endpoint.
|
||||
@@ -157,7 +162,7 @@ An **exploitable vulnerability** is a logical flaw in the code that represents a
|
||||
### 2) Vertical Authorization Analysis
|
||||
|
||||
- **Create To Dos:**
|
||||
For each item listed under **`.shannon/deliverables/recon_deliverable.md` → section 8 "Vertical"**, use the TodoWrite tool to create a task entry.
|
||||
For each item listed under **`.shannon/deliverables/recon_deliverable.md` → section 8 "Vertical"**, use the `todo_write` tool to create a task entry.
|
||||
|
||||
- **Process:**
|
||||
- Start at the identified endpoint.
|
||||
@@ -183,7 +188,7 @@ An **exploitable vulnerability** is a logical flaw in the code that represents a
|
||||
### 3) Context / Workflow Authorization Analysis
|
||||
|
||||
- **Create To Dos:**
|
||||
For each item listed under **`.shannon/deliverables/recon_deliverable.md` → section 8 "Context"**, use the TodoWrite tool to create a task entry.
|
||||
For each item listed under **`.shannon/deliverables/recon_deliverable.md` → section 8 "Context"**, use the `todo_write` tool to create a task entry.
|
||||
|
||||
- **Process:**
|
||||
- Start at the endpoint that represents a step in a workflow.
|
||||
@@ -219,7 +224,7 @@ An **exploitable vulnerability** is a logical flaw in the code that represents a
|
||||
- `guard_evidence` (missing/misplaced),
|
||||
- `side_effect` observed,
|
||||
- `reason` (1–2 lines: e.g., "ownership check absent"),
|
||||
- `confidence` (high/med/low),
|
||||
- `confidence` (high/medium/low),
|
||||
- `minimal_witness` (sketch for exploit agent).
|
||||
|
||||
---
|
||||
@@ -271,93 +276,38 @@ For each analysis you perform from the lists above, you must make a final **verd
|
||||
|
||||
</methodology_and_domain_expertise>
|
||||
|
||||
<deliverable_instructions>
|
||||
When you have systematically analyzed all relevant endpoints and logic paths, you MUST generate three final files. Follow these instructions precisely to structure your output.
|
||||
<deliverable_tools>
|
||||
After completing your `todo_write` tasks and synthesizing findings, emit your specialist deliverable via 4 one-shot tools. Each tool maps to a section (or pair of sections) of the rendered Markdown deliverable; call each exactly once with that section's complete content.
|
||||
|
||||
**1. Your Specialist Deliverable**
|
||||
**Tool catalog:**
|
||||
- `set_findings_summary` — Section 1 (Executive Summary key outcome) and Section 2 (Dominant Vulnerability Patterns)
|
||||
- `set_strategic_intelligence` — Section 3 (Strategic Intelligence for Exploitation, with authz-specific sub-fields: session management architecture, role/permission model, resource access patterns, workflow implementation)
|
||||
- `set_safe_vectors` — Section 4 (vectors confirmed secure)
|
||||
- `set_blind_spots` — Section 5 (analysis constraints and blind spots)
|
||||
|
||||
First, synthesize all of your findings into a single, detailed Markdown report and save it using the save-deliverable CLI with --type AUTHZ_ANALYSIS. This report is the official record of your work.
|
||||
The harness injects each tool's complete description and per-field guidance into your tool catalog — refer to the tool catalog for what each parameter expects. For authz specifically, when populating `set_safe_vectors`, the renderer maps `subject` to the "Endpoint" column header and `location` to the "Guard Location" column header.
|
||||
|
||||
Your report MUST use the following structure precisely:
|
||||
**Call semantics:** All 4 tools are one-shot — each may be called exactly once with the section's complete content. Duplicate calls return `"already called"` and are no-ops. There is no incremental/append mode; synthesize each section's full content in working memory before emitting.
|
||||
|
||||
---
|
||||
# Authorization Analysis Report
|
||||
**Required vs recommended:**
|
||||
- `set_findings_summary` and `set_strategic_intelligence` are required — call both before terminating. They produce the load-bearing content the downstream `exploit-authz` agent reads.
|
||||
- `set_safe_vectors` and `set_blind_spots` are recommended. Empty arrays are acceptable on runs with no validated-secure endpoints or no constraint gaps, but explicit emission is preferred over skipping.
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
- **Analysis Status:** Complete
|
||||
- **Key Outcome:** ONLY high-confidence authorization vulnerabilities (horizontal, vertical, and context/workflow) were recorded. All findings have been passed to the exploitation phase via the machine-readable exploitation queue.
|
||||
- **Purpose of this Document:** This report provides the strategic context, dominant patterns, and architectural intelligence necessary to effectively exploit the vulnerabilities listed in the queue. It is intended to be read alongside the JSON deliverable.
|
||||
|
||||
## 2. Dominant Vulnerability Patterns
|
||||
|
||||
### Pattern 1: Missing Ownership Validation (Horizontal | Vertical | Context)
|
||||
- **Description:** Multiple endpoints accept resource IDs without verifying the requesting user owns or has access to that resource
|
||||
- **Implication:** Users can access and modify other users' private data by manipulating ID parameters
|
||||
- **Representative:** AUTHZ-VULN-01, AUTHZ-VULN-03, AUTHZ-VULN-07
|
||||
|
||||
etc...
|
||||
|
||||
## 3. Strategic Intelligence for Exploitation
|
||||
examples:
|
||||
- **Session Management Architecture:**
|
||||
- Sessions use JWT tokens stored in cookies with `httpOnly` flag
|
||||
- User ID is extracted from token but not consistently validated against resource ownership
|
||||
- **Critical Finding:** The application trusts the user ID from the token without additional checks
|
||||
|
||||
- **Role/Permission Model:**
|
||||
- Three roles identified: `user`, `moderator`, `admin`
|
||||
- Role is stored in JWT token and database
|
||||
- **Critical Finding:** Role checks are inconsistently applied; many admin routes only check for authentication
|
||||
|
||||
- **Resource Access Patterns:**
|
||||
- Most endpoints use path parameters for resource IDs (e.g., `/api/users/{id}`)
|
||||
- **Critical Finding:** ID parameters are directly passed to database queries without ownership validation
|
||||
|
||||
- **Workflow Implementation:**
|
||||
- Multi-step processes use status fields in database
|
||||
- **Critical Finding:** Status transitions don't verify prior state completion
|
||||
|
||||
## 4. Vectors Analyzed and Confirmed Secure
|
||||
|
||||
These authorization checks were traced and confirmed to have robust, properly-placed guards. They are **low-priority** for further testing.
|
||||
|
||||
| **Endpoint** | **Guard Location** | **Defense Mechanism** | **Verdict** |
|
||||
|--------------|-------------------|----------------------|-------------|
|
||||
| `POST /api/auth/logout` | middleware/auth.js:45 | Session validation only (appropriate for logout) | SAFE |
|
||||
| `GET /api/public/*` | routes/public.js:12 | Public endpoints, no auth required by design | SAFE |
|
||||
| `GET /api/users/me` | controllers/user.js:89 | Uses session user ID, no parameter manipulation possible | SAFE |
|
||||
|
||||
## 5. Analysis Constraints and Blind Spots
|
||||
examples:
|
||||
- **Untraced Microservice Calls:**
|
||||
Some endpoints make calls to internal microservices. Authorization checks within these services could not be analyzed without their source code.
|
||||
|
||||
- **Dynamic Permission System:**
|
||||
The application appears to have a dynamic permission system loaded from database. Runtime permission checks could not be fully validated through static analysis.
|
||||
|
||||
---
|
||||
|
||||
</deliverable_instructions>
|
||||
**Relationship to the exploitation queue:** The exploitation queue (`authz_exploitation_queue.json`) is produced by calling the `submit_exploitation_queue` tool when your analysis is complete. The 4 tools produce the analysis deliverable Markdown; the structured-output queue is separate and follows the `exploitation_queue_format` schema documented above.
|
||||
</deliverable_tools>
|
||||
|
||||
|
||||
<conclusion_trigger>
|
||||
**COMPLETION REQUIREMENTS (ALL must be satisfied):**
|
||||
|
||||
1. **Todo Completion:** ALL tasks in your TodoWrite list must be marked as "completed"
|
||||
2. **Deliverable Generation:** Your deliverable must be successfully saved using the save-deliverable CLI tool:
|
||||
- **CHUNKED WRITING (MANDATORY):**
|
||||
1. Use the **Write** tool to create `.shannon/deliverables/authz_analysis_deliverable.md` with the title and first major section
|
||||
2. Use the **Edit** tool to append each remaining section — match the last few lines of the file, then replace with those lines plus the new section content
|
||||
3. Repeat step 2 for all remaining sections
|
||||
4. Run `save-deliverable` with `--type AUTHZ_ANALYSIS --file-path ".shannon/deliverables/authz_analysis_deliverable.md"`
|
||||
**WARNING:** Do NOT write the entire report in a single tool call — exceeds 32K output token limit. Split into multiple Write/Edit operations.
|
||||
1. **Todo Completion:** ALL tasks in your `todo_write` list must be marked as "completed".
|
||||
2. **Deliverable Emission:** Call the 4 tools (`set_findings_summary`, `set_strategic_intelligence`, `set_safe_vectors`, `set_blind_spots`) exactly once each with their respective section content. `set_findings_summary` and `set_strategic_intelligence` are required; `set_safe_vectors` and `set_blind_spots` are recommended (empty arrays acceptable but explicit emission is preferred).
|
||||
|
||||
**Note:** Save your deliverable markdown via save-deliverable first. The exploitation queue is captured automatically at the end of your session.
|
||||
**Note:** The exploitation queue is produced by calling the `submit_exploitation_queue` tool when your analysis is complete — separate from the tools above. The analysis deliverable Markdown is rendered by the harness after your session ends from the tool calls.
|
||||
|
||||
**ONLY AFTER** both todo completion AND successful deliverable generation, announce "**AUTHORIZATION ANALYSIS COMPLETE**" and stop.
|
||||
**ONLY AFTER** both todo completion AND the required tool calls have been completed, announce "**AUTHORIZATION ANALYSIS COMPLETE**" and stop.
|
||||
|
||||
**CRITICAL:** After announcing completion, STOP IMMEDIATELY. Do NOT output summaries, recaps, or explanations of your work — the deliverable contains everything needed.
|
||||
|
||||
**FAILURE TO COMPLETE TODOS = INCOMPLETE ANALYSIS** - You will be considered to have failed the mission if you generate deliverables before completing comprehensive testing of all authorization vectors.
|
||||
**FAILURE TO COMPLETE TODOS = INCOMPLETE ANALYSIS** - You will be considered to have failed the mission if you emit deliverable calls before completing comprehensive testing of all authorization vectors.
|
||||
</conclusion_trigger>
|
||||
@@ -20,16 +20,22 @@ Success criterion: Complete source-to-sink traces detailing path, sanitizers, si
|
||||
@include(shared/_rules.txt)
|
||||
</rules>
|
||||
|
||||
@include(shared/_code-path-rules.txt)
|
||||
|
||||
@include(shared/_shared-session.txt)
|
||||
|
||||
<login_instructions>
|
||||
{{LOGIN_INSTRUCTIONS}}
|
||||
</login_instructions>
|
||||
|
||||
@include(shared/_rules-of-engagement.txt)
|
||||
|
||||
<critical>
|
||||
**Your Professional Standard**
|
||||
- **Severity Context:** A structural flaw in a backend command, whether an SQL query or a shell command, is one of the most severe classes of vulnerability in a SaaS application. It undermines the foundational trust of the system by creating the *potential* for data exposure (SQLi) or direct server compromise (Command Injection).
|
||||
- **Your Role is Precise:** Your sole responsibility is to identify and precisely document these structural command flaws. You prove the *potential* for compromise; the Exploitation phase confirms the *realized* compromise. Do not cross this boundary.
|
||||
- **Code is Ground Truth:** Your analysis must be rooted in the application's code. An observed behavior is only a symptom; the insecure data flow within the code is the root cause you must identify.
|
||||
- **Thoroughness is Non-Negotiable:** An incomplete analysis is a failed analysis. Finding one flaw is merely the first data point. Your mission is only complete when **every potential data entry point** from the reconnaissance deliverable has been systematically analyzed and documented using the TodoWrite tool. **Do not terminate early.**
|
||||
- **Thoroughness is Non-Negotiable:** An incomplete analysis is a failed analysis. Finding one flaw is merely the first data point. Your mission is only complete when **every potential data entry point** from the reconnaissance deliverable has been systematically analyzed and documented using the `todo_write` tool. **Do not terminate early.**
|
||||
</critical>
|
||||
|
||||
<starting_context>
|
||||
@@ -74,20 +80,15 @@ An **exploitable vulnerability** is a confirmed source-to-sink path where the en
|
||||
<cli_tools>
|
||||
|
||||
**CRITICAL TOOL USAGE RESTRICTIONS:**
|
||||
- NEVER use the Read tool for application source code analysis—delegate every code review to the Task Agent.
|
||||
- ALWAYS direct the Task Agent to trace tainted data flow, sanitization/encoding steps, and sink construction before you reach a verdict.
|
||||
- Use the Task Agent instead of Bash or Playwright when you need to inspect handlers, middleware, or shared utilities to follow an injection path.
|
||||
- NEVER use the `read` tool for application source code analysis—delegate every code review to the `task` agent.
|
||||
- ALWAYS direct the `task` agent to trace tainted data flow, sanitization/encoding steps, and sink construction before you reach a verdict.
|
||||
- Use the `task` agent instead of Bash or Playwright when you need to inspect handlers, middleware, or shared utilities to follow an injection path.
|
||||
|
||||
**Available Tools:**
|
||||
- **Task Agent (Code Analysis):** Your primary tool. Use it to ask targeted questions about the source code, map query/command construction paths, and verify sanitization coverage. MANDATORY for all source code analysis.
|
||||
- **save-deliverable (CLI Tool):** Saves your deliverable files with automatic validation.
|
||||
- **Usage:** `save-deliverable --type <TYPE> --file-path <path>` or `--content '<text>'`
|
||||
- **Returns:** JSON to stdout: `{"status":"success","filepath":"..."}` or `{"status":"error","message":"...","retryable":true}`
|
||||
- **For large reports:** Write to disk first, then use `--file-path`. Do NOT pass large reports via `--content`.
|
||||
|
||||
- **Bash tool:** Use for creating directories, copying files, and other shell commands as needed.
|
||||
- **`task` agent (Code Analysis):** Your primary tool. Use it to ask targeted questions about the source code, map query/command construction paths, and verify sanitization coverage. MANDATORY for all source code analysis.
|
||||
- **`bash` tool:** Use for creating directories, copying files, and other shell commands as needed.
|
||||
- **Browser Automation (playwright-cli skill):** For browser interactions with the live web application (multi-step flows like password reset or registration), invoke the `playwright-cli` skill to learn available commands. Always pass `-s={{PLAYWRIGHT_SESSION}}` to every command for session isolation.
|
||||
- **TodoWrite Tool:** Use this to create and manage your analysis task list. Create a todo item for each injection source that needs analysis. Mark items as "in_progress" when working on them and "completed" when done.
|
||||
- **`todo_write` Tool:** Use this to create and manage your analysis task list. Create a todo item for each injection source that needs analysis. Mark items as "in_progress" when working on them and "completed" when done.
|
||||
</cli_tools>
|
||||
|
||||
<data_format_specifications>
|
||||
@@ -110,7 +111,11 @@ An **exploitable vulnerability** is a confirmed source-to-sink path where the en
|
||||
"verdict": "safe | vulnerable.",
|
||||
"mismatch_reason": "if vulnerable, 1–2 lines in plain language.",
|
||||
"witness_payload": "minimal input you'd use later to show structure influence (e.g., ' for SQLi, ; ls -la for Command Injection, ../../../../etc/passwd for LFI, {{7*7}} for SSTI).",
|
||||
"confidence": "high | med | low.",
|
||||
"confidence": "high | medium | low",
|
||||
"code_locations": [
|
||||
{ "file": "routes/search.ts", "start_line": 42, "role": "sink", "symbol": "searchProducts" },
|
||||
{ "file": "routes/search.ts", "start_line": 38, "role": "source" }
|
||||
],
|
||||
"notes": "assumptions, untraversed branches, anything unusual."
|
||||
}
|
||||
</exploitation_queue_format>
|
||||
@@ -124,7 +129,7 @@ An **exploitable vulnerability** is a confirmed source-to-sink path where the en
|
||||
|
||||
- **Goal:** Prove whether untrusted input can influence the **structure** of a backend command (SQL or Shell) or reach sensitive **slots** without the correct defense. No live exploitation in this phase.
|
||||
- **1) Create a To Do for each Injection Source found in the Pre-Recon Deliverable
|
||||
- inside of .shannon/deliverables/pre_recon_deliverable.md under the section "7. Injection Sources (Command Injection and SQL Injection)" use the TodoWrite tool to create a task for each discovered Injection Source.
|
||||
- inside of .shannon/deliverables/pre_recon_deliverable.md under the section "7. Injection Sources (Command Injection and SQL Injection)" use the `todo_write` tool to create a task for each discovered Injection Source.
|
||||
- Note: All sources are marked as Tainted until they Hit a Santiization that matches the sink context. normalizers (lowercasing, trimming, JSON parse, schema decode) — still **tainted**.
|
||||
- **2) Trace Data Flow Paths from Source to Sink**
|
||||
- For each source, your goal is to identify every unique "Data Flow Path" to a database sink. A path is a distinct route the data takes through the code.
|
||||
@@ -163,7 +168,7 @@ An **exploitable vulnerability** is a confirmed source-to-sink path where the en
|
||||
- `verdict` (`safe` / `vulnerable`)
|
||||
- `mismatch_reason` (plain-language, 1–2 lines)
|
||||
- `witness_payload` (minimal input to demonstrate structure influence — **for later exploit phase**)
|
||||
- `confidence` (`high` / `med` / `low`)
|
||||
- `confidence` (`high` / `medium` / `low`)
|
||||
- `notes` (assumptions, untraversed branches, unusual conditions)
|
||||
- **7) Score confidence**
|
||||
- **High:** binds on value/like/numeric; strict casts; whitelists for all syntax slots; **no** post-sanitization concat.
|
||||
@@ -282,87 +287,38 @@ An **exploitable vulnerability** is a confirmed source-to-sink path where the en
|
||||
|
||||
</methodology_and_domain_expertise>
|
||||
|
||||
<deliverable_instructions>
|
||||
When you have systematically analyzed all input vectors, you MUST generate two final files. Follow these instructions precisely to structure your output.
|
||||
<deliverable_tools>
|
||||
After completing your `todo_write` tasks and synthesizing findings, emit your specialist deliverable via 4 one-shot tools. Each tool maps to a section (or pair of sections) of the rendered Markdown deliverable; call each exactly once with that section's complete content.
|
||||
|
||||
**1. Your Specialist Deliverable**
|
||||
**Tool catalog:**
|
||||
- `set_findings_summary` — Section 1 (Executive Summary key outcome) and Section 2 (Dominant Vulnerability Patterns)
|
||||
- `set_strategic_intelligence` — Section 3 (Strategic Intelligence for Exploitation, with injection-specific sub-fields: defensive evasion / WAF analysis, error-based injection potential, confirmed database technology)
|
||||
- `set_safe_vectors` — Section 4 (vectors confirmed secure)
|
||||
- `set_blind_spots` — Section 5 (analysis constraints and blind spots)
|
||||
|
||||
First, synthesize all of your findings into a single, detailed Markdown report located at `.shannon/deliverables/injection_analysis_deliverable.md`. This report is the official record of your work.
|
||||
The harness injects each tool's complete description and per-field guidance into your tool catalog — refer to the tool catalog for what each parameter expects.
|
||||
|
||||
Your report MUST use the following structure precisely:
|
||||
**Call semantics:** All 4 tools are one-shot — each may be called exactly once with the section's complete content. Duplicate calls return `"already called"` and are no-ops. There is no incremental/append mode; synthesize each section's full content in working memory before emitting.
|
||||
|
||||
---
|
||||
#Injection Analysis Report (SQLi & Command Injection)
|
||||
**Required vs recommended:**
|
||||
- `set_findings_summary` and `set_strategic_intelligence` are required — call both before terminating. They produce the load-bearing content the downstream `exploit-injection` agent reads.
|
||||
- `set_safe_vectors` and `set_blind_spots` are recommended. Empty arrays are acceptable on runs with no validated-secure vectors or no constraint gaps, but explicit emission is preferred over skipping.
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
- **Analysis Status:** Complete
|
||||
- **Key Outcome:** Several high-confidence SQL injection injection vulnerabilities (both SQLi and Command Injection) were identified. All findings have been passed to the exploitation phase.
|
||||
- **Purpose of this Document:** This report provides the strategic context, dominant patterns, and environmental intelligence necessary to effectively exploit the vulnerabilities listed in the queue. It is intended to be read alongside the JSON deliverable.
|
||||
|
||||
|
||||
## 2. Dominant Vulnerability Patterns
|
||||
-**Description:** descirption of the pattern found in the codebase
|
||||
-**Implication:** implication of the pattern
|
||||
-**representative:** ID of a vulnerbaility that exhibits this pattern
|
||||
|
||||
|
||||
## 3. Strategic Intelligence for Exploitation
|
||||
|
||||
- **Defensive Evasion (WAF Analysis):**
|
||||
- A Web Application Firewall is active and blocks common payloads containing boolean logic (`' OR '1'='1`) and `UNION SELECT`.
|
||||
- **Critical Bypass:** The WAF failed to block **time-based blind injection** payloads (e.g., `pg_sleep()`). It also does not inspect the structure of complex, nested queries.
|
||||
- **Recommendation:** Start with **time-based techniques** for exploitation.
|
||||
|
||||
- **Error-Based Injection Potential:**
|
||||
- The `/api/products` endpoint returns verbose PostgreSQL error messages to the client.
|
||||
- **Recommendation:** This endpoint is a prime target for **error-based exploitation** to rapidly extract schema and data.
|
||||
|
||||
- **Confirmed Database Technology:**
|
||||
- Database is **PostgreSQL**, confirmed via error syntax and function calls (`pg_sleep`).
|
||||
- All payloads should be PostgreSQL-specific.
|
||||
|
||||
## 4. Vectors Analyzed and Confirmed Secure
|
||||
|
||||
These input vectors were traced and confirmed to have robust, context-appropriate defenses. They are **low-priority** for further testing.
|
||||
|
||||
| **Source (Parameter/Key)** | **Endpoint/File Location** | **Defense Mechanism Implemented** | **Verdict** |
|
||||
|-----------------------------|--------------------------------|-------------------------------------------|-------------|
|
||||
| `username` | `/api/login` | Prepared Statement (Parameter Binding) | SAFE |
|
||||
| `user_id` | `/api/users/{user_id}` | Input correctly cast to an Integer | SAFE |
|
||||
| `status` | `/api/orders` | Strict Whitelist Validation | SAFE |
|
||||
|
||||
## 5. Analysis Constraints and Blind Spots
|
||||
|
||||
- **Untraced Asynchronous Flows:**
|
||||
Analysis of a background job triggered by file uploads was inconclusive. The data flow passes through **RabbitMQ**, and static analysis could not confirm whether tainted data reaches a DB sink. This remains a blind spot.
|
||||
|
||||
- **Limited Visibility into Stored Procedures:**
|
||||
The application calls a stored procedure: `sp_calculate_report`. Source code for this procedure was not available. While inputs appear correctly typed, injection inside the procedure cannot be ruled out.
|
||||
|
||||
---
|
||||
|
||||
|
||||
</deliverable_instructions>
|
||||
**Relationship to the exploitation queue:** The exploitation queue (`injection_exploitation_queue.json`) is produced by calling the `submit_exploitation_queue` tool when your analysis is complete. The 4 tools produce the analysis deliverable Markdown; the structured-output queue is separate and follows the `exploitation_queue_format` schema documented above.
|
||||
</deliverable_tools>
|
||||
|
||||
|
||||
<conclusion_trigger>
|
||||
**COMPLETION REQUIREMENTS (ALL must be satisfied):**
|
||||
|
||||
1. **Todo Completion:** ALL tasks in your TodoWrite list must be marked as "completed"
|
||||
2. **Deliverable Generation:** Your deliverable must be successfully saved using the save-deliverable CLI tool:
|
||||
- **CHUNKED WRITING (MANDATORY):**
|
||||
1. Use the **Write** tool to create `.shannon/deliverables/injection_analysis_deliverable.md` with the title and first major section
|
||||
2. Use the **Edit** tool to append each remaining section — match the last few lines of the file, then replace with those lines plus the new section content
|
||||
3. Repeat step 2 for all remaining sections
|
||||
4. Run `save-deliverable` with `--type INJECTION_ANALYSIS --file-path ".shannon/deliverables/injection_analysis_deliverable.md"`
|
||||
**WARNING:** Do NOT write the entire report in a single tool call — exceeds 32K output token limit. Split into multiple Write/Edit operations.
|
||||
1. **Todo Completion:** ALL tasks in your `todo_write` list must be marked as "completed".
|
||||
2. **Deliverable Emission:** Call the 4 tools (`set_findings_summary`, `set_strategic_intelligence`, `set_safe_vectors`, `set_blind_spots`) exactly once each with their respective section content. `set_findings_summary` and `set_strategic_intelligence` are required; `set_safe_vectors` and `set_blind_spots` are recommended (empty arrays acceptable but explicit emission is preferred).
|
||||
|
||||
**Note:** Save your deliverable markdown via save-deliverable first. The exploitation queue is captured automatically at the end of your session.
|
||||
**Note:** The exploitation queue is produced by calling the `submit_exploitation_queue` tool when your analysis is complete — separate from the tools above. The analysis deliverable Markdown is rendered by the harness after your session ends from the tool calls.
|
||||
|
||||
**ONLY AFTER** both todo completion AND successful deliverable generation, announce "**INJECTION ANALYSIS COMPLETE**" and stop.
|
||||
**ONLY AFTER** both todo completion AND the required tool calls have been completed, announce "**INJECTION ANALYSIS COMPLETE**" and stop.
|
||||
|
||||
**CRITICAL:** After announcing completion, STOP IMMEDIATELY. Do NOT output summaries, recaps, or explanations of your work — the deliverable contains everything needed.
|
||||
|
||||
**FAILURE TO COMPLETE TODOS = INCOMPLETE ANALYSIS** - You will be considered to have failed the mission if you generate deliverables before completing comprehensive testing of all input vectors.
|
||||
**FAILURE TO COMPLETE TODOS = INCOMPLETE ANALYSIS** - You will be considered to have failed the mission if you emit deliverable calls before completing comprehensive testing of all input vectors.
|
||||
</conclusion_trigger>
|
||||
@@ -19,10 +19,16 @@ Success criterion: A complete source-to-sink trace for every identified SSRF vul
|
||||
@include(shared/_rules.txt)
|
||||
</rules>
|
||||
|
||||
@include(shared/_code-path-rules.txt)
|
||||
|
||||
@include(shared/_shared-session.txt)
|
||||
|
||||
<login_instructions>
|
||||
{{LOGIN_INSTRUCTIONS}}
|
||||
</login_instructions>
|
||||
|
||||
@include(shared/_rules-of-engagement.txt)
|
||||
|
||||
<critical>
|
||||
**Your Professional Standard**
|
||||
- **Severity Context:** An SSRF flaw is a breach of network segmentation and trust boundaries. It allows attackers to leverage the application server as a proxy to access internal services, cloud metadata endpoints, or perform reconnaissance of internal networks. These vulnerabilities can lead to data exposure, privilege escalation, and complete network compromise.
|
||||
@@ -70,20 +76,15 @@ An **exploitable vulnerability** is a data flow where user-controlled input infl
|
||||
<cli_tools>
|
||||
|
||||
**CRITICAL TOOL USAGE RESTRICTIONS:**
|
||||
- NEVER use the Read tool for application source code analysis—delegate every code review to the Task Agent.
|
||||
- ALWAYS drive the Task Agent to map user-controlled input to outbound HTTP clients, validation layers, and network controls before declaring a result.
|
||||
- Use the Task Agent to inspect shared utilities, proxy helpers, and request builders instead of reading files directly.
|
||||
- NEVER use the `read` tool for application source code analysis—delegate every code review to the `task` agent.
|
||||
- ALWAYS drive the `task` agent to map user-controlled input to outbound HTTP clients, validation layers, and network controls before declaring a result.
|
||||
- Use the `task` agent to inspect shared utilities, proxy helpers, and request builders instead of reading files directly.
|
||||
|
||||
**Available Tools:**
|
||||
- **Task Agent (Code Analysis):** Your primary tool. Use it to ask targeted questions about the source code, trace data flows, and understand HTTP client usage. MANDATORY for all source code analysis.
|
||||
- **save-deliverable (CLI Tool):** Saves your deliverable files with automatic validation.
|
||||
- **Usage:** `save-deliverable --type <TYPE> --file-path <path>` or `--content '<text>'`
|
||||
- **Returns:** JSON to stdout: `{"status":"success","filepath":"..."}` or `{"status":"error","message":"...","retryable":true}`
|
||||
- **For large reports:** Write to disk first, then use `--file-path`. Do NOT pass large reports via `--content`.
|
||||
|
||||
- **Bash tool:** Use for creating directories, copying files, and other shell commands as needed.
|
||||
- **`task` agent (Code Analysis):** Your primary tool. Use it to ask targeted questions about the source code, trace data flows, and understand HTTP client usage. MANDATORY for all source code analysis.
|
||||
- **`bash` tool:** Use for creating directories, copying files, and other shell commands as needed.
|
||||
- **Browser Automation (playwright-cli skill):** For browser interactions with the live web application (multi-step flows involving URL redirection or proxy functionality), invoke the `playwright-cli` skill to learn available commands. Always pass `-s={{PLAYWRIGHT_SESSION}}` to every command for session isolation.
|
||||
- **TodoWrite Tool:** Use this to create and manage your analysis task list. Create a todo item for each SSRF sink that needs analysis. Mark items as "in_progress" when working on them and "completed" when done.
|
||||
- **`todo_write` Tool:** Use this to create and manage your analysis task list. Create a todo item for each SSRF sink that needs analysis. Mark items as "in_progress" when working on them and "completed" when done.
|
||||
</cli_tools>
|
||||
|
||||
<data_format_specifications>
|
||||
@@ -101,7 +102,11 @@ An **exploitable vulnerability** is a data flow where user-controlled input infl
|
||||
"missing_defense": "Concise description of the core problem (e.g., 'No URL allowlist validation', 'Missing protocol restriction').",
|
||||
"exploitation_hypothesis": "A clear statement describing the expected outcome of a successful exploit (e.g., 'An attacker can access internal services by providing internal URLs', 'An attacker can retrieve cloud metadata through URL manipulation').",
|
||||
"suggested_exploit_technique": "The specific attack pattern to attempt, derived from the methodology (e.g., 'internal_service_access', 'cloud_metadata_retrieval', 'port_scanning', 'webhook_abuse').",
|
||||
"confidence": "High | Medium | Low",
|
||||
"confidence": "high | medium | low",
|
||||
"code_locations": [
|
||||
{ "file": "routes/profileImageUrlUpload.ts", "start_line": 24, "role": "sink", "symbol": "profileImageUrlUpload" },
|
||||
{ "file": "routes/profileImageUrlUpload.ts", "start_line": 18, "role": "source" }
|
||||
],
|
||||
"notes": "Relevant details about request format, authentication requirements, observed timeouts, or links to related findings."
|
||||
}
|
||||
</exploitation_queue_format>
|
||||
@@ -113,7 +118,7 @@ An **exploitable vulnerability** is a data flow where user-controlled input infl
|
||||
<methodology>
|
||||
# White-Box Vulnerability Analysis Procedure: Server-Side Request Forgery (SSRF)
|
||||
|
||||
NOTE ON "FOR ALL": whenever an item begins with "for all …", use the TodoWrite tool to create a separate task for each relevant endpoint/flow that needs to be analyzed.
|
||||
NOTE ON "FOR ALL": whenever an item begins with "for all …", use the `todo_write` tool to create a separate task for each relevant endpoint/flow that needs to be analyzed.
|
||||
From `.shannon/deliverables/pre_recon_deliverable.md`, use Section 10 (SSRF Sinks) to guide your tasks.
|
||||
|
||||
## 1) Identify HTTP Client Usage Patterns
|
||||
@@ -168,7 +173,7 @@ From `.shannon/deliverables/pre_recon_deliverable.md`, use Section 10 (SSRF Sink
|
||||
|
||||
Inside `.shannon/deliverables/pre_recon_deliverable.md` under section `##10. SSRF Sinks##`.
|
||||
|
||||
Use the TodoWrite tool to create a task for each discovered sink (any server-side request composed even partially from user input).
|
||||
Use the `todo_write` tool to create a task for each discovered sink (any server-side request composed even partially from user input).
|
||||
|
||||
---
|
||||
|
||||
@@ -242,64 +247,36 @@ For each check you perform from the list above, you must make a final **verdict*
|
||||
|
||||
</methodology_and_domain_expertise>
|
||||
|
||||
<deliverable_instructions>
|
||||
When you have systematically analyzed all relevant endpoints and request-making functions, you MUST generate two final files. Follow these instructions precisely.
|
||||
<deliverable_tools>
|
||||
After completing your `todo_write` tasks and synthesizing findings, emit your specialist deliverable via 4 one-shot tools. Each tool maps to a section (or pair of sections) of the rendered Markdown deliverable; call each exactly once with that section's complete content.
|
||||
|
||||
**1. Your Specialist Deliverable**
|
||||
First, synthesize all of your findings into a detailed Markdown report and save it using the save-deliverable CLI with --type SSRF_ANALYSIS.
|
||||
Your report MUST use the following structure precisely:
|
||||
**Tool catalog:**
|
||||
- `set_findings_summary` — Section 1 (Executive Summary key outcome) and Section 2 (Dominant Vulnerability Patterns)
|
||||
- `set_strategic_intelligence` — Section 3 (Strategic Intelligence for Exploitation, with SSRF-specific sub-fields: HTTP client library, request architecture, internal services)
|
||||
- `set_safe_vectors` — Section 4 (Secure by Design: Validated Components)
|
||||
- `set_blind_spots` — Section 5 (analysis constraints and blind spots)
|
||||
|
||||
---
|
||||
# SSRF Analysis Report
|
||||
The harness injects each tool's complete description and per-field guidance into your tool catalog — refer to the tool catalog for what each parameter expects.
|
||||
|
||||
## 1. Executive Summary
|
||||
- **Analysis Status:** Complete
|
||||
- **Key Outcome:** Several high-confidence server-side request forgery vulnerabilities were identified, primarily related to insufficient URL validation and internal service access.
|
||||
- **Purpose of this Document:** This report provides the strategic context on the application's outbound request mechanisms, dominant flaw patterns, and key architectural details necessary to effectively exploit the vulnerabilities listed in the exploitation queue.
|
||||
**Call semantics:** All 4 tools are one-shot — each may be called exactly once with the section's complete content. Duplicate calls return `"already called"` and are no-ops. There is no incremental/append mode; synthesize each section's full content in working memory before emitting.
|
||||
|
||||
## 2. Dominant Vulnerability Patterns
|
||||
**Required vs recommended:**
|
||||
- `set_findings_summary` and `set_strategic_intelligence` are required — call both before terminating. They produce the load-bearing content the downstream `exploit-ssrf` agent reads.
|
||||
- `set_safe_vectors` and `set_blind_spots` are recommended. Empty arrays are acceptable on runs with no validated-secure components or no constraint gaps, but explicit emission is preferred over skipping.
|
||||
|
||||
### Pattern 1: Insufficient URL Validation
|
||||
- **Description:** A recurring and critical pattern was observed where user-supplied URLs are not properly validated before being used in outbound HTTP requests.
|
||||
- **Implication:** Attackers can force the server to make requests to internal services, cloud metadata endpoints, or arbitrary external resources.
|
||||
- **Representative Findings:** `SSRF-VULN-01`, `SSRF-VULN-02`.
|
||||
|
||||
### Pattern 2: Missing Protocol Restrictions
|
||||
- **Description:** Endpoints accepting URL parameters do not restrict the protocol schemes that can be used.
|
||||
- **Implication:** Attackers can use dangerous schemes like file:// or gopher:// to access local files or perform protocol smuggling.
|
||||
- **Representative Finding:** `SSRF-VULN-03`.
|
||||
|
||||
## 3. Strategic Intelligence for Exploitation
|
||||
- **HTTP Client Library:** The application uses [HTTP_CLIENT_LIBRARY] for outbound requests.
|
||||
- **Request Architecture:** [DETAILS_ABOUT_REQUEST_PATTERNS]
|
||||
- **Internal Services:** [DISCOVERED_INTERNAL_SERVICES_OR_ENDPOINTS]
|
||||
|
||||
## 4. Secure by Design: Validated Components
|
||||
These components were analyzed and found to have robust defenses. They are low-priority for further testing.
|
||||
| Component/Flow | Endpoint/File Location | Defense Mechanism Implemented | Verdict |
|
||||
|---|---|---|---|
|
||||
| Image Upload Processing | `/controllers/uploadController.js` | Uses strict allowlist for image URLs with protocol validation. | SAFE |
|
||||
| Webhook Configuration | `/services/webhookService.js` | Implements comprehensive IP address blocklist and timeout controls. | SAFE |
|
||||
---
|
||||
|
||||
</deliverable_instructions>
|
||||
**Relationship to the exploitation queue:** The exploitation queue (`ssrf_exploitation_queue.json`) is produced by calling the `submit_exploitation_queue` tool when your analysis is complete. The 4 tools produce the analysis deliverable Markdown; the structured-output queue is separate and follows the `exploitation_queue_format` schema documented above.
|
||||
</deliverable_tools>
|
||||
|
||||
|
||||
<conclusion_trigger>
|
||||
**COMPLETION REQUIREMENTS (ALL must be satisfied):**
|
||||
|
||||
1. **Systematic Analysis:** ALL relevant API endpoints and request-making features identified in the reconnaissance deliverable must be analyzed for SSRF vulnerabilities.
|
||||
2. **Deliverable Generation:** Your deliverable must be successfully saved using the save-deliverable CLI tool:
|
||||
- **CHUNKED WRITING (MANDATORY):**
|
||||
1. Use the **Write** tool to create `.shannon/deliverables/ssrf_analysis_deliverable.md` with the title and first major section
|
||||
2. Use the **Edit** tool to append each remaining section — match the last few lines of the file, then replace with those lines plus the new section content
|
||||
3. Repeat step 2 for all remaining sections
|
||||
4. Run `save-deliverable` with `--type SSRF_ANALYSIS --file-path ".shannon/deliverables/ssrf_analysis_deliverable.md"`
|
||||
**WARNING:** Do NOT write the entire report in a single tool call — exceeds 32K output token limit. Split into multiple Write/Edit operations.
|
||||
2. **Deliverable Emission:** Call the 4 tools (`set_findings_summary`, `set_strategic_intelligence`, `set_safe_vectors`, `set_blind_spots`) exactly once each with their respective section content. `set_findings_summary` and `set_strategic_intelligence` are required; `set_safe_vectors` and `set_blind_spots` are recommended (empty arrays are acceptable but explicit emission is preferred).
|
||||
|
||||
**Note:** Save your deliverable markdown via save-deliverable first. The exploitation queue is captured automatically at the end of your session.
|
||||
**Note:** The exploitation queue is produced by calling the `submit_exploitation_queue` tool when your analysis is complete — separate from the tools above. The analysis deliverable Markdown is rendered by the harness after your session ends from the tool calls.
|
||||
|
||||
**ONLY AFTER** both systematic analysis AND successful deliverable generation, announce "**SSRF ANALYSIS COMPLETE**" and stop.
|
||||
**ONLY AFTER** both systematic analysis AND the required tool calls have been completed, announce "**SSRF ANALYSIS COMPLETE**" and stop.
|
||||
|
||||
**CRITICAL:** After announcing completion, STOP IMMEDIATELY. Do NOT output summaries, recaps, or explanations of your work — the deliverable contains everything needed.
|
||||
</conclusion_trigger>
|
||||
@@ -19,10 +19,16 @@ Success criterion: Live confirmation of XSS execution for every vulnerability th
|
||||
@include(shared/_rules.txt)
|
||||
</rules>
|
||||
|
||||
@include(shared/_code-path-rules.txt)
|
||||
|
||||
@include(shared/_shared-session.txt)
|
||||
|
||||
<login_instructions>
|
||||
{{LOGIN_INSTRUCTIONS}}
|
||||
</login_instructions>
|
||||
|
||||
@include(shared/_rules-of-engagement.txt)
|
||||
|
||||
<critical>
|
||||
**Your Professional Standard**
|
||||
- Severity Context: An XSS flaw is a breach of trust between the user and the application. It grants an attacker the ability to execute arbitrary code within a victim's browser, leading to session hijacking, credential theft, and complete compromise of the user's account and data within the application.
|
||||
@@ -71,22 +77,17 @@ An **exploitable vulnerability** is a confirmed source-to-sink path where the en
|
||||
<cli_tools>
|
||||
|
||||
**CRITICAL TOOL USAGE RESTRICTIONS:**
|
||||
- NEVER use the Read tool for application source code analysis - ALWAYS delegate to Task agents for examining .js, .ts, .py, .php files and application logic. You MAY use Read
|
||||
- NEVER use the `read` tool for application source code analysis - ALWAYS delegate to `task` agents for examining .js, .ts, .py, .php files and application logic. You MAY use Read
|
||||
tool directly for these files: `.shannon/deliverables/pre_recon_deliverable.md`, `.shannon/deliverables/recon_deliverable.md`
|
||||
- Direct the Task Agent to trace render contexts, sanitization coverage, and template/component boundaries before deciding on exploitability.
|
||||
- **ALWAYS delegate code analysis to Task agents**
|
||||
- Direct the `task` agent to trace render contexts, sanitization coverage, and template/component boundaries before deciding on exploitability.
|
||||
- **ALWAYS delegate code analysis to `task` agents**
|
||||
|
||||
**Available Tools:**
|
||||
- **Task Agent (Code Analysis):** MANDATORY for all source code analysis and data flow tracing. Use this instead of Read tool for examining application code, models, controllers, and templates.
|
||||
- **`task` agent (Code Analysis):** MANDATORY for all source code analysis and data flow tracing. Use this instead of `read` tool for examining application code, models, controllers, and templates.
|
||||
- **Terminal (curl):** MANDATORY for testing HTTP-based XSS vectors and observing raw HTML responses. Use for reflected XSS testing and JSONP injection testing.
|
||||
- **Browser Automation (playwright-cli skill):** MANDATORY for testing DOM-based XSS and form submission vectors. Invoke the `playwright-cli` skill to learn available commands. Use for stored XSS testing and client-side payload execution verification. Always pass `-s={{PLAYWRIGHT_SESSION}}` to every command for session isolation.
|
||||
- **TodoWrite Tool:** Use this to create and manage your analysis task list. Create a todo item for each sink you need to analyze.
|
||||
- **save-deliverable (CLI Tool):** Saves your deliverable files with automatic validation.
|
||||
- **Usage:** `save-deliverable --type <TYPE> --file-path <path>` or `--content '<text>'`
|
||||
- **Returns:** JSON to stdout: `{"status":"success","filepath":"..."}` or `{"status":"error","message":"...","retryable":true}`
|
||||
- **For large reports:** Write to disk first, then use `--file-path`. Do NOT pass large reports via `--content`.
|
||||
|
||||
- **Bash tool:** Use for creating directories, copying files, and other shell commands as needed.
|
||||
- **`todo_write` Tool:** Use this to create and manage your analysis task list. Create a todo item for each sink you need to analyze.
|
||||
- **`bash` tool:** Use for creating directories, copying files, and other shell commands as needed.
|
||||
</cli_tools>
|
||||
|
||||
<data_format_specifications>
|
||||
@@ -107,7 +108,11 @@ Structure: The vulnerability JSON object MUST follow this exact format:
|
||||
"verdict": "vulnerable | safe.",
|
||||
"mismatch_reason": "If vulnerable, explain why the observed encoding is wrong for the render context (e.g., 'URL encoding used in an HTML attribute context, allowing event handler injection.').",
|
||||
"witness_payload": "A minimal, non-malicious payload that proves context control (e.g., '><script>alert(1)</script>', '" onmouseover=alert(1) ').",
|
||||
"confidence": "high | med | low.",
|
||||
"confidence": "high | medium | low",
|
||||
"code_locations": [
|
||||
{ "file": "frontend/src/app/search-result/search-result.component.ts", "start_line": 121, "role": "sink", "symbol": "filterTable" },
|
||||
{ "file": "frontend/src/app/search-result/search-result.component.ts", "start_line": 115, "role": "source" }
|
||||
],
|
||||
"notes": "Relevant CSP, HttpOnly flags, WAF behavior, or other environmental factors."
|
||||
}
|
||||
</exploitation_queue_format>
|
||||
@@ -123,11 +128,11 @@ Structure: The vulnerability JSON object MUST follow this exact format:
|
||||
- **Goal:** Identify vulnerable data flow paths by starting at the XSS sinks received from the recon phase and tracing backward to their sanitizations and sources. This approach is optimized for finding all types of XSS, especially complex Stored XSS patterns.
|
||||
- **Core Principle:** Data is assumed to be tainted until a context-appropriate output encoder (sanitization) is encountered on its path to the sink.
|
||||
|
||||
### **1) Create a todo item for each XSS sink using the TodoWrite tool**
|
||||
Read .shannon/deliverables/pre_recon_deliverable.md section ##9. XSS Sinks and Render Contexts## and use the **TodoWrite tool** to create a todo item for each discovered sink-context pair that needs analysis.
|
||||
### **1) Create a todo item for each XSS sink using the `todo_write` tool**
|
||||
Read .shannon/deliverables/pre_recon_deliverable.md section ##9. XSS Sinks and Render Contexts## and use the **`todo_write` tool** to create a todo item for each discovered sink-context pair that needs analysis.
|
||||
|
||||
### **2) Trace Each Sink Backward (Backward Taint Analysis)**
|
||||
For each pending item in your todo list (managed via TodoWrite tool), trace the origin of the data variable backward from the sink through the application logic. Your goal is to find either a valid sanitizer or an untrusted source. Mark each todo item as completed after you've fully analyzed that sink.
|
||||
For each pending item in your todo list (managed via `todo_write` tool), trace the origin of the data variable backward from the sink through the application logic. Your goal is to find either a valid sanitizer or an untrusted source. Mark each todo item as completed after you've fully analyzed that sink.
|
||||
|
||||
- **Early Termination for Secure Paths (Efficiency Rule):**
|
||||
- As you trace backward, if you encounter a sanitization/encoding function, immediately perform two checks:
|
||||
@@ -204,83 +209,36 @@ This rulebook is used for the **Early Termination** check in Step 2.
|
||||
|
||||
</methodology_and_domain_expertise>
|
||||
|
||||
<deliverable_instructions>
|
||||
<deliverable_tools>
|
||||
After completing your `todo_write` tasks and synthesizing findings, emit your specialist deliverable via 4 one-shot tools. Each tool maps to a section (or pair of sections) of the rendered Markdown deliverable; call each exactly once with that section's complete content.
|
||||
|
||||
When you have systematically analyzed all input vectors, you MUST generate two final files. Follow these instructions precisely.
|
||||
**Tool catalog:**
|
||||
- `set_findings_summary` — Section 1 (Executive Summary key outcome) and Section 2 (Dominant Vulnerability Patterns)
|
||||
- `set_strategic_intelligence` — Section 3 (Strategic Intelligence for Exploitation, with XSS-specific sub-fields: CSP analysis, cookie security)
|
||||
- `set_safe_vectors` — Section 4 (vectors confirmed secure)
|
||||
- `set_blind_spots` — Section 5 (analysis constraints and blind spots)
|
||||
|
||||
## 1. Your Specialist Deliverable
|
||||
The harness injects each tool's complete description and per-field guidance into your tool catalog — refer to the tool catalog for what each parameter expects. For XSS specifically, when populating `set_safe_vectors`, include the optional `render_context` field on each entry (HTML_BODY, HTML_ATTRIBUTE, JAVASCRIPT_STRING, URL_PARAM, or CSS_VALUE).
|
||||
|
||||
- First, synthesize all of your findings into a single, detailed Markdown report located at
|
||||
`.shannon/deliverables/xss_analysis_deliverable.md`.
|
||||
**Call semantics:** All 4 tools are one-shot — each may be called exactly once with the section's complete content. Duplicate calls return `"already called"` and are no-ops. There is no incremental/append mode; synthesize each section's full content in working memory before emitting.
|
||||
|
||||
- Your report MUST use the following structure precisely:
|
||||
**Required vs recommended:**
|
||||
- `set_findings_summary` and `set_strategic_intelligence` are required — call both before terminating. They produce the load-bearing content the downstream `exploit-xss` agent reads.
|
||||
- `set_safe_vectors` and `set_blind_spots` are recommended. Empty arrays are acceptable on runs with no validated-secure vectors or no constraint gaps, but explicit emission is preferred over skipping.
|
||||
|
||||
|
||||
# Cross-Site Scripting (XSS) Analysis Report
|
||||
|
||||
## 1. Executive Summary
|
||||
- **Analysis Status:** Complete
|
||||
- **Key Outcome:** Several high-confidence XSS vulnerabilities were identified. All findings have been passed to the exploitation phase.
|
||||
- **Purpose of this Document:** This report provides the strategic context, dominant patterns, and environmental intelligence necessary to effectively exploit the vulnerabilities.
|
||||
|
||||
## 2. Dominant Vulnerability Patterns
|
||||
|
||||
**Pattern 1: Reflected XSS in Search Parameter**
|
||||
- **Description:** A recurring pattern was observed where the main search parameter (`?q=`) is reflected directly into the HTML body without any output encoding.
|
||||
- **Implication:** Any page with a search bar is likely vulnerable to basic reflected XSS. This is the easiest vector for exploitation.
|
||||
- **Representative Findings:** XSS-VULN-01, XSS-VULN-03.
|
||||
|
||||
**Pattern 2: DOM-based XSS in URL Hash**
|
||||
- **Description:** Client-side JavaScript reads from `location.hash` and writes the value into a div using `innerHTML` to dynamically load content, without sanitization.
|
||||
- **Implication:** This allows for script execution without the payload ever being sent to the server, potentially bypassing server-side logs and WAFs.
|
||||
- **Representative Finding:** XSS-VULN-02.
|
||||
|
||||
## 3. Strategic Intelligence for Exploitation
|
||||
|
||||
**Content Security Policy (CSP) Analysis**
|
||||
- **Current CSP:** `script-src 'self' https://trusted-cdn.com;`
|
||||
- **Critical Bypass:** The trusted CDN hosts an old version of AngularJS, which is vulnerable to client-side template injection. This provides a clear path to bypass the CSP.
|
||||
- **Recommendation:** Exploitation should focus on crafting AngularJS sandbox escape payloads.
|
||||
|
||||
**Cookie Security**
|
||||
- **Observation:** The primary session cookie (`sessionid`) is missing the `HttpOnly` flag.
|
||||
- **Recommendation:** This is a high-impact finding. The primary goal of exploitation should be to steal this cookie via `document.cookie`.
|
||||
|
||||
## 4. Vectors Analyzed and Confirmed Secure
|
||||
|
||||
These input vectors were traced and confirmed to have robust, context-appropriate defenses.
|
||||
|
||||
| Source (Parameter/Key) | Endpoint/File Location | Defense Mechanism Implemented | Render Context | Verdict |
|
||||
|--------------------------|-------------------------|--------------------------------|----------------|---------|
|
||||
| `username` | `/profile` | HTML Entity Encoding | HTML_BODY | SAFE |
|
||||
| `redirect_url` | `/login` | Strict URL Whitelist Validation | URL_PARAM | SAFE |
|
||||
|
||||
## 5. Analysis Constraints and Blind Spots
|
||||
|
||||
- **Minified JavaScript:** Analysis of the primary client-side bundle (`app.min.js`) was difficult. Some DOM XSS vulnerabilities may have been missed due to obfuscated code.
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
</deliverable_instructions>
|
||||
**Relationship to the exploitation queue:** The exploitation queue (`xss_exploitation_queue.json`) is produced by calling the `submit_exploitation_queue` tool when your analysis is complete. The 4 tools produce the analysis deliverable Markdown; the structured-output queue is separate and follows the `exploitation_queue_format` schema documented above.
|
||||
</deliverable_tools>
|
||||
|
||||
|
||||
<conclusion_trigger>
|
||||
COMPLETION REQUIREMENTS (ALL must be satisfied):
|
||||
|
||||
1. Systematic Analysis: ALL input vectors identified from the reconnaissance deliverable must be analyzed.
|
||||
2. Deliverable Generation: Your deliverable must be successfully saved using the save-deliverable CLI tool:
|
||||
- **CHUNKED WRITING (MANDATORY):**
|
||||
1. Use the **Write** tool to create `.shannon/deliverables/xss_analysis_deliverable.md` with the title and first major section
|
||||
2. Use the **Edit** tool to append each remaining section — match the last few lines of the file, then replace with those lines plus the new section content
|
||||
3. Repeat step 2 for all remaining sections
|
||||
4. Run `save-deliverable` with `--type XSS_ANALYSIS --file-path ".shannon/deliverables/xss_analysis_deliverable.md"`
|
||||
**WARNING:** Do NOT write the entire report in a single tool call — exceeds 32K output token limit. Split into multiple Write/Edit operations.
|
||||
2. Deliverable Emission: Call the 4 tools (`set_findings_summary`, `set_strategic_intelligence`, `set_safe_vectors`, `set_blind_spots`) exactly once each with their respective section content. `set_findings_summary` and `set_strategic_intelligence` are required; `set_safe_vectors` and `set_blind_spots` are recommended (empty arrays acceptable but explicit emission is preferred).
|
||||
|
||||
**Note:** Save your deliverable markdown via save-deliverable first. The exploitation queue is captured automatically at the end of your session.
|
||||
**Note:** The exploitation queue is produced by calling the `submit_exploitation_queue` tool when your analysis is complete — separate from the tools above. The analysis deliverable Markdown is rendered by the harness after your session ends from the tool calls.
|
||||
|
||||
ONLY AFTER both systematic analysis AND successful deliverable generation, announce "XSS ANALYSIS COMPLETE" and stop.
|
||||
ONLY AFTER both systematic analysis AND the required tool calls have been completed, announce "XSS ANALYSIS COMPLETE" and stop.
|
||||
|
||||
**CRITICAL:** After announcing completion, STOP IMMEDIATELY. Do NOT output summaries, recaps, or explanations of your work — the deliverable contains everything needed.
|
||||
</conclusion_trigger>
|
||||
@@ -14,6 +14,7 @@ export interface AuditLogger {
|
||||
logToolStart(toolName: string, parameters: unknown): Promise<void>;
|
||||
logToolEnd(result: unknown): Promise<void>;
|
||||
logError(error: Error, duration: number, turns: number): Promise<void>;
|
||||
logNote(category: string, message: string): Promise<void>;
|
||||
}
|
||||
|
||||
class RealAuditLogger implements AuditLogger {
|
||||
@@ -56,6 +57,10 @@ class RealAuditLogger implements AuditLogger {
|
||||
timestamp: formatTimestamp(),
|
||||
});
|
||||
}
|
||||
|
||||
async logNote(category: string, message: string): Promise<void> {
|
||||
await this.auditSession.logWorkflowNote(category, message);
|
||||
}
|
||||
}
|
||||
|
||||
/** Null Object implementation - all methods are safe no-ops */
|
||||
@@ -67,6 +72,8 @@ class NullAuditLogger implements AuditLogger {
|
||||
async logToolEnd(_result: unknown): Promise<void> {}
|
||||
|
||||
async logError(_error: Error, _duration: number, _turns: number): Promise<void> {}
|
||||
|
||||
async logNote(_category: string, _message: string): Promise<void> {}
|
||||
}
|
||||
|
||||
// Returns no-op when auditSession is null
|
||||
|
||||
@@ -1,399 +0,0 @@
|
||||
// Copyright (C) 2025 Keygraph, Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License version 3
|
||||
// as published by the Free Software Foundation.
|
||||
|
||||
// Production Claude agent execution with retry, git checkpoints, and audit logging
|
||||
|
||||
import { type JsonSchemaOutputFormat, query } from '@anthropic-ai/claude-agent-sdk';
|
||||
import { fs, path } from 'zx';
|
||||
import type { AuditSession } from '../audit/index.js';
|
||||
import { deliverablesDir } from '../paths.js';
|
||||
import { isRetryableError, PentestError } from '../services/error-handling.js';
|
||||
import { AGENT_VALIDATORS } from '../session-manager.js';
|
||||
import type { ActivityLogger } from '../types/activity-logger.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';
|
||||
|
||||
declare global {
|
||||
var SHANNON_DISABLE_LOADER: boolean | undefined;
|
||||
}
|
||||
|
||||
export interface ClaudePromptResult {
|
||||
result?: string | null | undefined;
|
||||
success: boolean;
|
||||
duration: number;
|
||||
turns?: number | undefined;
|
||||
cost: number;
|
||||
model?: string | undefined;
|
||||
partialCost?: number | undefined;
|
||||
apiErrorDetected?: boolean | undefined;
|
||||
error?: string | undefined;
|
||||
errorType?: string | undefined;
|
||||
prompt?: string | undefined;
|
||||
retryable?: boolean | undefined;
|
||||
structuredOutput?: unknown;
|
||||
}
|
||||
|
||||
function outputLines(lines: string[]): void {
|
||||
for (const line of lines) {
|
||||
console.log(line);
|
||||
}
|
||||
}
|
||||
|
||||
async function writeErrorLog(
|
||||
err: Error & { code?: string; status?: number },
|
||||
sourceDir: string,
|
||||
fullPrompt: string,
|
||||
duration: number,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const errorLog = {
|
||||
timestamp: formatTimestamp(),
|
||||
agent: 'claude-executor',
|
||||
error: {
|
||||
name: err.constructor.name,
|
||||
message: err.message,
|
||||
code: err.code,
|
||||
status: err.status,
|
||||
stack: err.stack,
|
||||
},
|
||||
context: {
|
||||
sourceDir,
|
||||
prompt: `${fullPrompt.slice(0, 200)}...`,
|
||||
retryable: isRetryableError(err),
|
||||
},
|
||||
duration,
|
||||
};
|
||||
const logPath = path.join(deliverablesDir(sourceDir), 'error.log');
|
||||
await fs.appendFile(logPath, `${JSON.stringify(errorLog)}\n`);
|
||||
} catch {
|
||||
// Best-effort error log writing - don't propagate failures
|
||||
}
|
||||
}
|
||||
|
||||
export async function validateAgentOutput(
|
||||
result: ClaudePromptResult,
|
||||
agentName: string | null,
|
||||
sourceDir: string,
|
||||
logger: ActivityLogger,
|
||||
): Promise<boolean> {
|
||||
logger.info(`Validating ${agentName} agent output`);
|
||||
|
||||
try {
|
||||
// Check if agent completed successfully (text result OR structured output)
|
||||
if (!result.success || (!result.result && result.structuredOutput === undefined)) {
|
||||
logger.error('Validation failed: Agent execution was unsuccessful');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get validator function for this agent
|
||||
const validator = agentName ? AGENT_VALIDATORS[agentName as keyof typeof AGENT_VALIDATORS] : undefined;
|
||||
|
||||
if (!validator) {
|
||||
logger.warn(`No validator found for agent "${agentName}" - assuming success`);
|
||||
logger.info('Validation passed: Unknown agent with successful result');
|
||||
return true;
|
||||
}
|
||||
|
||||
logger.info(`Using validator for agent: ${agentName}`, { sourceDir });
|
||||
|
||||
// Apply validation function
|
||||
const validationResult = await validator(sourceDir, logger);
|
||||
|
||||
if (validationResult) {
|
||||
logger.info('Validation passed: Required files/structure present');
|
||||
} else {
|
||||
logger.error('Validation failed: Missing required deliverable files');
|
||||
}
|
||||
|
||||
return validationResult;
|
||||
} catch (error) {
|
||||
const errMsg = error instanceof Error ? error.message : String(error);
|
||||
logger.error(`Validation failed with error: ${errMsg}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Low-level SDK execution. Handles message streaming, progress, and audit logging.
|
||||
// Exported for Temporal activities to call single-attempt execution.
|
||||
export async function runClaudePrompt(
|
||||
prompt: string,
|
||||
sourceDir: string,
|
||||
context: string = '',
|
||||
description: string = 'Claude analysis',
|
||||
_agentName: string | null = null,
|
||||
auditSession: AuditSession | null = null,
|
||||
logger: ActivityLogger,
|
||||
modelTier: ModelTier = 'medium',
|
||||
outputFormat?: JsonSchemaOutputFormat,
|
||||
apiKey?: string,
|
||||
deliverablesSubdir?: string,
|
||||
providerConfig?: import('../types/config.js').ProviderConfig,
|
||||
): Promise<ClaudePromptResult> {
|
||||
// 1. Initialize timing and prompt
|
||||
const timer = new Timer(`agent-${description.toLowerCase().replace(/\s+/g, '-')}`);
|
||||
const fullPrompt = context ? `${context}\n\n${prompt}` : prompt;
|
||||
|
||||
// 2. Set up progress and audit infrastructure
|
||||
const execContext = detectExecutionContext(description);
|
||||
const progress = createProgressManager(
|
||||
{ description, useCleanOutput: execContext.useCleanOutput },
|
||||
global.SHANNON_DISABLE_LOADER ?? false,
|
||||
);
|
||||
const auditLogger = createAuditLogger(auditSession);
|
||||
|
||||
logger.info(`Running Claude Code: ${description}...`);
|
||||
|
||||
// 3. Build env vars to pass to SDK subprocesses
|
||||
const sdkEnv: Record<string, string> = {
|
||||
CLAUDE_CODE_MAX_OUTPUT_TOKENS: process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS || '64000',
|
||||
PLAYWRIGHT_MCP_OUTPUT_DIR: deliverablesSubdir
|
||||
? path.join(sourceDir, path.dirname(deliverablesSubdir), '.playwright-cli')
|
||||
: path.join(sourceDir, '.shannon', '.playwright-cli'),
|
||||
// apiKey from ContainerConfig takes precedence over process.env
|
||||
...(apiKey && { ANTHROPIC_API_KEY: apiKey }),
|
||||
// Deliverables subdir for save-deliverable CLI tool
|
||||
...(deliverablesSubdir && { SHANNON_DELIVERABLES_SUBDIR: deliverablesSubdir }),
|
||||
};
|
||||
|
||||
// 3a. Apply structured provider config directly to sdkEnv (no process.env mutation)
|
||||
if (providerConfig) {
|
||||
switch (providerConfig.providerType) {
|
||||
case 'bedrock':
|
||||
sdkEnv.CLAUDE_CODE_USE_BEDROCK = '1';
|
||||
if (providerConfig.awsRegion) sdkEnv.AWS_REGION = providerConfig.awsRegion;
|
||||
if (providerConfig.awsAccessKeyId) sdkEnv.AWS_ACCESS_KEY_ID = providerConfig.awsAccessKeyId;
|
||||
if (providerConfig.awsSecretAccessKey) sdkEnv.AWS_SECRET_ACCESS_KEY = providerConfig.awsSecretAccessKey;
|
||||
break;
|
||||
case 'vertex':
|
||||
sdkEnv.CLAUDE_CODE_USE_VERTEX = '1';
|
||||
if (providerConfig.gcpRegion) sdkEnv.CLOUD_ML_REGION = providerConfig.gcpRegion;
|
||||
if (providerConfig.gcpProjectId) sdkEnv.ANTHROPIC_VERTEX_PROJECT_ID = providerConfig.gcpProjectId;
|
||||
if (providerConfig.gcpCredentialsPath) sdkEnv.GOOGLE_APPLICATION_CREDENTIALS = providerConfig.gcpCredentialsPath;
|
||||
break;
|
||||
case 'litellm_router':
|
||||
if (providerConfig.baseUrl) sdkEnv.ANTHROPIC_BASE_URL = providerConfig.baseUrl;
|
||||
if (providerConfig.authToken) sdkEnv.ANTHROPIC_AUTH_TOKEN = providerConfig.authToken;
|
||||
break;
|
||||
default:
|
||||
// 'anthropic_api' or unset — apiKey already handled above
|
||||
if (providerConfig.apiKey && !apiKey) sdkEnv.ANTHROPIC_API_KEY = providerConfig.apiKey;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 3b. Passthrough env vars not already set by providerConfig or apiKey
|
||||
const passthroughVars = [
|
||||
...(!sdkEnv.ANTHROPIC_API_KEY ? ['ANTHROPIC_API_KEY'] : []),
|
||||
'CLAUDE_CODE_OAUTH_TOKEN',
|
||||
...(!sdkEnv.ANTHROPIC_BASE_URL ? ['ANTHROPIC_BASE_URL'] : []),
|
||||
...(!sdkEnv.ANTHROPIC_AUTH_TOKEN ? ['ANTHROPIC_AUTH_TOKEN'] : []),
|
||||
...(!sdkEnv.CLAUDE_CODE_USE_BEDROCK ? ['CLAUDE_CODE_USE_BEDROCK'] : []),
|
||||
...(!sdkEnv.AWS_REGION ? ['AWS_REGION'] : []),
|
||||
'AWS_BEARER_TOKEN_BEDROCK',
|
||||
...(!sdkEnv.CLAUDE_CODE_USE_VERTEX ? ['CLAUDE_CODE_USE_VERTEX'] : []),
|
||||
...(!sdkEnv.CLOUD_ML_REGION ? ['CLOUD_ML_REGION'] : []),
|
||||
...(!sdkEnv.ANTHROPIC_VERTEX_PROJECT_ID ? ['ANTHROPIC_VERTEX_PROJECT_ID'] : []),
|
||||
...(!sdkEnv.GOOGLE_APPLICATION_CREDENTIALS ? ['GOOGLE_APPLICATION_CREDENTIALS'] : []),
|
||||
'HOME',
|
||||
'PATH',
|
||||
'PLAYWRIGHT_MCP_EXECUTABLE_PATH',
|
||||
];
|
||||
for (const name of passthroughVars) {
|
||||
const val = process.env[name];
|
||||
if (val) {
|
||||
sdkEnv[name] = val;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Configure SDK options
|
||||
// Model override from providerConfig takes precedence over env-based resolveModel
|
||||
const model = providerConfig?.modelOverrides?.[modelTier] ?? resolveModel(modelTier);
|
||||
const options = {
|
||||
model,
|
||||
maxTurns: 10_000,
|
||||
cwd: sourceDir,
|
||||
permissionMode: 'bypassPermissions' as const,
|
||||
allowDangerouslySkipPermissions: true,
|
||||
settingSources: ['user'] as ('user' | 'project' | 'local')[],
|
||||
env: sdkEnv,
|
||||
...(outputFormat && { outputFormat }),
|
||||
};
|
||||
|
||||
if (!execContext.useCleanOutput) {
|
||||
logger.info(`SDK Options: maxTurns=${options.maxTurns}, cwd=${sourceDir}, permissions=BYPASS`);
|
||||
}
|
||||
|
||||
let turnCount = 0;
|
||||
let result: string | null = null;
|
||||
let apiErrorDetected = false;
|
||||
let totalCost = 0;
|
||||
|
||||
progress.start();
|
||||
|
||||
try {
|
||||
// 6. Process the message stream
|
||||
const messageLoopResult = await processMessageStream(
|
||||
fullPrompt,
|
||||
options,
|
||||
{ execContext, description, progress, auditLogger, logger },
|
||||
timer,
|
||||
);
|
||||
|
||||
turnCount = messageLoopResult.turnCount;
|
||||
result = messageLoopResult.result;
|
||||
apiErrorDetected = messageLoopResult.apiErrorDetected;
|
||||
totalCost = messageLoopResult.cost;
|
||||
const model = messageLoopResult.model;
|
||||
|
||||
// === SPENDING CAP SAFEGUARD ===
|
||||
// 7. Defense-in-depth: Detect spending cap that slipped through detectApiError().
|
||||
// Uses consolidated billing detection from utils/billing-detection.ts
|
||||
if (isSpendingCapBehavior(turnCount, totalCost, result || '')) {
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
// 8. Finalize successful result
|
||||
const duration = timer.stop();
|
||||
|
||||
if (apiErrorDetected) {
|
||||
logger.warn(`API Error detected in ${description} - will validate deliverables before failing`);
|
||||
}
|
||||
|
||||
progress.finish(formatCompletionMessage(execContext, description, turnCount, duration));
|
||||
|
||||
return {
|
||||
result,
|
||||
success: true,
|
||||
duration,
|
||||
turns: turnCount,
|
||||
cost: totalCost,
|
||||
model,
|
||||
partialCost: totalCost,
|
||||
apiErrorDetected,
|
||||
...(messageLoopResult.structuredOutput !== undefined && {
|
||||
structuredOutput: messageLoopResult.structuredOutput,
|
||||
}),
|
||||
};
|
||||
} catch (error) {
|
||||
// 9. Handle errors — log, write error file, return failure
|
||||
const duration = timer.stop();
|
||||
|
||||
const err = error as Error & { code?: string; status?: number };
|
||||
|
||||
await auditLogger.logError(err, duration, turnCount);
|
||||
progress.stop();
|
||||
outputLines(formatErrorOutput(err, execContext, description, duration, sourceDir, isRetryableError(err)));
|
||||
await writeErrorLog(err, sourceDir, fullPrompt, duration);
|
||||
|
||||
return {
|
||||
error: err.message,
|
||||
errorType: err.constructor.name,
|
||||
prompt: `${fullPrompt.slice(0, 100)}...`,
|
||||
success: false,
|
||||
duration,
|
||||
cost: totalCost,
|
||||
retryable: isRetryableError(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface MessageLoopResult {
|
||||
turnCount: number;
|
||||
result: string | null;
|
||||
apiErrorDetected: boolean;
|
||||
cost: number;
|
||||
model?: string | undefined;
|
||||
structuredOutput?: unknown;
|
||||
}
|
||||
|
||||
interface MessageLoopDeps {
|
||||
execContext: ReturnType<typeof detectExecutionContext>;
|
||||
description: string;
|
||||
progress: ReturnType<typeof createProgressManager>;
|
||||
auditLogger: ReturnType<typeof createAuditLogger>;
|
||||
logger: ActivityLogger;
|
||||
}
|
||||
|
||||
async function processMessageStream(
|
||||
fullPrompt: string,
|
||||
options: NonNullable<Parameters<typeof query>[0]['options']>,
|
||||
deps: MessageLoopDeps,
|
||||
timer: Timer,
|
||||
): Promise<MessageLoopResult> {
|
||||
const { execContext, description, progress, auditLogger, logger } = deps;
|
||||
const HEARTBEAT_INTERVAL = 30000;
|
||||
|
||||
let turnCount = 0;
|
||||
let result: string | null = null;
|
||||
let apiErrorDetected = false;
|
||||
let cost = 0;
|
||||
let model: string | undefined;
|
||||
let structuredOutput: unknown | undefined;
|
||||
let lastHeartbeat = Date.now();
|
||||
|
||||
for await (const message of query({ prompt: fullPrompt, options })) {
|
||||
// Heartbeat logging when loader is disabled
|
||||
const now = Date.now();
|
||||
if (global.SHANNON_DISABLE_LOADER && now - lastHeartbeat > HEARTBEAT_INTERVAL) {
|
||||
logger.info(`[${Math.floor((now - timer.startTime) / 1000)}s] ${description} running... (Turn ${turnCount})`);
|
||||
lastHeartbeat = now;
|
||||
}
|
||||
|
||||
// Increment turn count for assistant messages
|
||||
if (message.type === 'assistant') {
|
||||
turnCount++;
|
||||
}
|
||||
|
||||
const dispatchResult = await dispatchMessage(message as { type: string; subtype?: string }, turnCount, {
|
||||
execContext,
|
||||
description,
|
||||
progress,
|
||||
auditLogger,
|
||||
logger,
|
||||
});
|
||||
|
||||
if (dispatchResult.type === 'throw') {
|
||||
throw dispatchResult.error;
|
||||
}
|
||||
|
||||
if (dispatchResult.type === 'complete') {
|
||||
result = dispatchResult.result;
|
||||
cost = dispatchResult.cost;
|
||||
if (dispatchResult.structuredOutput !== undefined) {
|
||||
structuredOutput = dispatchResult.structuredOutput;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (dispatchResult.type === 'continue') {
|
||||
if (dispatchResult.apiErrorDetected) {
|
||||
apiErrorDetected = true;
|
||||
}
|
||||
if (dispatchResult.model) {
|
||||
model = dispatchResult.model;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
turnCount,
|
||||
result,
|
||||
apiErrorDetected,
|
||||
cost,
|
||||
model,
|
||||
...(structuredOutput !== undefined && { structuredOutput }),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* pi extension: enforce a bounded timeout on every `bash` tool call.
|
||||
*
|
||||
* pi's built-in bash tool accepts an optional `timeout` (in seconds) but applies
|
||||
* NO default and NO upper bound — an unbounded command (e.g. a `playwright-cli`
|
||||
* browser action that never returns) hangs the agent indefinitely. This extension
|
||||
* registers a `tool_call` pre-execution handler that blocks any `bash` invocation
|
||||
* that omits `timeout` or sets it above the maximum, returning a message that tells
|
||||
* the model how to re-run the command correctly.
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI, ToolCallEvent, ToolCallEventResult } from '@earendil-works/pi-coding-agent';
|
||||
import { isToolCallEventType } from '@earendil-works/pi-coding-agent';
|
||||
|
||||
/** Recommended timeout (seconds) suggested to the model when it omits one. */
|
||||
const DEFAULT_TIMEOUT_SECONDS = 120;
|
||||
|
||||
/** Hard upper bound (seconds) a single bash command may run. */
|
||||
const MAX_TIMEOUT_SECONDS = 600;
|
||||
|
||||
function evaluateBashTimeout(timeout: number | undefined): ToolCallEventResult | undefined {
|
||||
const hasValidTimeout = typeof timeout === 'number' && Number.isFinite(timeout) && timeout > 0;
|
||||
if (!hasValidTimeout) {
|
||||
return {
|
||||
block: true,
|
||||
reason: `A timeout in seconds is required for the bash tool. The bash tool was not executed. Use the default of ${DEFAULT_TIMEOUT_SECONDS} seconds, or up to a maximum of ${MAX_TIMEOUT_SECONDS} seconds.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (timeout > MAX_TIMEOUT_SECONDS) {
|
||||
return {
|
||||
block: true,
|
||||
reason: `bash 'timeout' ${timeout}s exceeds max ${MAX_TIMEOUT_SECONDS}s. Default ${DEFAULT_TIMEOUT_SECONDS}s, max ${MAX_TIMEOUT_SECONDS}s.`,
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export default function bashTimeoutExtension(pi: ExtensionAPI): void {
|
||||
pi.on('tool_call', (event: ToolCallEvent): ToolCallEventResult | undefined => {
|
||||
if (!isToolCallEventType('bash', event)) {
|
||||
return undefined;
|
||||
}
|
||||
return evaluateBashTimeout(event.input.timeout);
|
||||
});
|
||||
}
|
||||
@@ -1,368 +0,0 @@
|
||||
// Copyright (C) 2025 Keygraph, Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// 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 { formatTimestamp } from '../utils/formatting.js';
|
||||
import type { AuditLogger } from './audit-logger.js';
|
||||
import {
|
||||
filterJsonToolCalls,
|
||||
formatAssistantOutput,
|
||||
formatResultOutput,
|
||||
formatToolResultOutput,
|
||||
formatToolUseOutput,
|
||||
} from './output-formatters.js';
|
||||
import type { ProgressManager } from './progress-manager.js';
|
||||
import type {
|
||||
ApiErrorDetection,
|
||||
AssistantMessage,
|
||||
AssistantResult,
|
||||
ContentBlock,
|
||||
ExecutionContext,
|
||||
ResultData,
|
||||
ResultMessage,
|
||||
SystemInitMessage,
|
||||
ToolResultData,
|
||||
ToolResultMessage,
|
||||
ToolUseData,
|
||||
ToolUseMessage,
|
||||
} from './types.js';
|
||||
|
||||
// Handles both array and string content formats from SDK
|
||||
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 String(messageContent.content);
|
||||
}
|
||||
|
||||
// Extracts only text content (no tool_use JSON) to avoid false positives in error detection
|
||||
function extractTextOnlyContent(message: AssistantMessage): string {
|
||||
const messageContent = message.message;
|
||||
|
||||
if (Array.isArray(messageContent.content)) {
|
||||
return messageContent.content
|
||||
.filter((c: ContentBlock) => c.type === 'text' || c.text)
|
||||
.map((c: ContentBlock) => c.text || '')
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
return String(messageContent.content);
|
||||
}
|
||||
|
||||
function detectApiError(content: string): ApiErrorDetection {
|
||||
if (!content || typeof content !== 'string') {
|
||||
return { detected: false };
|
||||
}
|
||||
|
||||
const lowerContent = content.toLowerCase();
|
||||
|
||||
// === BILLING/SPENDING CAP ERRORS (Retryable with long backoff) ===
|
||||
// When Claude Code hits its spending cap, it returns a short message like
|
||||
// "Spending cap reached resets 8am" instead of throwing an error.
|
||||
// These should retry with 5-30 min backoff so workflows can recover when cap resets.
|
||||
if (matchesBillingTextPattern(content)) {
|
||||
return {
|
||||
detected: true,
|
||||
shouldThrow: new PentestError(
|
||||
`Billing limit reached: ${content.slice(0, 100)}`,
|
||||
'billing',
|
||||
true, // RETRYABLE - Temporal will use 5-30 min backoff
|
||||
{},
|
||||
ErrorCode.SPENDING_CAP_REACHED,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// === SESSION LIMIT (Non-retryable) ===
|
||||
// Different from spending cap - usually means something is fundamentally wrong
|
||||
if (lowerContent.includes('session limit reached')) {
|
||||
return {
|
||||
detected: true,
|
||||
shouldThrow: new PentestError('Session limit reached', 'billing', false),
|
||||
};
|
||||
}
|
||||
|
||||
// Non-fatal API errors - detected but continue
|
||||
if (lowerContent.includes('api error') || lowerContent.includes('terminated')) {
|
||||
return { detected: true };
|
||||
}
|
||||
|
||||
return { detected: false };
|
||||
}
|
||||
|
||||
// Maps SDK structured error types to our error handling.
|
||||
function handleStructuredError(errorType: SDKAssistantMessageError, content: string): ApiErrorDetection {
|
||||
switch (errorType) {
|
||||
case 'billing_error':
|
||||
return {
|
||||
detected: true,
|
||||
shouldThrow: new PentestError(
|
||||
`Billing error (structured): ${content.slice(0, 100)}`,
|
||||
'billing',
|
||||
true, // Retryable with backoff
|
||||
{},
|
||||
ErrorCode.INSUFFICIENT_CREDITS,
|
||||
),
|
||||
};
|
||||
case 'rate_limit':
|
||||
return {
|
||||
detected: true,
|
||||
shouldThrow: new PentestError(
|
||||
`Rate limit hit (structured): ${content.slice(0, 100)}`,
|
||||
'network',
|
||||
true, // Retryable with backoff
|
||||
{},
|
||||
ErrorCode.API_RATE_LIMITED,
|
||||
),
|
||||
};
|
||||
case 'authentication_failed':
|
||||
return {
|
||||
detected: true,
|
||||
shouldThrow: new PentestError(
|
||||
`Authentication failed: ${content.slice(0, 100)}`,
|
||||
'config',
|
||||
false, // Not retryable - needs API key fix
|
||||
),
|
||||
};
|
||||
case 'server_error':
|
||||
return {
|
||||
detected: true,
|
||||
shouldThrow: new PentestError(
|
||||
`Server error (structured): ${content.slice(0, 100)}`,
|
||||
'network',
|
||||
true, // Retryable
|
||||
),
|
||||
};
|
||||
case 'invalid_request':
|
||||
return {
|
||||
detected: true,
|
||||
shouldThrow: new PentestError(
|
||||
`Invalid request: ${content.slice(0, 100)}`,
|
||||
'config',
|
||||
false, // Not retryable - needs code fix
|
||||
),
|
||||
};
|
||||
case 'max_output_tokens':
|
||||
return {
|
||||
detected: true,
|
||||
shouldThrow: new PentestError(
|
||||
`Max output tokens reached: ${content.slice(0, 100)}`,
|
||||
'billing',
|
||||
true, // Retryable - may succeed with different content
|
||||
),
|
||||
};
|
||||
default:
|
||||
return { detected: true };
|
||||
}
|
||||
}
|
||||
|
||||
function handleAssistantMessage(message: AssistantMessage, turnCount: number): AssistantResult {
|
||||
const content = extractMessageContent(message);
|
||||
const cleanedContent = filterJsonToolCalls(content);
|
||||
|
||||
// Prefer structured error field from SDK, fall back to text-sniffing
|
||||
// Use text-only content for error detection to avoid false positives
|
||||
// from tool_use JSON (e.g. security reports containing "usage limit")
|
||||
let errorDetection: ApiErrorDetection;
|
||||
if (message.error) {
|
||||
errorDetection = handleStructuredError(message.error, content);
|
||||
} else {
|
||||
const textOnlyContent = extractTextOnlyContent(message);
|
||||
errorDetection = detectApiError(textOnlyContent);
|
||||
}
|
||||
|
||||
const result: AssistantResult = {
|
||||
content,
|
||||
cleanedContent,
|
||||
apiErrorDetected: errorDetection.detected,
|
||||
logData: {
|
||||
turn: turnCount,
|
||||
content,
|
||||
timestamp: formatTimestamp(),
|
||||
},
|
||||
};
|
||||
|
||||
// Only add shouldThrow if it exists (exactOptionalPropertyTypes compliance)
|
||||
if (errorDetection.shouldThrow) {
|
||||
result.shouldThrow = errorDetection.shouldThrow;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Final message of a query with cost/duration info
|
||||
function handleResultMessage(message: ResultMessage): ResultData {
|
||||
const result: ResultData = {
|
||||
result: message.result || null,
|
||||
cost: message.total_cost_usd || 0,
|
||||
duration_ms: message.duration_ms || 0,
|
||||
permissionDenials: message.permission_denials?.length || 0,
|
||||
};
|
||||
|
||||
// Only add subtype if it exists (exactOptionalPropertyTypes compliance)
|
||||
if (message.subtype) {
|
||||
result.subtype = message.subtype;
|
||||
}
|
||||
|
||||
// Capture stop_reason for diagnostics (helps debug early stops, budget exceeded, etc.)
|
||||
if (message.stop_reason !== undefined) {
|
||||
result.stop_reason = message.stop_reason;
|
||||
if (message.stop_reason && message.stop_reason !== 'end_turn') {
|
||||
console.log(` Stop reason: ${message.stop_reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (message.structured_output !== undefined) {
|
||||
result.structuredOutput = message.structured_output;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function handleToolUseMessage(message: ToolUseMessage): ToolUseData {
|
||||
return {
|
||||
toolName: message.name,
|
||||
parameters: message.input || {},
|
||||
timestamp: formatTimestamp(),
|
||||
};
|
||||
}
|
||||
|
||||
// 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 displayContent =
|
||||
contentStr.length > 500
|
||||
? `${contentStr.slice(0, 500)}...\n[Result truncated - ${contentStr.length} total chars]`
|
||||
: contentStr;
|
||||
|
||||
return {
|
||||
content,
|
||||
displayContent,
|
||||
timestamp: formatTimestamp(),
|
||||
};
|
||||
}
|
||||
|
||||
function outputLines(lines: string[]): void {
|
||||
for (const line of lines) {
|
||||
console.log(line);
|
||||
}
|
||||
}
|
||||
|
||||
export type MessageDispatchAction =
|
||||
| { type: 'continue'; apiErrorDetected?: boolean | undefined; model?: string | undefined }
|
||||
| { type: 'complete'; result: string | null; cost: number; structuredOutput?: unknown }
|
||||
| { type: 'throw'; error: Error };
|
||||
|
||||
export interface MessageDispatchDeps {
|
||||
execContext: ExecutionContext;
|
||||
description: string;
|
||||
progress: ProgressManager;
|
||||
auditLogger: AuditLogger;
|
||||
logger: ActivityLogger;
|
||||
}
|
||||
|
||||
// Dispatches SDK messages to appropriate handlers and formatters
|
||||
export async function dispatchMessage(
|
||||
message: { type: string; subtype?: string },
|
||||
turnCount: number,
|
||||
deps: MessageDispatchDeps,
|
||||
): Promise<MessageDispatchAction> {
|
||||
const { execContext, description, progress, auditLogger, logger } = deps;
|
||||
|
||||
switch (message.type) {
|
||||
case 'assistant': {
|
||||
const assistantResult = handleAssistantMessage(message as AssistantMessage, turnCount);
|
||||
|
||||
if (assistantResult.shouldThrow) {
|
||||
return { type: 'throw', error: assistantResult.shouldThrow };
|
||||
}
|
||||
|
||||
if (assistantResult.cleanedContent.trim()) {
|
||||
progress.stop();
|
||||
outputLines(formatAssistantOutput(assistantResult.cleanedContent, execContext, turnCount, description));
|
||||
progress.start();
|
||||
}
|
||||
|
||||
await auditLogger.logLlmResponse(turnCount, assistantResult.content);
|
||||
|
||||
if (assistantResult.apiErrorDetected) {
|
||||
logger.warn('API Error detected in assistant response');
|
||||
return { type: 'continue', apiErrorDetected: true };
|
||||
}
|
||||
|
||||
return { type: 'continue' };
|
||||
}
|
||||
|
||||
case 'system': {
|
||||
if (message.subtype === 'init') {
|
||||
const initMsg = message as SystemInitMessage;
|
||||
if (!execContext.useCleanOutput) {
|
||||
logger.info(`Model: ${initMsg.model}, Permission: ${initMsg.permissionMode}`);
|
||||
}
|
||||
return { type: 'continue', model: initMsg.model };
|
||||
}
|
||||
return { type: 'continue' };
|
||||
}
|
||||
|
||||
case 'user':
|
||||
case 'tool_progress':
|
||||
case 'tool_use_summary':
|
||||
case 'auth_status':
|
||||
return { type: 'continue' };
|
||||
|
||||
case 'tool_use': {
|
||||
const toolData = handleToolUseMessage(message as unknown as ToolUseMessage);
|
||||
outputLines(formatToolUseOutput(toolData.toolName, toolData.parameters));
|
||||
await auditLogger.logToolStart(toolData.toolName, toolData.parameters);
|
||||
return { type: 'continue' };
|
||||
}
|
||||
|
||||
case 'tool_result': {
|
||||
const toolResultData = handleToolResultMessage(message as unknown as ToolResultMessage);
|
||||
outputLines(formatToolResultOutput(toolResultData.displayContent));
|
||||
await auditLogger.logToolEnd(toolResultData.content);
|
||||
return { type: 'continue' };
|
||||
}
|
||||
|
||||
case 'result': {
|
||||
const resultData = handleResultMessage(message as ResultMessage);
|
||||
outputLines(formatResultOutput(resultData, !execContext.useCleanOutput));
|
||||
|
||||
if (resultData.subtype === 'error_max_structured_output_retries') {
|
||||
return {
|
||||
type: 'throw',
|
||||
error: new PentestError(
|
||||
'Structured output validation failed after max retries',
|
||||
'validation',
|
||||
true,
|
||||
{},
|
||||
ErrorCode.OUTPUT_VALIDATION_FAILED,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'complete' as const,
|
||||
result: resultData.result,
|
||||
cost: resultData.cost,
|
||||
...(resultData.structuredOutput !== undefined && { structuredOutput: resultData.structuredOutput }),
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
logger.info(`Unhandled message type: ${message.type}`);
|
||||
return { type: 'continue' };
|
||||
}
|
||||
}
|
||||
+326
-21
@@ -5,33 +5,338 @@
|
||||
// as published by the Free Software Foundation.
|
||||
|
||||
/**
|
||||
* Model tier definitions and resolution.
|
||||
* Model selection and resolution for the pi harness.
|
||||
*
|
||||
* Three tiers mapped to capability levels:
|
||||
* - "small" (Haiku — summarization, structured extraction)
|
||||
* - "medium" (Sonnet — tool use, general analysis)
|
||||
* - "large" (Opus — deep reasoning, complex analysis)
|
||||
* One model runs the entire workflow. Users name it with a single setting:
|
||||
*
|
||||
* Users override via ANTHROPIC_SMALL_MODEL / ANTHROPIC_MEDIUM_MODEL / ANTHROPIC_LARGE_MODEL,
|
||||
* which works across all providers (direct, Bedrock, Vertex).
|
||||
* SHANNON_AI_MODEL=<provider>:<model-id>
|
||||
*
|
||||
* The provider half decides the endpoint, the credential, and the API dialect;
|
||||
* the model half is passed to pi's registry as-is. The separator is a colon
|
||||
* because model IDs routinely contain slashes, and it is the *first* colon that
|
||||
* splits, because Bedrock model IDs contain colons of their own
|
||||
* (`amazon-bedrock:us.anthropic.claude-opus-4-5-20251101-v1:0`).
|
||||
*
|
||||
* Resolution returns a pi `Model` plus the `ModelRuntime` that owns its auth,
|
||||
* built over an in-memory credential store primed from the environment.
|
||||
*/
|
||||
|
||||
export type ModelTier = 'small' | 'medium' | 'large';
|
||||
import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { Api, Credential, CredentialInfo, CredentialStore, Model } from '@earendil-works/pi-ai';
|
||||
import { getAgentDir, ModelRuntime } from '@earendil-works/pi-coding-agent';
|
||||
|
||||
const DEFAULT_MODELS: Readonly<Record<ModelTier, string>> = {
|
||||
small: 'claude-haiku-4-5-20251001',
|
||||
medium: 'claude-sonnet-4-6',
|
||||
large: 'claude-opus-4-6',
|
||||
/**
|
||||
* Providers Shannon curates with their own credential variables, config sections,
|
||||
* and setup flows. Each is a pi-ai provider id; any other pi provider is still
|
||||
* reachable through the generic credential path below.
|
||||
*/
|
||||
export const CURATED_PROVIDERS = ['anthropic', 'openai', 'xai', 'amazon-bedrock'] as const;
|
||||
|
||||
export type CuratedProviderId = (typeof CURATED_PROVIDERS)[number];
|
||||
|
||||
function isCuratedProvider(value: string): value is CuratedProviderId {
|
||||
return (CURATED_PROVIDERS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
/** Generic API key, honored for any provider Shannon does not curate. */
|
||||
export const GENERIC_API_KEY_ENV = 'SHANNON_AI_API_KEY';
|
||||
|
||||
/**
|
||||
* Env vars carrying each curated provider's API key, in precedence order. Shannon
|
||||
* does not invent credential names — these are the variables each provider's own
|
||||
* tooling uses. Bedrock pairs its bearer token with AWS_REGION, which is provider
|
||||
* config rather than a credential.
|
||||
*/
|
||||
export const PROVIDER_API_KEY_ENV: Readonly<Record<CuratedProviderId, readonly string[]>> = {
|
||||
anthropic: ['ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN'],
|
||||
openai: ['OPENAI_API_KEY'],
|
||||
xai: ['XAI_API_KEY'],
|
||||
'amazon-bedrock': ['AWS_BEARER_TOKEN_BEDROCK'],
|
||||
};
|
||||
|
||||
/** Resolve a model tier to a concrete model ID. */
|
||||
export function resolveModel(tier: ModelTier = 'medium'): string {
|
||||
switch (tier) {
|
||||
case 'small':
|
||||
return process.env.ANTHROPIC_SMALL_MODEL || DEFAULT_MODELS.small;
|
||||
case 'large':
|
||||
return process.env.ANTHROPIC_LARGE_MODEL || DEFAULT_MODELS.large;
|
||||
default:
|
||||
return process.env.ANTHROPIC_MEDIUM_MODEL || DEFAULT_MODELS.medium;
|
||||
/** Model used when SHANNON_AI_MODEL is unset. */
|
||||
export const DEFAULT_MODEL_SPEC = 'anthropic:claude-sonnet-4-6';
|
||||
|
||||
/** Browsable pi model catalogue — the source of valid `<provider>:<model-id>` ids. */
|
||||
export const PI_CATALOG_URL = 'https://pi.dev/models';
|
||||
|
||||
/**
|
||||
* Wire formats an OpenAI-compatible gateway may serve, named by
|
||||
* SHANNON_AI_OPENAI_FORMAT. Only `openai` offers a choice: every other supported
|
||||
* provider has exactly one API in pi's registry.
|
||||
*/
|
||||
export const OPENAI_FORMATS = {
|
||||
'chat-completions': 'openai-completions',
|
||||
responses: 'openai-responses',
|
||||
} as const;
|
||||
|
||||
export type OpenAiFormat = keyof typeof OPENAI_FORMATS;
|
||||
|
||||
/** Format assumed when a gateway is configured but no format is named. */
|
||||
export const DEFAULT_OPENAI_FORMAT: OpenAiFormat = 'chat-completions';
|
||||
|
||||
function isOpenAiFormat(value: string): value is OpenAiFormat {
|
||||
return value in OPENAI_FORMATS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read SHANNON_AI_OPENAI_FORMAT. Unset returns undefined, which lets the caller
|
||||
* distinguish "not configured" from an explicit choice and reject the variable
|
||||
* where it has no effect.
|
||||
*/
|
||||
export function resolveOpenAiFormat(): OpenAiFormat | undefined {
|
||||
const raw = process.env.SHANNON_AI_OPENAI_FORMAT?.trim();
|
||||
if (!raw) return undefined;
|
||||
|
||||
if (!isOpenAiFormat(raw)) {
|
||||
throw new Error(
|
||||
`SHANNON_AI_OPENAI_FORMAT must be one of: ${Object.keys(OPENAI_FORMATS).join(', ')}. Got "${raw}".`,
|
||||
);
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
export interface ModelSpec {
|
||||
providerId: string;
|
||||
modelId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a `<provider>:<model-id>` spec. Splits on the first colon only, so colons
|
||||
* inside a model ID survive. The provider id is passed through as given — pi's
|
||||
* registry validates it later — so this throws only on a malformed spec.
|
||||
*/
|
||||
export function parseModelSpec(spec: string): ModelSpec {
|
||||
const trimmed = spec.trim();
|
||||
const separator = trimmed.indexOf(':');
|
||||
if (separator === -1) {
|
||||
throw new Error(
|
||||
`SHANNON_AI_MODEL must be "<provider>:<model-id>", got "${trimmed}". Example: ${DEFAULT_MODEL_SPEC}`,
|
||||
);
|
||||
}
|
||||
|
||||
const providerId = trimmed.slice(0, separator).trim();
|
||||
const modelId = trimmed.slice(separator + 1).trim();
|
||||
|
||||
if (!providerId || !modelId) {
|
||||
throw new Error(
|
||||
`SHANNON_AI_MODEL must be "<provider>:<model-id>", got "${trimmed}". Example: ${DEFAULT_MODEL_SPEC}`,
|
||||
);
|
||||
}
|
||||
|
||||
return { providerId, modelId };
|
||||
}
|
||||
|
||||
/** Resolve the run's model from SHANNON_AI_MODEL, falling back to the default. */
|
||||
export function resolveModelSpec(): ModelSpec {
|
||||
return parseModelSpec(process.env.SHANNON_AI_MODEL || DEFAULT_MODEL_SPEC);
|
||||
}
|
||||
|
||||
export interface ProviderCredentials {
|
||||
/** Endpoint override, applied whatever the provider (proxies, gateways). */
|
||||
baseUrl?: string;
|
||||
/** Runtime API key primed into the ModelRuntime's credential store. */
|
||||
apiKey?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect the API key and optional endpoint override for a provider. A curated
|
||||
* provider's own variables win, then the generic SHANNON_AI_API_KEY. Bedrock is
|
||||
* excluded — it authenticates through its AWS_ variables, which pi reads directly.
|
||||
*/
|
||||
export function resolveProviderCredentials(providerId: string): ProviderCredentials {
|
||||
const credentials: ProviderCredentials = {};
|
||||
|
||||
const namedVars = isCuratedProvider(providerId) ? PROVIDER_API_KEY_ENV[providerId] : [];
|
||||
for (const name of namedVars) {
|
||||
const value = process.env[name];
|
||||
if (value) {
|
||||
credentials.apiKey = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!credentials.apiKey && providerId !== 'amazon-bedrock' && process.env[GENERIC_API_KEY_ENV]) {
|
||||
credentials.apiKey = process.env[GENERIC_API_KEY_ENV];
|
||||
}
|
||||
if (process.env.SHANNON_AI_BASE_URL) credentials.baseUrl = process.env.SHANNON_AI_BASE_URL;
|
||||
|
||||
return credentials;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory credential store holding the selected provider's API key.
|
||||
*
|
||||
* pi ships the `CredentialStore` interface but no in-memory implementation — its
|
||||
* own store reads `auth.json` from disk. Shannon's credentials arrive as env vars
|
||||
* in an ephemeral container, so nothing may be read from or written to disk.
|
||||
*/
|
||||
class RuntimeCredentialStore implements CredentialStore {
|
||||
private readonly credentials = new Map<string, Credential>();
|
||||
|
||||
constructor(providerId: string, apiKey: string | undefined) {
|
||||
if (apiKey) {
|
||||
this.credentials.set(providerId, { type: 'api_key', key: apiKey });
|
||||
}
|
||||
}
|
||||
|
||||
async read(providerId: string): Promise<Credential | undefined> {
|
||||
return this.credentials.get(providerId);
|
||||
}
|
||||
|
||||
async list(): Promise<readonly CredentialInfo[]> {
|
||||
return [...this.credentials].map(([providerId, credential]) => ({ providerId, type: credential.type }));
|
||||
}
|
||||
|
||||
/** Serialized read-modify-write. `fn` returning undefined leaves the entry alone. */
|
||||
async modify(
|
||||
providerId: string,
|
||||
fn: (current: Credential | undefined) => Promise<Credential | undefined>,
|
||||
): Promise<Credential | undefined> {
|
||||
const next = await fn(this.credentials.get(providerId));
|
||||
if (next !== undefined) {
|
||||
this.credentials.set(providerId, next);
|
||||
}
|
||||
return this.credentials.get(providerId);
|
||||
}
|
||||
|
||||
async delete(providerId: string): Promise<void> {
|
||||
this.credentials.delete(providerId);
|
||||
}
|
||||
}
|
||||
|
||||
/** The file pi reads credentials from: the agent dir's auth.json. */
|
||||
function piAuthPath(): string {
|
||||
return path.join(getAgentDir(), 'auth.json');
|
||||
}
|
||||
|
||||
/** Whether the host's pi credentials are mounted (auth.json present in the agent dir). */
|
||||
export function piAuthPresent(): boolean {
|
||||
return existsSync(piAuthPath());
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a ModelRuntime whose only credential is the one supplied. Model catalogs
|
||||
* stay offline (`allowModelNetwork` defaults to false) so a scan never blocks on
|
||||
* a catalog refresh.
|
||||
*
|
||||
* When the host's pi auth.json is present, the runtime reads it instead: pi's
|
||||
* disk-backed store resolves the credential. The mount is writable so OAuth
|
||||
* refreshes persist to the host for subsequent runs.
|
||||
*/
|
||||
export async function createModelRuntime(providerId: string, apiKey: string | undefined): Promise<ModelRuntime> {
|
||||
if (piAuthPresent()) {
|
||||
return ModelRuntime.create({ authPath: piAuthPath() });
|
||||
}
|
||||
return ModelRuntime.create({ credentials: new RuntimeCredentialStore(providerId, apiKey) });
|
||||
}
|
||||
|
||||
export interface ModelSelection {
|
||||
model: Model<Api>;
|
||||
modelRuntime: ModelRuntime;
|
||||
modelId: string;
|
||||
providerId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Point a model descriptor at a gateway.
|
||||
*
|
||||
* An OpenAI gateway may serve either wire format, named by
|
||||
* SHANNON_AI_OPENAI_FORMAT and defaulting to chat completions, which is what
|
||||
* most gateway software exposes. Switching to completions also drops the stored
|
||||
* `compat` block: the catalogue's block describes Responses, and an explicit
|
||||
* entry outranks pi's `detectCompat`, so leaving it would apply Responses
|
||||
* settings to a completions request. Staying on Responses keeps it, since it
|
||||
* then describes the format in use. Every other provider has one API and only
|
||||
* changes address.
|
||||
*/
|
||||
function pointAtGateway(model: Model<Api>, providerId: string, baseUrl: string, format: OpenAiFormat): Model<Api> {
|
||||
if (providerId !== 'openai') return { ...model, baseUrl };
|
||||
if (format === 'responses') return { ...model, baseUrl, api: OPENAI_FORMATS.responses };
|
||||
|
||||
const { compat: _responsesCompat, ...withoutCompat } = model;
|
||||
return { ...withoutCompat, baseUrl, api: OPENAI_FORMATS['chat-completions'] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a model against a runtime.
|
||||
*
|
||||
* Direct to a provider, the model must exist in the catalogue. Behind a custom
|
||||
* endpoint it need not: a gateway may serve models under its own names, so an
|
||||
* unknown id is passed through on a descriptor borrowed from the provider's
|
||||
* catalogue for its API dialect. Cost and context window on such a descriptor
|
||||
* are the reference model's, so spend figures are approximate there.
|
||||
*
|
||||
* Returns undefined when the id is unresolvable — unknown with no endpoint
|
||||
* override, or a provider carrying no models at all.
|
||||
*/
|
||||
export function resolveModel(
|
||||
modelRuntime: ModelRuntime,
|
||||
providerId: string,
|
||||
modelId: string,
|
||||
baseUrl: string | undefined,
|
||||
format: OpenAiFormat = DEFAULT_OPENAI_FORMAT,
|
||||
): Model<Api> | undefined {
|
||||
const found = modelRuntime.getModel(providerId, modelId);
|
||||
if (found) {
|
||||
return baseUrl ? pointAtGateway(found, providerId, baseUrl, format) : found;
|
||||
}
|
||||
if (!baseUrl) return undefined;
|
||||
|
||||
const reference = modelRuntime.getModels(providerId)[0];
|
||||
if (!reference) return undefined;
|
||||
|
||||
return pointAtGateway({ ...reference, id: modelId, name: modelId }, providerId, baseUrl, format);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate SHANNON_AI_OPENAI_FORMAT against the rest of the configuration and
|
||||
* return the format a gateway run should use.
|
||||
*
|
||||
* The variable only reaches a request when both an OpenAI model and a gateway
|
||||
* are configured, so it is rejected outside that combination rather than
|
||||
* silently ignored.
|
||||
*/
|
||||
export function resolveGatewayFormat(providerId: string, baseUrl: string | undefined): OpenAiFormat {
|
||||
const configured = resolveOpenAiFormat();
|
||||
if (!configured) return DEFAULT_OPENAI_FORMAT;
|
||||
|
||||
if (providerId !== 'openai') {
|
||||
throw new Error(
|
||||
`SHANNON_AI_OPENAI_FORMAT applies to openai models only, but SHANNON_AI_MODEL selects "${providerId}". ` +
|
||||
`${providerId} serves a single API, so there is no format to choose.`,
|
||||
);
|
||||
}
|
||||
if (!baseUrl) {
|
||||
throw new Error(
|
||||
'SHANNON_AI_OPENAI_FORMAT applies to gateway runs only. Set SHANNON_AI_BASE_URL, or unset the format to call OpenAI directly.',
|
||||
);
|
||||
}
|
||||
return configured;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve SHANNON_AI_MODEL, build a ModelRuntime primed with the provider's
|
||||
* credential, and look the model up in it.
|
||||
*/
|
||||
export async function resolveModelSelection(): Promise<ModelSelection> {
|
||||
const { providerId, modelId } = resolveModelSpec();
|
||||
const credentials = resolveProviderCredentials(providerId);
|
||||
const format = resolveGatewayFormat(providerId, credentials.baseUrl);
|
||||
|
||||
const modelRuntime = await createModelRuntime(providerId, credentials.apiKey);
|
||||
|
||||
const model = resolveModel(modelRuntime, providerId, modelId, credentials.baseUrl, format);
|
||||
if (!model) {
|
||||
throw new Error(
|
||||
`Model not found in pi registry: provider="${providerId}" model="${modelId}". Browse valid providers and models at ${PI_CATALOG_URL}.`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
model,
|
||||
modelRuntime,
|
||||
modelId,
|
||||
providerId,
|
||||
};
|
||||
}
|
||||
@@ -4,36 +4,31 @@
|
||||
// it under the terms of the GNU Affero General Public License version 3
|
||||
// as published by the Free Software Foundation.
|
||||
|
||||
/**
|
||||
* Human-readable console formatting for the agent executor.
|
||||
*
|
||||
* Driven by the pi harness event stream: `turn_end` (assistant text) and
|
||||
* `tool_execution_start` (structured tool calls). Unlike the previous harness —
|
||||
* where tool calls were tool_use JSON embedded in assistant text and had to be
|
||||
* parsed out — pi delivers tool name + args as discrete events, so formatting is
|
||||
* a direct mapping.
|
||||
*/
|
||||
|
||||
import { AGENTS } from '../session-manager.js';
|
||||
import { extractAgentType, formatDuration } from '../utils/formatting.js';
|
||||
import type { ExecutionContext, ResultData } from './types.js';
|
||||
import type { ExecutionContext } from './types.js';
|
||||
|
||||
interface ToolCallInput {
|
||||
url?: string;
|
||||
element?: string;
|
||||
key?: string;
|
||||
fields?: unknown[];
|
||||
text?: string;
|
||||
action?: string;
|
||||
description?: string;
|
||||
command?: string;
|
||||
todos?: Array<{
|
||||
status: string;
|
||||
content: string;
|
||||
}>;
|
||||
description?: string;
|
||||
path?: string;
|
||||
todos?: Array<{ status: string; content: string }>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface ToolCall {
|
||||
name: string;
|
||||
input?: ToolCallInput;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get agent prefix for parallel execution
|
||||
*/
|
||||
/** Agent prefix used to attribute output when parallel agents interleave on one stream. */
|
||||
export function getAgentPrefix(description: string): string {
|
||||
// Map agent names to their prefixes
|
||||
const agentPrefixes: Record<string, string> = {
|
||||
'injection-vuln': '[Injection]',
|
||||
'xss-vuln': '[XSS]',
|
||||
@@ -47,7 +42,6 @@ export function getAgentPrefix(description: string): string {
|
||||
'ssrf-exploit': '[SSRF]',
|
||||
};
|
||||
|
||||
// First try to match by agent name directly
|
||||
for (const [agentName, prefix] of Object.entries(agentPrefixes)) {
|
||||
const agent = AGENTS[agentName as keyof typeof AGENTS];
|
||||
if (agent && description.includes(agent.displayName)) {
|
||||
@@ -55,7 +49,6 @@ export function getAgentPrefix(description: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to partial matches for backwards compatibility
|
||||
if (description.includes('injection')) return '[Injection]';
|
||||
if (description.includes('xss')) return '[XSS]';
|
||||
if (description.includes('authz')) return '[Authz]'; // Check authz before auth
|
||||
@@ -65,9 +58,7 @@ export function getAgentPrefix(description: string): string {
|
||||
return '[Agent]';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract domain from URL for display
|
||||
*/
|
||||
/** Extract domain from URL for display. */
|
||||
function extractDomain(url: string): string {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
@@ -77,11 +68,8 @@ function extractDomain(url: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format playwright-cli commands into clean progress indicators
|
||||
*/
|
||||
/** Format a playwright-cli command (run via the bash tool) into a clean progress indicator. */
|
||||
function formatBrowserAction(command: string): string | null {
|
||||
// Extract subcommand after optional session flag (e.g., "playwright-cli -s=session1 navigate https://example.com")
|
||||
const match = command.match(/playwright-cli\s+(?:-s=\S+\s+)?(\S+)(?:\s+(.*))?/);
|
||||
if (!match) return null;
|
||||
|
||||
@@ -151,26 +139,19 @@ function formatBrowserAction(command: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Summarize TodoWrite updates into clean progress indicators
|
||||
*/
|
||||
/** Summarize a todo_write update into a clean progress indicator. */
|
||||
function summarizeTodoUpdate(input: ToolCallInput | undefined): string | null {
|
||||
if (!input?.todos || !Array.isArray(input.todos)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const todos = input.todos;
|
||||
const completed = todos.filter((t) => t.status === 'completed');
|
||||
const inProgress = todos.filter((t) => t.status === 'in_progress');
|
||||
|
||||
// Show recently completed tasks
|
||||
const recent = completed.at(-1);
|
||||
const recent = todos.filter((t) => t.status === 'completed').at(-1);
|
||||
if (recent) {
|
||||
return `✅ ${recent.content}`;
|
||||
}
|
||||
|
||||
// Show current in-progress task
|
||||
const current = inProgress.at(0);
|
||||
const current = todos.filter((t) => t.status === 'in_progress').at(0);
|
||||
if (current) {
|
||||
return `🔄 ${current.content}`;
|
||||
}
|
||||
@@ -178,69 +159,6 @@ function summarizeTodoUpdate(input: ToolCallInput | undefined): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter out JSON tool calls from content, with special handling for Task calls
|
||||
*/
|
||||
export function filterJsonToolCalls(content: string | null | undefined): string {
|
||||
if (!content || typeof content !== 'string') {
|
||||
return content || '';
|
||||
}
|
||||
|
||||
const lines = content.split('\n');
|
||||
const processedLines: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
// Skip empty lines
|
||||
if (trimmed === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this is a JSON tool call
|
||||
if (trimmed.startsWith('{"type":"tool_use"')) {
|
||||
try {
|
||||
const toolCall = JSON.parse(trimmed) as ToolCall;
|
||||
|
||||
// Special handling for Task tool calls
|
||||
if (toolCall.name === 'Task') {
|
||||
const description = toolCall.input?.description || 'analysis agent';
|
||||
processedLines.push(`🚀 Launching ${description}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Special handling for TodoWrite tool calls
|
||||
if (toolCall.name === 'TodoWrite') {
|
||||
const summary = summarizeTodoUpdate(toolCall.input);
|
||||
if (summary) {
|
||||
processedLines.push(summary);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Special handling for browser tool calls (playwright-cli via Bash)
|
||||
if (toolCall.name === 'Bash') {
|
||||
const command = toolCall.input?.command || '';
|
||||
if (command.includes('playwright-cli')) {
|
||||
const browserAction = formatBrowserAction(command);
|
||||
if (browserAction) {
|
||||
processedLines.push(browserAction);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// If JSON parsing fails, treat as regular text
|
||||
processedLines.push(line);
|
||||
}
|
||||
} else {
|
||||
// Keep non-JSON lines (assistant text)
|
||||
processedLines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
return processedLines.join('\n');
|
||||
}
|
||||
|
||||
export function detectExecutionContext(description: string): ExecutionContext {
|
||||
const isParallelExecution = description.includes('vuln agent') || description.includes('exploit agent');
|
||||
|
||||
@@ -252,62 +170,69 @@ export function detectExecutionContext(description: string): ExecutionContext {
|
||||
description.includes('exploit agent');
|
||||
|
||||
const agentType = extractAgentType(description);
|
||||
|
||||
const agentKey = description.toLowerCase().replace(/\s+/g, '-');
|
||||
|
||||
return { isParallelExecution, useCleanOutput, agentType, agentKey };
|
||||
}
|
||||
|
||||
/** Format assistant turn text (from a pi `turn_end` event). */
|
||||
export function formatAssistantOutput(
|
||||
cleanedContent: string,
|
||||
text: string,
|
||||
context: ExecutionContext,
|
||||
turnCount: number,
|
||||
description: string,
|
||||
): string[] {
|
||||
if (!cleanedContent.trim()) {
|
||||
if (!text.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
|
||||
if (context.isParallelExecution) {
|
||||
// Compact output for parallel agents with prefixes
|
||||
const prefix = getAgentPrefix(description);
|
||||
lines.push(`${prefix} ${cleanedContent}`);
|
||||
} else {
|
||||
// Full turn output for sequential agents
|
||||
lines.push(`\n Turn ${turnCount} (${description}):`);
|
||||
lines.push(` ${cleanedContent}`);
|
||||
// Compact, attributed output for interleaved parallel agents.
|
||||
return [`${getAgentPrefix(description)} ${text}`];
|
||||
}
|
||||
|
||||
return lines;
|
||||
// Full turn output for sequential agents.
|
||||
return [`\n Turn ${turnCount} (${description}):`, ` ${text}`];
|
||||
}
|
||||
|
||||
export function formatResultOutput(data: ResultData, showFullResult: boolean): string[] {
|
||||
const lines: string[] = [];
|
||||
/**
|
||||
* Format a pi `tool_execution_start` event into a clean one-line progress indicator.
|
||||
*
|
||||
* Maps the common tool surfaces — `task` (sub-agent delegation), `todo_write`
|
||||
* (plan updates), `bash` (incl. playwright-cli browser actions), read-only file
|
||||
* tools, and the structured collector/submit tools — to friendly lines. Returns
|
||||
* `[]` when there's nothing worth surfacing (e.g. a todo update with no active item).
|
||||
*/
|
||||
export function formatToolCall(
|
||||
toolName: string,
|
||||
args: Record<string, unknown> | undefined,
|
||||
context: ExecutionContext,
|
||||
description: string,
|
||||
): string[] {
|
||||
const input = (args ?? {}) as ToolCallInput;
|
||||
let line: string | null;
|
||||
|
||||
lines.push(`\n COMPLETED:`);
|
||||
lines.push(` Duration: ${(data.duration_ms / 1000).toFixed(1)}s, Cost: $${data.cost.toFixed(4)}`);
|
||||
|
||||
if (data.subtype === 'error_max_turns') {
|
||||
lines.push(` Stopped: Hit maximum turns limit`);
|
||||
} else if (data.subtype === 'error_during_execution') {
|
||||
lines.push(` Stopped: Execution error`);
|
||||
if (toolName === 'task') {
|
||||
line = `🚀 Launching ${input.description ?? 'sub-agent'}`;
|
||||
} else if (toolName === 'todo_write') {
|
||||
line = summarizeTodoUpdate(input);
|
||||
} else if (toolName === 'bash') {
|
||||
const command = typeof input.command === 'string' ? input.command : '';
|
||||
line = command.includes('playwright-cli') ? formatBrowserAction(command) : `💻 ${command.slice(0, 60)}`;
|
||||
} else if (toolName === 'read' || toolName === 'grep' || toolName === 'find' || toolName === 'ls') {
|
||||
const path = typeof input.path === 'string' ? ` ${input.path.slice(0, 60)}` : '';
|
||||
line = `📖 ${toolName}${path}`;
|
||||
} else if (toolName.startsWith('set_') || toolName.startsWith('add_') || toolName.startsWith('submit_')) {
|
||||
line = `📊 ${toolName.replace(/_/g, ' ')}`;
|
||||
} else {
|
||||
line = `🔧 ${toolName}`;
|
||||
}
|
||||
|
||||
if (data.permissionDenials > 0) {
|
||||
lines.push(` ${data.permissionDenials} permission denials`);
|
||||
}
|
||||
if (!line) return [];
|
||||
|
||||
if (showFullResult && data.result && typeof data.result === 'string') {
|
||||
if (data.result.length > 1000) {
|
||||
lines.push(` ${data.result.slice(0, 1000)}... [${data.result.length} total chars]`);
|
||||
} else {
|
||||
lines.push(` ${data.result}`);
|
||||
}
|
||||
if (context.isParallelExecution) {
|
||||
return [`${getAgentPrefix(description)} ${line}`];
|
||||
}
|
||||
|
||||
return lines;
|
||||
return [` ${line}`];
|
||||
}
|
||||
|
||||
export function formatErrorOutput(
|
||||
@@ -321,12 +246,11 @@ export function formatErrorOutput(
|
||||
const lines: string[] = [];
|
||||
|
||||
if (context.isParallelExecution) {
|
||||
const prefix = getAgentPrefix(description);
|
||||
lines.push(`${prefix} Failed (${formatDuration(duration)})`);
|
||||
lines.push(`${getAgentPrefix(description)} Failed (${formatDuration(duration)})`);
|
||||
} else if (context.useCleanOutput) {
|
||||
lines.push(`${context.agentType} failed (${formatDuration(duration)})`);
|
||||
} else {
|
||||
lines.push(` Claude Code failed: ${description} (${formatDuration(duration)})`);
|
||||
lines.push(` pi agent failed: ${description} (${formatDuration(duration)})`);
|
||||
}
|
||||
|
||||
lines.push(` Error Type: ${error.constructor.name}`);
|
||||
@@ -352,35 +276,12 @@ export function formatCompletionMessage(
|
||||
duration: number,
|
||||
): string {
|
||||
if (context.isParallelExecution) {
|
||||
const prefix = getAgentPrefix(description);
|
||||
return `${prefix} Complete (${turnCount} turns, ${formatDuration(duration)})`;
|
||||
return `${getAgentPrefix(description)} Complete (${turnCount} turns, ${formatDuration(duration)})`;
|
||||
}
|
||||
|
||||
if (context.useCleanOutput) {
|
||||
return `${context.agentType.charAt(0).toUpperCase() + context.agentType.slice(1)} complete! (${turnCount} turns, ${formatDuration(duration)})`;
|
||||
}
|
||||
|
||||
return ` Claude Code completed: ${description} (${turnCount} turns) in ${formatDuration(duration)}`;
|
||||
}
|
||||
|
||||
export function formatToolUseOutput(toolName: string, input: Record<string, unknown> | undefined): string[] {
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push(`\n Using Tool: ${toolName}`);
|
||||
if (input && Object.keys(input).length > 0) {
|
||||
lines.push(` Input: ${JSON.stringify(input, null, 2)}`);
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
export function formatToolResultOutput(displayContent: string): string[] {
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push(` Tool Result:`);
|
||||
if (displayContent) {
|
||||
lines.push(` ${displayContent}`);
|
||||
}
|
||||
|
||||
return lines;
|
||||
return ` pi agent completed: ${description} (${turnCount} turns) in ${formatDuration(duration)}`;
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
// Copyright (C) 2025 Keygraph, Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License version 3
|
||||
// as published by the Free Software Foundation.
|
||||
|
||||
/**
|
||||
* code_path "avoid" enforcement for the pi harness, delegated to the
|
||||
* @gotgenes/pi-permission-system extension.
|
||||
*
|
||||
* Each `code_path` avoid is translated into the extension's cross-cutting `path`
|
||||
* deny surface — the strongest gate, blocking file access (read/edit/write/grep/
|
||||
* find/ls) AND recognized bash file commands (cat/grep/sed/…) on any matching path,
|
||||
* across every tool and child `task` session, not overridable by a per-tool allow.
|
||||
*
|
||||
* `external_directory: allow` keeps the extension from gating the agent's legitimate
|
||||
* access outside the working directory once it is loaded (the pentest agent shells
|
||||
* out to tools/paths outside the mounted repo). When there are no avoids the config
|
||||
* is removed so the executor skips loading the extension entirely.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import path from 'node:path';
|
||||
import { getAgentDir } from '@earendil-works/pi-coding-agent';
|
||||
import type { DistributedConfig } from '../../types/config.js';
|
||||
|
||||
const PERMISSION_EXTENSION_ID = 'pi-permission-system';
|
||||
|
||||
/**
|
||||
* Translate one avoid value into the extension's flat-wildcard `path` patterns.
|
||||
*
|
||||
* The extension's `*` already spans path separators (no `**` globstar), and tool
|
||||
* paths are compared as absolute. A plain directory value is expanded to cover the
|
||||
* directory itself and everything under it, in both cwd-relative and prefixed
|
||||
* (absolute) positions. Glob values fold `**`→`*`; a `dir/*` contents glob also
|
||||
* denies the directory entry itself.
|
||||
*/
|
||||
export function toPathPatterns(value: string): string[] {
|
||||
// Strip only leading path prefixes ("/", "./", "../"); preserve a dotfile's dot
|
||||
// (so `.env` stays `.env`, not `env`).
|
||||
const base = value.replace(/^(?:\.{0,2}\/)+/, '').replace(/\/+$/, '');
|
||||
if (!base) return [];
|
||||
|
||||
if (base.includes('*') || base.includes('?')) {
|
||||
// The extension's `*` already spans path separators, so fold `**` to `*`.
|
||||
const flat = base.replace(/\*\*\//g, '*/').replace(/\*\*/g, '*');
|
||||
const tail = flat.replace(/^(?:\*\/)+/, '');
|
||||
const patterns = [flat, `*/${tail}`];
|
||||
// Depth-agnostic catch-all only for a bare-name tail (so `**/*.env` hits a
|
||||
// root-level `.env`); a structured tail would over-match sibling names.
|
||||
if (!tail.includes('/')) {
|
||||
patterns.push(tail.startsWith('*') ? tail : `*${tail}`);
|
||||
}
|
||||
// A `dir/*` contents glob should also deny the directory entry itself — the
|
||||
// contents patterns require a trailing segment and wouldn't match the folder.
|
||||
if (flat.endsWith('/*')) {
|
||||
const folder = flat.slice(0, -2);
|
||||
if (folder && !folder.includes('*')) {
|
||||
patterns.push(folder, `*/${folder}`);
|
||||
}
|
||||
}
|
||||
return [...new Set(patterns)];
|
||||
}
|
||||
|
||||
return [base, `${base}/*`, `*/${base}`, `*/${base}/*`];
|
||||
}
|
||||
|
||||
interface PermissionSystemConfig {
|
||||
permission: {
|
||||
'*': 'allow';
|
||||
path: Record<string, 'allow' | 'deny'>;
|
||||
external_directory: 'allow';
|
||||
};
|
||||
}
|
||||
|
||||
/** Build the extension config that denies every avoid pattern across all tools. */
|
||||
export function buildPermissionConfig(patterns: readonly string[]): PermissionSystemConfig {
|
||||
// Default allow first; deny entries are appended so they win (last match wins).
|
||||
const pathRules: Record<string, 'allow' | 'deny'> = { '*': 'allow' };
|
||||
for (const pattern of patterns) {
|
||||
for (const expanded of toPathPatterns(pattern)) {
|
||||
pathRules[expanded] = 'deny';
|
||||
}
|
||||
}
|
||||
return {
|
||||
permission: {
|
||||
'*': 'allow',
|
||||
path: pathRules,
|
||||
external_directory: 'allow',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Path to the extension's global config under the agent directory. */
|
||||
export function permissionSystemConfigPath(agentDir: string): string {
|
||||
return path.join(agentDir, 'extensions', PERMISSION_EXTENSION_ID, 'config.json');
|
||||
}
|
||||
|
||||
/** True when a pi-permission-system config has been written (avoid rules exist). */
|
||||
export function permissionSystemConfigExists(agentDir: string): boolean {
|
||||
return fs.existsSync(permissionSystemConfigPath(agentDir));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync the distributed config's `code_path` avoids into the extension's global
|
||||
* config (`<agentDir>/extensions/pi-permission-system/config.json`). When there
|
||||
* are no avoids the config is removed so the executor skips loading the extension.
|
||||
*
|
||||
* Global (not project) config is used deliberately: it loads synchronously at
|
||||
* extension init without depending on a session_start/ctx, it keeps the config
|
||||
* out of the scanned repo, and it is idempotent across the agents of one run.
|
||||
*/
|
||||
export function syncPermissionSystemConfig(config: DistributedConfig | null): void {
|
||||
const configPath = permissionSystemConfigPath(getAgentDir());
|
||||
const avoidRules = (config?.avoid ?? []).filter((r) => r.type === 'code_path');
|
||||
|
||||
if (avoidRules.length === 0) {
|
||||
fs.rmSync(configPath, { force: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// Single-repo (fixed mount): patterns are the raw avoid values.
|
||||
const patterns = avoidRules.map((r) => r.value);
|
||||
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
||||
fs.writeFileSync(configPath, JSON.stringify(buildPermissionConfig(patterns), null, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Absolute path to the installed @gotgenes/pi-permission-system package directory,
|
||||
* suitable for `DefaultResourceLoader`'s `additionalExtensionPaths`. The loader
|
||||
* reads the package's `pi.extensions` manifest and loads the extension itself.
|
||||
*
|
||||
* The package's `.` export points at its service module, so we resolve that and
|
||||
* walk up to the package root. Throws if the package is not resolvable.
|
||||
*/
|
||||
export function permissionSystemPackageDir(): string {
|
||||
const require = createRequire(import.meta.url);
|
||||
const servicePath = require.resolve('@gotgenes/pi-permission-system');
|
||||
return path.resolve(path.dirname(servicePath), '..');
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
// Copyright (C) 2025 Keygraph, Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License version 3
|
||||
// as published by the Free Software Foundation.
|
||||
|
||||
// Production agent execution on the pi harness, with git checkpoints and audit logging.
|
||||
|
||||
import os from 'node:os';
|
||||
import type { AgentMessage } from '@earendil-works/pi-agent-core';
|
||||
import {
|
||||
type AgentSession,
|
||||
type AgentSessionEvent,
|
||||
createAgentSession,
|
||||
DefaultResourceLoader,
|
||||
getAgentDir,
|
||||
type ResourceLoader,
|
||||
SessionManager,
|
||||
SettingsManager,
|
||||
type Skill,
|
||||
type ToolDefinition,
|
||||
} from '@earendil-works/pi-coding-agent';
|
||||
import { fs, path } from 'zx';
|
||||
import type { AuditSession } from '../../audit/index.js';
|
||||
import { BASH_TIMEOUT_EXTENSION_DIR, deliverablesDir } from '../../paths.js';
|
||||
import { isRetryableFailure, PentestError } from '../../services/error-handling.js';
|
||||
import { AGENT_VALIDATORS } from '../../session-manager.js';
|
||||
import type { ActivityLogger } from '../../types/activity-logger.js';
|
||||
import { isBrowserAgent } from '../../utils/browser-agents.js';
|
||||
import { formatTimestamp } from '../../utils/formatting.js';
|
||||
import { Timer } from '../../utils/metrics.js';
|
||||
import { createAuditLogger } from '../audit-logger.js';
|
||||
import { resolveModelSelection } from '../models.js';
|
||||
import {
|
||||
detectExecutionContext,
|
||||
formatAssistantOutput,
|
||||
formatCompletionMessage,
|
||||
formatErrorOutput,
|
||||
formatToolCall,
|
||||
} from '../output-formatters.js';
|
||||
import { createProgressManager } from '../progress-manager.js';
|
||||
import type { CapturedSubmitTool } from '../submit-tool.js';
|
||||
import { permissionSystemConfigExists, permissionSystemPackageDir } from './permission-system.js';
|
||||
import { PI_RETRY_SETTINGS } from './retry-settings.js';
|
||||
import { createGlobTool, createTodoWriteTool } from './session-tools.js';
|
||||
import { createTaskTool } from './task-tool.js';
|
||||
import { providerTurnError } from './turn-error.js';
|
||||
|
||||
declare global {
|
||||
var SHANNON_DISABLE_LOADER: boolean | undefined;
|
||||
}
|
||||
|
||||
/** Built-in pi tools enabled for every agent (custom tool names are appended). */
|
||||
const BUILTIN_TOOLS = ['read', 'bash', 'edit', 'write', 'grep', 'find', 'ls'];
|
||||
|
||||
/** Build the playwright-cli Skill object injected for browser-using agents. */
|
||||
function buildPlaywrightSkill(): Skill {
|
||||
const filePath =
|
||||
process.env.PLAYWRIGHT_CLI_SKILL_PATH ?? path.join(os.homedir(), '.claude/skills/playwright-cli/SKILL.md');
|
||||
const baseDir = path.dirname(filePath);
|
||||
return {
|
||||
name: 'playwright-cli',
|
||||
description:
|
||||
'Drive a real browser via the playwright-cli binary. Use for any task that navigates, clicks, ' +
|
||||
'fills forms, takes screenshots, or reads live pages.',
|
||||
filePath,
|
||||
baseDir,
|
||||
sourceInfo: { path: filePath, source: 'custom', scope: 'user', origin: 'top-level', baseDir },
|
||||
disableModelInvocation: false,
|
||||
};
|
||||
}
|
||||
|
||||
async function buildResourceLoader(
|
||||
cwd: string,
|
||||
logger: ActivityLogger,
|
||||
agentName: string | null,
|
||||
): Promise<ResourceLoader> {
|
||||
// Always enforce bounded bash timeouts so an unbounded command cannot hang the agent.
|
||||
const additionalExtensionPaths: string[] = [BASH_TIMEOUT_EXTENSION_DIR];
|
||||
if (permissionSystemConfigExists(getAgentDir())) {
|
||||
try {
|
||||
additionalExtensionPaths.push(permissionSystemPackageDir());
|
||||
} catch {
|
||||
logger.warn(
|
||||
'code_path deny config present but @gotgenes/pi-permission-system not resolvable — skipping enforcement',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Only browser-driving agents get the playwright-cli skill; the rest run with no skills.
|
||||
const loader = new DefaultResourceLoader({
|
||||
cwd,
|
||||
agentDir: getAgentDir(),
|
||||
...(additionalExtensionPaths.length > 0 && { additionalExtensionPaths }),
|
||||
...(isBrowserAgent(agentName)
|
||||
? {
|
||||
skillsOverride: (base) => ({
|
||||
skills: [buildPlaywrightSkill()],
|
||||
diagnostics: base.diagnostics,
|
||||
}),
|
||||
}
|
||||
: { noSkills: true }),
|
||||
});
|
||||
await loader.reload();
|
||||
return loader;
|
||||
}
|
||||
|
||||
interface ChildUsage {
|
||||
cost: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheReadTokens: number;
|
||||
cacheWriteTokens: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Usage for one agent: the parent session plus every `task` sub-session it
|
||||
* spawned. Sub-sessions keep their own stats, so their spend is accumulated
|
||||
* separately and added here.
|
||||
*/
|
||||
function totalUsage(session: AgentSession | undefined, childUsage: ChildUsage) {
|
||||
const stats = session?.getSessionStats();
|
||||
return {
|
||||
cost: (stats?.cost ?? 0) + childUsage.cost,
|
||||
inputTokens: (stats?.tokens.input ?? 0) + childUsage.inputTokens,
|
||||
outputTokens: (stats?.tokens.output ?? 0) + childUsage.outputTokens,
|
||||
cacheReadTokens: (stats?.tokens.cacheRead ?? 0) + childUsage.cacheReadTokens,
|
||||
cacheWriteTokens: (stats?.tokens.cacheWrite ?? 0) + childUsage.cacheWriteTokens,
|
||||
};
|
||||
}
|
||||
|
||||
export interface PiPromptResult {
|
||||
result?: string | null | undefined;
|
||||
success: boolean;
|
||||
duration: number;
|
||||
turns?: number | undefined;
|
||||
cost: number;
|
||||
inputTokens?: number | undefined;
|
||||
outputTokens?: number | undefined;
|
||||
cacheReadTokens?: number | undefined;
|
||||
cacheWriteTokens?: number | undefined;
|
||||
model?: string | undefined;
|
||||
error?: string | undefined;
|
||||
errorType?: string | undefined;
|
||||
prompt?: string | undefined;
|
||||
retryable?: boolean | undefined;
|
||||
structuredOutput?: unknown;
|
||||
}
|
||||
|
||||
function outputLines(lines: string[]): void {
|
||||
for (const line of lines) {
|
||||
console.log(line);
|
||||
}
|
||||
}
|
||||
|
||||
async function writeErrorLog(
|
||||
err: Error & { code?: string; status?: number },
|
||||
sourceDir: string,
|
||||
fullPrompt: string,
|
||||
duration: number,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const errorLog = {
|
||||
timestamp: formatTimestamp(),
|
||||
agent: 'pi-executor',
|
||||
error: { name: err.constructor.name, message: err.message, code: err.code, status: err.status, stack: err.stack },
|
||||
context: { sourceDir, prompt: `${fullPrompt.slice(0, 200)}...`, retryable: isRetryableFailure(err) },
|
||||
duration,
|
||||
};
|
||||
const logPath = path.join(deliverablesDir(sourceDir), 'error.log');
|
||||
await fs.appendFile(logPath, `${JSON.stringify(errorLog)}\n`);
|
||||
} catch {
|
||||
// Best-effort error log writing - don't propagate failures
|
||||
}
|
||||
}
|
||||
|
||||
export async function validateAgentOutput(
|
||||
result: PiPromptResult,
|
||||
agentName: string | null,
|
||||
sourceDir: string,
|
||||
logger: ActivityLogger,
|
||||
): Promise<boolean> {
|
||||
logger.info(`Validating ${agentName} agent output`);
|
||||
try {
|
||||
if (!result.success || (!result.result && result.structuredOutput === undefined)) {
|
||||
logger.error('Validation failed: Agent execution was unsuccessful');
|
||||
return false;
|
||||
}
|
||||
const validator = agentName ? AGENT_VALIDATORS[agentName as keyof typeof AGENT_VALIDATORS] : undefined;
|
||||
if (!validator) {
|
||||
logger.warn(`No validator found for agent "${agentName}" - assuming success`);
|
||||
return true;
|
||||
}
|
||||
logger.info(`Using validator for agent: ${agentName}`, { sourceDir });
|
||||
const validationResult = await validator(sourceDir, logger);
|
||||
if (validationResult) {
|
||||
logger.info('Validation passed: Required files/structure present');
|
||||
} else {
|
||||
logger.error('Validation failed: Missing required deliverable files');
|
||||
}
|
||||
return validationResult;
|
||||
} catch (error) {
|
||||
const errMsg = error instanceof Error ? error.message : String(error);
|
||||
logger.error(`Validation failed with error: ${errMsg}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Concatenate the text blocks of an assistant message (skips thinking + tool calls). */
|
||||
function extractAssistantText(message: AgentMessage): string {
|
||||
if (message.role !== 'assistant') return '';
|
||||
const blocks = message.content as Array<{ type: string; text?: string }>;
|
||||
return blocks
|
||||
.filter((c) => c.type === 'text')
|
||||
.map((c) => c.text ?? '')
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
// Low-level pi execution. Drives one agent session to completion with progress and
|
||||
// audit logging. Exported for Temporal activities to call single-attempt execution.
|
||||
export async function runPiPrompt(
|
||||
prompt: string,
|
||||
sourceDir: string,
|
||||
context: string = '',
|
||||
description: string = 'Agent analysis',
|
||||
agentName: string | null = null,
|
||||
auditSession: AuditSession | null = null,
|
||||
logger: ActivityLogger,
|
||||
callerTools?: ToolDefinition[],
|
||||
deliverablesSubdir?: string,
|
||||
cancellationSignal?: AbortSignal,
|
||||
submitTool?: CapturedSubmitTool,
|
||||
): Promise<PiPromptResult> {
|
||||
// 1. Initialize timing and prompt. A submit tool appends its directive so the
|
||||
// instruction to call it lives with the tool, not in every prompt file.
|
||||
const timer = new Timer(`agent-${description.toLowerCase().replace(/\s+/g, '-')}`);
|
||||
const basePrompt = context ? `${context}\n\n${prompt}` : prompt;
|
||||
const fullPrompt = submitTool?.directive ? basePrompt + submitTool.directive : basePrompt;
|
||||
|
||||
// 2. Set up progress and audit infrastructure
|
||||
const execContext = detectExecutionContext(description);
|
||||
const progress = createProgressManager(
|
||||
{ description, useCleanOutput: execContext.useCleanOutput },
|
||||
global.SHANNON_DISABLE_LOADER ?? false,
|
||||
);
|
||||
const auditLogger = createAuditLogger(auditSession);
|
||||
|
||||
logger.info(`Running pi agent: ${description}...`);
|
||||
|
||||
// 3. Expose bash-invoked CLI tooling (playwright-cli, save-deliverable) to the
|
||||
// environment pi's bash tool inherits. These are constant per container, so
|
||||
// setting them on process.env is parallel-safe across this workflow's agents.
|
||||
process.env.PLAYWRIGHT_MCP_OUTPUT_DIR = deliverablesSubdir
|
||||
? path.join(sourceDir, path.dirname(deliverablesSubdir), '.playwright-cli')
|
||||
: path.join(sourceDir, '.shannon', '.playwright-cli');
|
||||
if (deliverablesSubdir) process.env.SHANNON_DELIVERABLES_SUBDIR = deliverablesSubdir;
|
||||
|
||||
// 4. Resolve model + auth, then assemble the tool set (universal task/todo tools
|
||||
// plus any caller-supplied collector/submit tools).
|
||||
const selection = await resolveModelSelection();
|
||||
const resourceLoader = await buildResourceLoader(sourceDir, logger, agentName);
|
||||
// Accumulates usage from in-process `task` child sessions so the parent's reported
|
||||
// cost includes sub-agent spend (their getSessionStats is separate from ours).
|
||||
const childUsage: ChildUsage = { cost: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 };
|
||||
const customTools: ToolDefinition[] = [
|
||||
createTaskTool({
|
||||
model: selection.model,
|
||||
modelRuntime: selection.modelRuntime,
|
||||
cwd: sourceDir,
|
||||
onUsage: (usage) => {
|
||||
childUsage.cost += usage.cost;
|
||||
childUsage.inputTokens += usage.inputTokens;
|
||||
childUsage.outputTokens += usage.outputTokens;
|
||||
childUsage.cacheReadTokens += usage.cacheReadTokens;
|
||||
childUsage.cacheWriteTokens += usage.cacheWriteTokens;
|
||||
},
|
||||
resourceLoader,
|
||||
...(cancellationSignal && { cancellationSignal }),
|
||||
}),
|
||||
createTodoWriteTool(auditLogger),
|
||||
createGlobTool(sourceDir),
|
||||
...(callerTools ?? []),
|
||||
...(submitTool ? [submitTool.tool] : []),
|
||||
];
|
||||
// pi's `tools` allowlist gates custom tools too — list every custom name.
|
||||
const tools = [...BUILTIN_TOOLS, ...customTools.map((t) => t.name)];
|
||||
|
||||
let turnCount = 0;
|
||||
let pendingError: PentestError | null = null;
|
||||
// Declared out here so the catch can bill spend accrued before a failure.
|
||||
let session: AgentSession | undefined;
|
||||
|
||||
// Abort the in-flight agent when the Temporal activity is cancelled (UI/CLI cancel).
|
||||
// Without this the top-level session runs to startToCloseTimeout despite the cancel.
|
||||
const onCancellation = (): void => {
|
||||
void session?.abort().catch(() => {
|
||||
// Best-effort — the session is torn down regardless once the prompt unwinds.
|
||||
});
|
||||
};
|
||||
|
||||
progress.start();
|
||||
|
||||
try {
|
||||
({ session } = await createAgentSession({
|
||||
cwd: sourceDir,
|
||||
model: selection.model,
|
||||
tools,
|
||||
customTools,
|
||||
modelRuntime: selection.modelRuntime,
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
// Temporal owns agent restarts, pi absorbs transport faults (see
|
||||
// PI_RETRY_SETTINGS); compaction stays on to guard against context overflow
|
||||
// on long agent runs.
|
||||
settingsManager: SettingsManager.inMemory({ retry: PI_RETRY_SETTINGS, compaction: { enabled: true } }),
|
||||
resourceLoader,
|
||||
}));
|
||||
|
||||
// Wire activity cancellation to the session now that it exists.
|
||||
if (cancellationSignal?.aborted) {
|
||||
onCancellation();
|
||||
} else {
|
||||
cancellationSignal?.addEventListener('abort', onCancellation, { once: true });
|
||||
}
|
||||
|
||||
// 5. Map pi events to audit logging + progress + error capture.
|
||||
session.subscribe((event: AgentSessionEvent) => {
|
||||
switch (event.type) {
|
||||
case 'turn_end': {
|
||||
turnCount += 1;
|
||||
const msg = event.message;
|
||||
const text = extractAssistantText(msg);
|
||||
if (text.trim()) {
|
||||
void auditLogger.logLlmResponse(turnCount, text);
|
||||
progress.stop();
|
||||
outputLines(formatAssistantOutput(text, execContext, turnCount, description));
|
||||
progress.start();
|
||||
}
|
||||
if (msg.role === 'assistant' && msg.stopReason === 'error') {
|
||||
pendingError = pendingError ?? providerTurnError(msg, 'Agent error', selection.model.contextWindow);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'tool_execution_start': {
|
||||
void auditLogger.logToolStart(event.toolName, event.args);
|
||||
const toolLines = formatToolCall(
|
||||
event.toolName,
|
||||
event.args as Record<string, unknown>,
|
||||
execContext,
|
||||
description,
|
||||
);
|
||||
if (toolLines.length > 0) {
|
||||
progress.stop();
|
||||
outputLines(toolLines);
|
||||
progress.start();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'tool_execution_end':
|
||||
void auditLogger.logToolEnd(event.result);
|
||||
break;
|
||||
case 'compaction_end':
|
||||
if (!event.aborted && !event.willRetry && event.errorMessage) {
|
||||
pendingError =
|
||||
pendingError ??
|
||||
new PentestError(`Context compaction failed: ${event.errorMessage.slice(0, 200)}`, 'unknown', true);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// 6. Run the agent to completion (resolves at agent_end).
|
||||
await session.prompt(fullPrompt);
|
||||
session.dispose();
|
||||
|
||||
// 7. Surface any error captured during the run.
|
||||
if (pendingError) throw pendingError;
|
||||
|
||||
// 8. Read usage/cost and final text.
|
||||
const usage = totalUsage(session, childUsage);
|
||||
const result = session.getLastAssistantText() ?? null;
|
||||
|
||||
const duration = timer.stop();
|
||||
progress.finish(formatCompletionMessage(execContext, description, turnCount, duration));
|
||||
|
||||
// Capture the submit tool's structured payload so callers read it off the
|
||||
// result instead of holding a reference to the tool.
|
||||
const structuredOutput = submitTool?.getCaptured();
|
||||
|
||||
return {
|
||||
result,
|
||||
success: true,
|
||||
duration,
|
||||
turns: turnCount,
|
||||
cost: usage.cost,
|
||||
inputTokens: usage.inputTokens,
|
||||
outputTokens: usage.outputTokens,
|
||||
cacheReadTokens: usage.cacheReadTokens,
|
||||
cacheWriteTokens: usage.cacheWriteTokens,
|
||||
model: selection.model.id,
|
||||
...(structuredOutput !== undefined && { structuredOutput }),
|
||||
};
|
||||
} catch (error) {
|
||||
// 10. Handle errors — log, write error file, return failure
|
||||
const duration = timer.stop();
|
||||
const err = error as Error & { code?: string; status?: number };
|
||||
await auditLogger.logError(err, duration, turnCount);
|
||||
progress.stop();
|
||||
outputLines(formatErrorOutput(err, execContext, description, duration, sourceDir, isRetryableFailure(err)));
|
||||
await writeErrorLog(err, sourceDir, fullPrompt, duration);
|
||||
|
||||
// A failed agent still spent money — on its own turns and, since Shannon's
|
||||
// prompts delegate the heavy work, mostly on `task` sub-agents. Both count
|
||||
// toward the run's usage.
|
||||
const usage = totalUsage(session, childUsage);
|
||||
|
||||
return {
|
||||
error: err.message,
|
||||
errorType: err instanceof PentestError && err.code ? err.code : err.constructor.name,
|
||||
prompt: `${fullPrompt.slice(0, 100)}...`,
|
||||
success: false,
|
||||
duration,
|
||||
turns: turnCount,
|
||||
cost: usage.cost,
|
||||
inputTokens: usage.inputTokens,
|
||||
outputTokens: usage.outputTokens,
|
||||
cacheReadTokens: usage.cacheReadTokens,
|
||||
cacheWriteTokens: usage.cacheWriteTokens,
|
||||
retryable: isRetryableFailure(err),
|
||||
};
|
||||
} finally {
|
||||
cancellationSignal?.removeEventListener('abort', onCancellation);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// Copyright (C) 2025 Keygraph, Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License version 3
|
||||
// as published by the Free Software Foundation.
|
||||
|
||||
/**
|
||||
* Retry split between the two layers that can restart work.
|
||||
*
|
||||
* `enabled: false` turns off pi's own agent-level retry loop — Temporal owns
|
||||
* agent restarts, and both retrying the same turn would compound. `provider`
|
||||
* settings are read independently of that flag, so transport faults
|
||||
* (408/409/429/5xx) are still absorbed inside the session, which is far cheaper
|
||||
* than a Temporal retry that re-runs the agent and respends its tokens.
|
||||
*
|
||||
* `maxRetries` is handed to the selected vendor's SDK, which owns the backoff, so
|
||||
* the schedule varies by provider rather than following one formula.
|
||||
*
|
||||
* NOTE: pi recommends keeping this at 0, since SDK-level retries consume
|
||||
* out-of-usage-limit responses before pi's classifier can mark them terminal.
|
||||
* Shannon accepts that trade for the transport-fault coverage. `maxRetryDelayMs`
|
||||
* is left at pi's 60s default so a server asking for a longer wait fails fast
|
||||
* instead of parking the activity.
|
||||
*/
|
||||
export const PI_RETRY_SETTINGS = {
|
||||
enabled: false,
|
||||
provider: { maxRetries: 8 },
|
||||
} as const;
|
||||
@@ -0,0 +1,116 @@
|
||||
// Copyright (C) 2025 Keygraph, Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License version 3
|
||||
// as published by the Free Software Foundation.
|
||||
|
||||
/**
|
||||
* Per-session custom tools registered for every agent: `todo_write` and `glob`.
|
||||
*
|
||||
* These replace harness built-ins that pi does not ship. `todo_write` is a
|
||||
* full-state-replace planning scratchpad mirrored to the workflow log; `glob` is
|
||||
* fast-glob file matching (pi has no `Glob` built-in).
|
||||
*/
|
||||
|
||||
import { defineTool, type ToolDefinition } from '@earendil-works/pi-coding-agent';
|
||||
import { Type } from 'typebox';
|
||||
import { fs, glob, path } from 'zx';
|
||||
|
||||
import type { AuditLogger } from '../audit-logger.js';
|
||||
|
||||
export interface TodoItem {
|
||||
content: string;
|
||||
status: 'pending' | 'in_progress' | 'completed';
|
||||
activeForm: string;
|
||||
}
|
||||
|
||||
function renderTodos(todos: readonly TodoItem[]): string {
|
||||
const mark = (status: TodoItem['status']): string => {
|
||||
if (status === 'completed') return 'x';
|
||||
if (status === 'in_progress') return '~';
|
||||
return ' ';
|
||||
};
|
||||
return todos.map((todo) => `[${mark(todo.status)}] ${todo.content}`).join(' ');
|
||||
}
|
||||
|
||||
export function createTodoWriteTool(auditLogger: AuditLogger): ToolDefinition {
|
||||
let current: TodoItem[] = [];
|
||||
|
||||
return defineTool({
|
||||
name: 'todo_write',
|
||||
label: 'Todo Write',
|
||||
description:
|
||||
'Use this tool to create and manage a structured task list for your current session. ' +
|
||||
'Pass the complete todo list on every call; it replaces the stored list entirely. Each ' +
|
||||
'todo has a status of pending, in_progress, or completed.',
|
||||
promptSnippet: 'todo_write: create and manage a structured task list',
|
||||
parameters: Type.Object({
|
||||
todos: Type.Array(
|
||||
Type.Object({
|
||||
content: Type.String({ description: 'Imperative task description, e.g. "Map SSRF sinks".' }),
|
||||
status: Type.Union([Type.Literal('pending'), Type.Literal('in_progress'), Type.Literal('completed')]),
|
||||
activeForm: Type.String({ description: 'Present-continuous form, e.g. "Mapping SSRF sinks".' }),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
async execute(_toolCallId, params) {
|
||||
current = params.todos as TodoItem[];
|
||||
const completed = current.filter((todo) => todo.status === 'completed').length;
|
||||
await auditLogger.logNote('todo', renderTodos(current));
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Todos updated (${current.length} items, ${completed} completed).`,
|
||||
},
|
||||
],
|
||||
details: undefined,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function createGlobTool(cwd: string): ToolDefinition {
|
||||
return defineTool({
|
||||
name: 'glob',
|
||||
label: 'Glob',
|
||||
description:
|
||||
'Fast file pattern matching. Supports glob patterns like "**/*.ts" or "src/**/*.{js,ts}". ' +
|
||||
'Returns matching file paths sorted by modification time, most recent first.',
|
||||
promptSnippet: 'glob: find files by name pattern',
|
||||
parameters: Type.Object({
|
||||
pattern: Type.String({ description: 'The glob pattern to match files against.' }),
|
||||
path: Type.Optional(Type.String({ description: 'Directory to search in. Omit for the repository root.' })),
|
||||
}),
|
||||
async execute(_toolCallId, params) {
|
||||
const searchRoot = params.path ? path.resolve(cwd, params.path) : cwd;
|
||||
const matches = await glob.globby(params.pattern, {
|
||||
cwd: searchRoot,
|
||||
absolute: true,
|
||||
dot: true,
|
||||
onlyFiles: true,
|
||||
followSymbolicLinks: false,
|
||||
});
|
||||
|
||||
if (matches.length === 0) {
|
||||
return { content: [{ type: 'text' as const, text: 'No files found' }], details: undefined };
|
||||
}
|
||||
|
||||
const withMtime = await Promise.all(
|
||||
matches.map(async (file) => {
|
||||
try {
|
||||
return { file, mtime: (await fs.stat(file)).mtimeMs };
|
||||
} catch {
|
||||
return { file, mtime: 0 };
|
||||
}
|
||||
}),
|
||||
);
|
||||
withMtime.sort((a, b) => b.mtime - a.mtime);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: withMtime.map((match) => match.file).join('\n') }],
|
||||
details: undefined,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
// Copyright (C) 2025 Keygraph, Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License version 3
|
||||
// as published by the Free Software Foundation.
|
||||
|
||||
/**
|
||||
* Generic `task` tool — pi.dev ships no built-in Task tool, so this supplies the
|
||||
* Task-delegation surface Shannon's prompts require.
|
||||
*
|
||||
* Shannon's prompts mandate Task delegation (recon source tracer; the vuln
|
||||
* agents delegate *every* code review; the exploit agents delegate automation),
|
||||
* so this tool is required for parity, not optional. It spawns a nested pi
|
||||
* session with the parent's resolved model object (never a tier string — that
|
||||
* would route sub-agents through hardcoded IDs and leak billing), the parent's
|
||||
* resource loader, and a fixed child tool surface.
|
||||
*/
|
||||
|
||||
import { type AssistantMessage, type Model, Type } from '@earendil-works/pi-ai';
|
||||
import {
|
||||
createAgentSession,
|
||||
defineTool,
|
||||
getAgentDir,
|
||||
type ModelRuntime,
|
||||
type ResourceLoader,
|
||||
SessionManager,
|
||||
SettingsManager,
|
||||
type ToolDefinition,
|
||||
} from '@earendil-works/pi-coding-agent';
|
||||
import { PI_RETRY_SETTINGS } from './retry-settings.js';
|
||||
|
||||
export interface TaskToolContext {
|
||||
cwd: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
model: Model<any>;
|
||||
/** Parent's model/auth runtime, reused so sub-agents share its resolved credential. */
|
||||
modelRuntime: ModelRuntime;
|
||||
resourceLoader: ResourceLoader;
|
||||
cancellationSignal?: AbortSignal | undefined;
|
||||
/**
|
||||
* Reports the cost/tokens of each spawned sub-session back to the caller.
|
||||
* Sub-agents run in their own pi sessions that the parent has no reference to,
|
||||
* so without this their spend (the bulk of a whitebox run, since Shannon
|
||||
* prompts delegate the heavy work) is invisible to billing.
|
||||
*/
|
||||
onUsage?: (usage: {
|
||||
cost: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheReadTokens: number;
|
||||
cacheWriteTokens: number;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
const CHILD_TOOLS = ['read', 'grep', 'find', 'ls', 'write', 'bash'];
|
||||
|
||||
function textResult(text: string) {
|
||||
return { content: [{ type: 'text' as const, text }], details: undefined };
|
||||
}
|
||||
|
||||
export function createTaskTool(config: TaskToolContext): ToolDefinition {
|
||||
const taskTool: ToolDefinition = defineTool({
|
||||
name: 'task',
|
||||
label: 'Task',
|
||||
description:
|
||||
'Delegate a focused task to a sub-agent that runs independently with its own tools and returns ' +
|
||||
'the result. Use this to break complex work into smaller, parallelizable sub-tasks.',
|
||||
executionMode: 'parallel',
|
||||
promptSnippet: 'task - Delegate a focused task to a sub-agent with read, grep, find, ls, write, and bash.',
|
||||
promptGuidelines: [
|
||||
'Use the task tool to delegate focused work: code review, reconnaissance, automation scripting, validation.',
|
||||
'Pass all necessary context in the "prompt" parameter — the sub-agent cannot see your conversation history.',
|
||||
'The sub-agent can use read, grep, find, ls, write, and bash, but cannot call task or custom collector tools.',
|
||||
'You can launch multiple task tool calls in a single message to run sub-tasks in parallel.',
|
||||
],
|
||||
parameters: Type.Object({
|
||||
prompt: Type.String({
|
||||
description: 'The task for the sub-agent to perform. Include all necessary context.',
|
||||
}),
|
||||
description: Type.Optional(Type.String({ description: 'A short (3-5 word) description of the task.' })),
|
||||
}),
|
||||
async execute(_toolCallId, params) {
|
||||
const agentDir = getAgentDir();
|
||||
const { session: subSession } = await createAgentSession({
|
||||
cwd: config.cwd,
|
||||
agentDir,
|
||||
resourceLoader: config.resourceLoader,
|
||||
model: config.model,
|
||||
tools: CHILD_TOOLS,
|
||||
modelRuntime: config.modelRuntime,
|
||||
sessionManager: SessionManager.inMemory(config.cwd),
|
||||
settingsManager: SettingsManager.inMemory({
|
||||
retry: PI_RETRY_SETTINGS,
|
||||
compaction: { enabled: true },
|
||||
}),
|
||||
});
|
||||
|
||||
const abortChildSession = (): void => {
|
||||
void subSession.abort().catch(() => {
|
||||
// Parent logger is not available inside the tool; dispose still tears
|
||||
// down the session if abort itself rejects.
|
||||
});
|
||||
};
|
||||
const onCancellation = (): void => abortChildSession();
|
||||
if (config.cancellationSignal?.aborted) {
|
||||
abortChildSession();
|
||||
} else {
|
||||
config.cancellationSignal?.addEventListener('abort', onCancellation, { once: true });
|
||||
}
|
||||
|
||||
let resultText = '';
|
||||
let subCost = 0;
|
||||
subSession.subscribe((event) => {
|
||||
if (event.type === 'turn_end') {
|
||||
const msg = event.message as AssistantMessage | undefined;
|
||||
for (const block of msg?.content ?? []) {
|
||||
if (block.type === 'text' && block.text) {
|
||||
resultText += (resultText ? '\n' : '') + block.text;
|
||||
}
|
||||
}
|
||||
if (msg?.usage?.cost?.total != null) subCost += msg.usage.cost.total;
|
||||
}
|
||||
});
|
||||
|
||||
let swallowedError: string | undefined;
|
||||
try {
|
||||
try {
|
||||
await subSession.prompt(params.prompt);
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
resultText += `\n[Sub-agent error: ${errorMsg}]`;
|
||||
}
|
||||
|
||||
swallowedError = subSession.state.errorMessage;
|
||||
// Read stats before dispose; reconcile cost the same way the parent does.
|
||||
const subStats = subSession.getSessionStats();
|
||||
if (subStats.cost > subCost) subCost = subStats.cost;
|
||||
config.onUsage?.({
|
||||
cost: subCost,
|
||||
inputTokens: subStats.tokens.input,
|
||||
outputTokens: subStats.tokens.output,
|
||||
cacheReadTokens: subStats.tokens.cacheRead,
|
||||
cacheWriteTokens: subStats.tokens.cacheWrite,
|
||||
});
|
||||
} finally {
|
||||
config.cancellationSignal?.removeEventListener('abort', onCancellation);
|
||||
subSession.dispose();
|
||||
}
|
||||
|
||||
if (swallowedError && !resultText.includes(swallowedError)) {
|
||||
resultText += `\n[Sub-agent error: ${swallowedError}]`;
|
||||
}
|
||||
|
||||
return textResult(resultText || '[Sub-agent produced no output]');
|
||||
},
|
||||
});
|
||||
|
||||
return taskTool;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Copyright (C) 2025 Keygraph, Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License version 3
|
||||
// as published by the Free Software Foundation.
|
||||
|
||||
import { type AssistantMessage, isContextOverflow, isRetryableAssistantError } from '@earendil-works/pi-ai';
|
||||
import { PentestError } from '../../services/error-handling.js';
|
||||
import { ErrorCode } from '../../types/errors.js';
|
||||
|
||||
/**
|
||||
* Wrap a failed assistant turn, taking the verdict from pi.
|
||||
*
|
||||
* Overflow is separated first, as pi's retry contract requires: it means the
|
||||
* request was too large, not that the provider faltered, so an identical retry
|
||||
* would overflow again. Everything else goes to pi's classifier, which treats
|
||||
* quota, billing, and auth exhaustion as terminal and load, throttling, and
|
||||
* transport faults as transient — those were already retried in-session, so
|
||||
* reaching here means the attempts were exhausted.
|
||||
*
|
||||
* `contextWindow` is omitted where overflow cannot apply, such as a one-word
|
||||
* credential probe.
|
||||
*/
|
||||
export function providerTurnError(message: AssistantMessage, label: string, contextWindow?: number): PentestError {
|
||||
const detail = (message.errorMessage ?? 'unknown provider error').slice(0, 300);
|
||||
|
||||
if (contextWindow !== undefined && isContextOverflow(message, contextWindow)) {
|
||||
return new PentestError(
|
||||
`${label}: context window exceeded after compaction: ${detail}`,
|
||||
'unknown',
|
||||
false,
|
||||
{ contextWindow },
|
||||
ErrorCode.AGENT_EXECUTION_FAILED,
|
||||
);
|
||||
}
|
||||
|
||||
return new PentestError(
|
||||
`${label}: ${detail}`,
|
||||
'unknown',
|
||||
isRetryableAssistantError(message),
|
||||
{},
|
||||
ErrorCode.AGENT_EXECUTION_FAILED,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// Copyright (C) 2025 Keygraph, Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License version 3
|
||||
// as published by the Free Software Foundation.
|
||||
|
||||
/**
|
||||
* Writes <sourceDir>/.playwright/cli.config.json with stealth defaults so
|
||||
* `playwright-cli open` auto-loads them from the agent's cwd. Skipped when a
|
||||
* config already exists so user-provided files are never clobbered.
|
||||
*
|
||||
* NOTE: Playwright's MCP browser config treats `initScript` entries as file
|
||||
* paths, not inline source. The stealth script is written alongside the config
|
||||
* and referenced by absolute path. Inline strings silently fail the daemon.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
async function pathExists(p: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const STEALTH_INIT_SCRIPT = `delete Object.getPrototypeOf(navigator).webdriver;
|
||||
|
||||
Object.defineProperty(navigator, 'plugins', {
|
||||
get: () => {
|
||||
const arr = [
|
||||
{ name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer', description: 'Portable Document Format' },
|
||||
{ name: 'Chrome PDF Viewer', filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai', description: '' },
|
||||
{ name: 'Native Client', filename: 'internal-nacl-plugin', description: '' },
|
||||
];
|
||||
arr.__proto__ = PluginArray.prototype;
|
||||
return arr;
|
||||
},
|
||||
});
|
||||
|
||||
window.chrome = window.chrome || {};
|
||||
window.chrome.runtime = window.chrome.runtime || {
|
||||
PlatformOs: { MAC: 'mac', WIN: 'win', ANDROID: 'android', CROS: 'cros', LINUX: 'linux', OPENBSD: 'openbsd' },
|
||||
PlatformArch: { ARM: 'arm', X86_32: 'x86-32', X86_64: 'x86-64' },
|
||||
PlatformNaclArch: { ARM: 'arm', X86_32: 'x86-32', X86_64: 'x86-64' },
|
||||
RequestUpdateCheckStatus: { THROTTLED: 'throttled', NO_UPDATE: 'no_update', UPDATE_AVAILABLE: 'update_available' },
|
||||
OnInstalledReason: { INSTALL: 'install', UPDATE: 'update', CHROME_UPDATE: 'chrome_update', SHARED_MODULE_UPDATE: 'shared_module_update' },
|
||||
OnRestartRequiredReason: { APP_UPDATE: 'app_update', OS_UPDATE: 'os_update', PERIODIC: 'periodic' },
|
||||
};
|
||||
`;
|
||||
|
||||
function buildStealthConfig(initScriptPath: string) {
|
||||
return {
|
||||
browser: {
|
||||
browserName: 'chromium',
|
||||
launchOptions: {
|
||||
headless: true,
|
||||
args: ['--disable-blink-features=AutomationControlled'],
|
||||
ignoreDefaultArgs: ['--enable-automation'],
|
||||
},
|
||||
contextOptions: {
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
locale: 'en-US',
|
||||
extraHTTPHeaders: { 'Accept-Language': 'en-US,en;q=0.9' },
|
||||
userAgent:
|
||||
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
|
||||
},
|
||||
initScript: [initScriptPath],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type StealthConfigWriteResult = 'wrote' | 'skipped-existing';
|
||||
|
||||
export async function writePlaywrightStealthConfig(
|
||||
sourceDir: string,
|
||||
): Promise<{ result: StealthConfigWriteResult; configPath: string }> {
|
||||
const playwrightDir = path.join(sourceDir, '.playwright');
|
||||
const configPath = path.join(playwrightDir, 'cli.config.json');
|
||||
if (await pathExists(configPath)) {
|
||||
return { result: 'skipped-existing', configPath };
|
||||
}
|
||||
const initScriptPath = path.join(playwrightDir, 'scripts', 'stealth.js');
|
||||
await fs.mkdir(path.dirname(initScriptPath), { recursive: true });
|
||||
await fs.writeFile(initScriptPath, STEALTH_INIT_SCRIPT);
|
||||
await fs.writeFile(configPath, JSON.stringify(buildStealthConfig(initScriptPath), null, 2));
|
||||
return { result: 'wrote', configPath };
|
||||
}
|
||||
@@ -5,104 +5,135 @@
|
||||
// as published by the Free Software Foundation.
|
||||
|
||||
/**
|
||||
* Zod schema definitions for vulnerability exploitation queue structured outputs.
|
||||
* TypeBox schemas + submit-tool factory for vulnerability exploitation queues.
|
||||
*
|
||||
* Each vuln agent returns a structured JSON response matching its schema.
|
||||
* The SDK validates the output against the JSON Schema generated from these Zod definitions.
|
||||
* pi captures each vuln agent's structured queue via a `submit_exploitation_queue`
|
||||
* custom tool whose parameters mirror the per-class schema below. Entry types are
|
||||
* derived from the same schemas and consumed by the findings renderer.
|
||||
*/
|
||||
|
||||
import type { JsonSchemaOutputFormat } from '@anthropic-ai/claude-agent-sdk';
|
||||
import { z } from 'zod';
|
||||
import { defineTool } from '@earendil-works/pi-coding-agent';
|
||||
import { type Static, type TObject, Type } from 'typebox';
|
||||
import { stringEnum } from '../collectors/schema.js';
|
||||
import type { AgentName } from '../types/agents.js';
|
||||
import type { CapturedSubmitTool } from './submit-tool.js';
|
||||
|
||||
// === Common Fields ===
|
||||
const ANALYSIS_NOTES_DESCRIPTION = 'Plain context for defenders (caveats, scope, what is at risk). Not attack steps.';
|
||||
|
||||
const baseVulnerability = z.object({
|
||||
ID: z.string(),
|
||||
vulnerability_type: z.string(),
|
||||
externally_exploitable: z.boolean(),
|
||||
confidence: z.string(),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
|
||||
// === Per-Vuln-Type Schemas ===
|
||||
|
||||
const InjectionVulnerability = baseVulnerability.extend({
|
||||
source: z.string().optional(),
|
||||
combined_sources: z.string().optional(),
|
||||
path: z.string().optional(),
|
||||
sink_call: z.string().optional(),
|
||||
slot_type: z.string().optional(),
|
||||
sanitization_observed: z.string().optional(),
|
||||
concat_occurrences: z.string().optional(),
|
||||
verdict: z.string().optional(),
|
||||
mismatch_reason: z.string().optional(),
|
||||
witness_payload: z.string().optional(),
|
||||
});
|
||||
|
||||
const XssVulnerability = baseVulnerability.extend({
|
||||
source: z.string().optional(),
|
||||
source_detail: z.string().optional(),
|
||||
path: z.string().optional(),
|
||||
sink_function: z.string().optional(),
|
||||
render_context: z.string().optional(),
|
||||
encoding_observed: z.string().optional(),
|
||||
verdict: z.string().optional(),
|
||||
mismatch_reason: z.string().optional(),
|
||||
witness_payload: z.string().optional(),
|
||||
});
|
||||
|
||||
const AuthVulnerability = baseVulnerability.extend({
|
||||
source_endpoint: z.string().optional(),
|
||||
vulnerable_code_location: z.string().optional(),
|
||||
missing_defense: z.string().optional(),
|
||||
exploitation_hypothesis: z.string().optional(),
|
||||
suggested_exploit_technique: z.string().optional(),
|
||||
});
|
||||
|
||||
const SsrfVulnerability = baseVulnerability.extend({
|
||||
source_endpoint: z.string().optional(),
|
||||
vulnerable_parameter: z.string().optional(),
|
||||
vulnerable_code_location: z.string().optional(),
|
||||
missing_defense: z.string().optional(),
|
||||
exploitation_hypothesis: z.string().optional(),
|
||||
suggested_exploit_technique: z.string().optional(),
|
||||
});
|
||||
|
||||
const AuthzVulnerability = baseVulnerability.extend({
|
||||
endpoint: z.string().optional(),
|
||||
vulnerable_code_location: z.string().optional(),
|
||||
role_context: z.string().optional(),
|
||||
guard_evidence: z.string().optional(),
|
||||
side_effect: z.string().optional(),
|
||||
reason: z.string().optional(),
|
||||
minimal_witness: z.string().optional(),
|
||||
});
|
||||
|
||||
// === Queue Wrapper Schemas ===
|
||||
|
||||
const InjectionQueueSchema = z.object({ vulnerabilities: z.array(InjectionVulnerability) });
|
||||
const XssQueueSchema = z.object({ vulnerabilities: z.array(XssVulnerability) });
|
||||
const AuthQueueSchema = z.object({ vulnerabilities: z.array(AuthVulnerability) });
|
||||
const SsrfQueueSchema = z.object({ vulnerabilities: z.array(SsrfVulnerability) });
|
||||
const AuthzQueueSchema = z.object({ vulnerabilities: z.array(AuthzVulnerability) });
|
||||
|
||||
// === Convert to JSON Schema for SDK ===
|
||||
|
||||
// NOTE: The SDK's AJV validator expects draft-07. Zod defaults to draft-2020-12 which
|
||||
// causes the SDK to silently skip structured output.
|
||||
function toOutputFormat(zodSchema: z.ZodType): JsonSchemaOutputFormat {
|
||||
return { type: 'json_schema', schema: z.toJSONSchema(zodSchema, { target: 'draft-07' }) as Record<string, unknown> };
|
||||
function optStr(description?: string) {
|
||||
return Type.Optional(Type.String(description === undefined ? {} : { description }));
|
||||
}
|
||||
|
||||
// === Lookup Maps ===
|
||||
/**
|
||||
* Base fields shared by every queue entry. `notes` gains guidance in analysis mode.
|
||||
*
|
||||
* `confidence` is enumerated so it reaches the report agent in the same casing the report
|
||||
* schema accepts — an analysis-only run carries it through verbatim as its only rating.
|
||||
*/
|
||||
function baseFields(exploit: boolean) {
|
||||
return {
|
||||
ID: Type.String(),
|
||||
vulnerability_type: Type.String(),
|
||||
externally_exploitable: Type.Boolean(),
|
||||
confidence: stringEnum(['high', 'medium', 'low'], {
|
||||
description: 'Confidence that this is a real, reachable vulnerability.',
|
||||
}),
|
||||
code_locations: Type.Optional(
|
||||
Type.Array(
|
||||
Type.Object({
|
||||
file: Type.String({ description: 'Repository-relative path, no leading slash.' }),
|
||||
start_line: Type.Optional(Type.Integer({ minimum: 1 })),
|
||||
end_line: Type.Optional(Type.Integer({ minimum: 1, description: 'Set when the flaw spans a range.' })),
|
||||
role: stringEnum(['sink', 'source', 'guard'], {
|
||||
description:
|
||||
'sink where the flaw manifests, source where untrusted input enters, guard for a check ' +
|
||||
'that is missing or misplaced.',
|
||||
}),
|
||||
symbol: Type.Optional(
|
||||
Type.String({ description: 'Enclosing function or method, named as written in the code.' }),
|
||||
),
|
||||
}),
|
||||
{ description: 'Every code site this finding touches, sink first.' },
|
||||
),
|
||||
),
|
||||
notes: exploit ? optStr() : optStr(ANALYSIS_NOTES_DESCRIPTION),
|
||||
};
|
||||
}
|
||||
|
||||
const VULN_AGENT_OUTPUT_FORMAT: Partial<Record<AgentName, JsonSchemaOutputFormat>> = {
|
||||
'injection-vuln': toOutputFormat(InjectionQueueSchema),
|
||||
'xss-vuln': toOutputFormat(XssQueueSchema),
|
||||
'auth-vuln': toOutputFormat(AuthQueueSchema),
|
||||
'ssrf-vuln': toOutputFormat(SsrfQueueSchema),
|
||||
'authz-vuln': toOutputFormat(AuthzQueueSchema),
|
||||
const injectionFields = {
|
||||
source: optStr(),
|
||||
combined_sources: optStr(),
|
||||
path: optStr(),
|
||||
sink_call: optStr(),
|
||||
slot_type: optStr(),
|
||||
sanitization_observed: optStr(),
|
||||
concat_occurrences: optStr(),
|
||||
verdict: optStr(),
|
||||
mismatch_reason: optStr(),
|
||||
witness_payload: optStr(),
|
||||
};
|
||||
|
||||
const xssFields = {
|
||||
source: optStr(),
|
||||
source_detail: optStr(),
|
||||
path: optStr(),
|
||||
sink_function: optStr(),
|
||||
render_context: optStr(),
|
||||
encoding_observed: optStr(),
|
||||
verdict: optStr(),
|
||||
mismatch_reason: optStr(),
|
||||
witness_payload: optStr(),
|
||||
};
|
||||
|
||||
const authFields = {
|
||||
source_endpoint: optStr(),
|
||||
vulnerable_code_location: optStr(),
|
||||
missing_defense: optStr(),
|
||||
exploitation_hypothesis: optStr(),
|
||||
suggested_exploit_technique: optStr(),
|
||||
};
|
||||
|
||||
const ssrfFields = {
|
||||
source_endpoint: optStr(),
|
||||
vulnerable_parameter: optStr(),
|
||||
vulnerable_code_location: optStr(),
|
||||
missing_defense: optStr(),
|
||||
exploitation_hypothesis: optStr(),
|
||||
suggested_exploit_technique: optStr(),
|
||||
};
|
||||
|
||||
const authzFields = {
|
||||
endpoint: optStr(),
|
||||
vulnerable_code_location: optStr(),
|
||||
role_context: optStr(),
|
||||
guard_evidence: optStr(),
|
||||
side_effect: optStr(),
|
||||
reason: optStr(),
|
||||
minimal_witness: optStr(),
|
||||
};
|
||||
|
||||
// === Per-entry schemas (single vulnerability). Entry types derive from these. ===
|
||||
|
||||
const injectionEntry = () => Type.Object({ ...baseFields(true), ...injectionFields });
|
||||
const xssEntry = () => Type.Object({ ...baseFields(true), ...xssFields });
|
||||
const authEntry = () => Type.Object({ ...baseFields(true), ...authFields });
|
||||
const ssrfEntry = () => Type.Object({ ...baseFields(true), ...ssrfFields });
|
||||
const authzEntry = () => Type.Object({ ...baseFields(true), ...authzFields });
|
||||
|
||||
export type QueueCodeLocation = NonNullable<Static<ReturnType<typeof injectionEntry>>['code_locations']>[number];
|
||||
|
||||
export type InjectionFinding = Static<ReturnType<typeof injectionEntry>>;
|
||||
export type XssFinding = Static<ReturnType<typeof xssEntry>>;
|
||||
export type AuthFinding = Static<ReturnType<typeof authEntry>>;
|
||||
export type SsrfFinding = Static<ReturnType<typeof ssrfEntry>>;
|
||||
export type AuthzFinding = Static<ReturnType<typeof authzEntry>>;
|
||||
|
||||
const PER_TYPE_FIELDS: Partial<Record<AgentName, Record<string, ReturnType<typeof optStr>>>> = {
|
||||
'injection-vuln': injectionFields,
|
||||
'xss-vuln': xssFields,
|
||||
'auth-vuln': authFields,
|
||||
'ssrf-vuln': ssrfFields,
|
||||
'authz-vuln': authzFields,
|
||||
};
|
||||
|
||||
const VULN_AGENT_QUEUE_FILENAMES: Partial<Record<AgentName, string>> = {
|
||||
@@ -113,12 +144,53 @@ const VULN_AGENT_QUEUE_FILENAMES: Partial<Record<AgentName, string>> = {
|
||||
'authz-vuln': 'authz_exploitation_queue.json',
|
||||
};
|
||||
|
||||
/** Returns the structured output format for a vuln agent, or undefined for non-vuln agents. */
|
||||
export function getOutputFormat(agentName: AgentName): JsonSchemaOutputFormat | undefined {
|
||||
return VULN_AGENT_OUTPUT_FORMAT[agentName];
|
||||
/** Build the TypeBox submit-tool parameters for a vuln agent, or undefined for non-vuln agents. */
|
||||
function queueSchema(agentName: AgentName, exploit: boolean): TObject | undefined {
|
||||
const extra = PER_TYPE_FIELDS[agentName];
|
||||
if (!extra) return undefined;
|
||||
return Type.Object({
|
||||
vulnerabilities: Type.Array(Type.Object({ ...baseFields(exploit), ...extra })),
|
||||
});
|
||||
}
|
||||
|
||||
/** Returns the queue filename for a vuln agent, or undefined for non-vuln agents. */
|
||||
export function getQueueFilename(agentName: AgentName): string | undefined {
|
||||
return VULN_AGENT_QUEUE_FILENAMES[agentName];
|
||||
}
|
||||
|
||||
/** Build the pi submit tool that captures the exploitation queue for vuln agents. */
|
||||
export function createQueueSubmitTool(agentName: AgentName, exploit = true): CapturedSubmitTool | undefined {
|
||||
const schema = queueSchema(agentName, exploit);
|
||||
if (!schema) return undefined;
|
||||
|
||||
let captured: unknown | undefined;
|
||||
return {
|
||||
tool: defineTool({
|
||||
name: 'submit_exploitation_queue',
|
||||
label: 'Submit Exploitation Queue',
|
||||
description:
|
||||
'Submit the final structured list of analyzed vulnerabilities for this class. Call exactly once when analysis is complete.',
|
||||
promptSnippet: 'submit_exploitation_queue: record the final structured findings list (call once)',
|
||||
promptGuidelines: [
|
||||
'You MUST call submit_exploitation_queue exactly once as your final action.',
|
||||
'Include every analyzed finding in the vulnerabilities array.',
|
||||
],
|
||||
parameters: schema,
|
||||
async execute(_toolCallId, params) {
|
||||
captured = params;
|
||||
const count = Array.isArray((params as { vulnerabilities?: unknown }).vulnerabilities)
|
||||
? (params as { vulnerabilities: unknown[] }).vulnerabilities.length
|
||||
: 0;
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Recorded ${count} findings.` }],
|
||||
details: params,
|
||||
terminate: true,
|
||||
};
|
||||
},
|
||||
}),
|
||||
getCaptured: () => captured,
|
||||
directive:
|
||||
'\n\nYou MUST call the submit_exploitation_queue tool exactly once as your final action ' +
|
||||
'to deliver your structured exploitation queue. Do not output JSON as text. Fill every required parameter.',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// Copyright (C) 2025 Keygraph, Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License version 3
|
||||
// as published by the Free Software Foundation.
|
||||
|
||||
import { defineTool, type ToolDefinition } from '@earendil-works/pi-coding-agent';
|
||||
import { Type } from 'typebox';
|
||||
|
||||
/**
|
||||
* A pi custom submit tool plus the captured payload it records.
|
||||
*
|
||||
* pi ships no JSON-schema output format, so an agent that must return structured
|
||||
* data does so by calling a purpose-built TypeBox tool. This bundles that tool
|
||||
* with its capture accessor and the directive that instructs the model to call
|
||||
* it. The executor owns the wiring — it registers the tool, appends the
|
||||
* directive to the prompt, and reads `getCaptured()` back as `structuredOutput`
|
||||
* — so callers never assemble it by hand.
|
||||
*/
|
||||
export interface CapturedSubmitTool {
|
||||
readonly tool: ToolDefinition;
|
||||
readonly getCaptured: () => unknown | undefined;
|
||||
readonly directive?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `submit_result` tool from a raw JSON Schema, for agents whose result
|
||||
* shape is not one of the built-in per-agent schemas (e.g. an out-of-tree agent
|
||||
* with its own verdict schema). pi validates the tool call against `schema`
|
||||
* before `execute()` runs, so a captured payload is already schema-valid — no
|
||||
* separate validation pass is needed.
|
||||
*/
|
||||
export function createGenericSubmitTool(schema: Record<string, unknown>): CapturedSubmitTool {
|
||||
let captured: unknown | undefined;
|
||||
return {
|
||||
tool: defineTool({
|
||||
name: 'submit_result',
|
||||
label: 'Submit Result',
|
||||
description: 'Return your final structured answer. Call exactly once as your last action.',
|
||||
promptSnippet: 'submit_result: deliver your structured answer (call once)',
|
||||
promptGuidelines: [
|
||||
'You MUST call submit_result exactly once as your final action.',
|
||||
'Fill every required parameter. Do not output JSON as text.',
|
||||
],
|
||||
parameters: Type.Unsafe(schema),
|
||||
async execute(_toolCallId, params) {
|
||||
captured = params;
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: 'Result submitted.' }],
|
||||
details: params,
|
||||
terminate: true,
|
||||
};
|
||||
},
|
||||
}),
|
||||
getCaptured: () => captured,
|
||||
directive:
|
||||
'\n\nYou MUST call the submit_result tool exactly once as your final action ' +
|
||||
'to deliver your structured answer. Do not output JSON as text. Fill every required parameter.',
|
||||
};
|
||||
}
|
||||
@@ -4,9 +4,7 @@
|
||||
// it under the terms of the GNU Affero General Public License version 3
|
||||
// as published by the Free Software Foundation.
|
||||
|
||||
// Type definitions for Claude executor message processing pipeline
|
||||
|
||||
import type { SDKAssistantMessageError } from '@anthropic-ai/claude-agent-sdk';
|
||||
// Shared display/formatting types for the agent executor output layer.
|
||||
|
||||
export interface ExecutionContext {
|
||||
isParallelExecution: boolean;
|
||||
@@ -14,88 +12,3 @@ export interface ExecutionContext {
|
||||
agentType: string;
|
||||
agentKey: string;
|
||||
}
|
||||
|
||||
export interface AssistantResult {
|
||||
content: string;
|
||||
cleanedContent: string;
|
||||
apiErrorDetected: boolean;
|
||||
shouldThrow?: Error;
|
||||
logData: {
|
||||
turn: number;
|
||||
content: string;
|
||||
timestamp: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ResultData {
|
||||
result: string | null;
|
||||
cost: number;
|
||||
duration_ms: number;
|
||||
subtype?: string;
|
||||
stop_reason?: string | null;
|
||||
permissionDenials: number;
|
||||
structuredOutput?: unknown;
|
||||
}
|
||||
|
||||
export interface ToolUseData {
|
||||
toolName: string;
|
||||
parameters: Record<string, unknown>;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface ToolResultData {
|
||||
content: unknown;
|
||||
displayContent: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface ContentBlock {
|
||||
type?: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export interface AssistantMessage {
|
||||
type: 'assistant';
|
||||
error?: SDKAssistantMessageError;
|
||||
message: {
|
||||
content: ContentBlock[] | string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ResultMessage {
|
||||
type: 'result';
|
||||
result?: string;
|
||||
total_cost_usd?: number;
|
||||
duration_ms?: number;
|
||||
subtype?: string;
|
||||
stop_reason?: string | null;
|
||||
permission_denials?: unknown[];
|
||||
structured_output?: unknown;
|
||||
}
|
||||
|
||||
export interface ToolUseMessage {
|
||||
type: 'tool_use';
|
||||
name: string;
|
||||
input?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ToolResultMessage {
|
||||
type: 'tool_result';
|
||||
content?: unknown;
|
||||
}
|
||||
|
||||
export interface ApiErrorDetection {
|
||||
detected: boolean;
|
||||
shouldThrow?: Error;
|
||||
}
|
||||
|
||||
export interface SystemInitMessage {
|
||||
type: 'system';
|
||||
subtype: 'init';
|
||||
model?: string;
|
||||
permissionMode?: string;
|
||||
}
|
||||
|
||||
export interface UserMessage {
|
||||
type: 'user';
|
||||
}
|
||||
@@ -28,7 +28,7 @@ const sessionMutex = new SessionMutex();
|
||||
* AuditSession - Main audit system facade
|
||||
*/
|
||||
export class AuditSession {
|
||||
private sessionMetadata: SessionMetadata;
|
||||
readonly sessionMetadata: SessionMetadata;
|
||||
private sessionId: string;
|
||||
private metricsTracker: MetricsTracker;
|
||||
private workflowLogger: WorkflowLogger;
|
||||
@@ -158,6 +158,14 @@ export class AuditSession {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a human-readable note to the unified workflow log (e.g. a model
|
||||
* refusal fallback). Independent of agent event logging.
|
||||
*/
|
||||
async logWorkflowNote(category: string, message: string): Promise<void> {
|
||||
await this.workflowLogger.logEvent(category, message);
|
||||
}
|
||||
|
||||
/**
|
||||
* End agent execution (mutex-protected)
|
||||
*/
|
||||
@@ -202,7 +210,7 @@ export class AuditSession {
|
||||
/**
|
||||
* Update session status
|
||||
*/
|
||||
async updateSessionStatus(status: 'in-progress' | 'completed' | 'failed' | 'cancelled'): Promise<void> {
|
||||
async updateSessionStatus(status: 'in-progress' | 'completed' | 'failed' | 'cancelled' | 'partial'): Promise<void> {
|
||||
await this.ensureInitialized();
|
||||
|
||||
const unlock = await sessionMutex.lock(this.sessionId);
|
||||
|
||||
@@ -23,6 +23,11 @@ interface AttemptData {
|
||||
attempt_number: number;
|
||||
duration_ms: number;
|
||||
cost_usd: number;
|
||||
input_tokens?: number | undefined;
|
||||
output_tokens?: number | undefined;
|
||||
cache_read_tokens?: number | undefined;
|
||||
cache_write_tokens?: number | undefined;
|
||||
turns?: number | undefined;
|
||||
success: boolean;
|
||||
timestamp: string;
|
||||
model?: string | undefined;
|
||||
@@ -34,6 +39,10 @@ interface AgentAuditMetrics {
|
||||
attempts: AttemptData[];
|
||||
final_duration_ms: number;
|
||||
total_cost_usd: number;
|
||||
total_input_tokens: number;
|
||||
total_output_tokens: number;
|
||||
total_cache_read_tokens: number;
|
||||
total_cache_write_tokens: number;
|
||||
model?: string | undefined;
|
||||
checkpoint?: string | undefined;
|
||||
}
|
||||
@@ -57,7 +66,7 @@ interface SessionData {
|
||||
id: string;
|
||||
webUrl: string;
|
||||
repoPath?: string;
|
||||
status: 'in-progress' | 'completed' | 'failed' | 'cancelled';
|
||||
status: 'in-progress' | 'completed' | 'failed' | 'cancelled' | 'partial';
|
||||
createdAt: string;
|
||||
completedAt?: string;
|
||||
originalWorkflowId?: string; // First workflow that created this workspace
|
||||
@@ -174,6 +183,10 @@ export class MetricsTracker {
|
||||
attempts: [],
|
||||
final_duration_ms: 0,
|
||||
total_cost_usd: 0,
|
||||
total_input_tokens: 0,
|
||||
total_output_tokens: 0,
|
||||
total_cache_read_tokens: 0,
|
||||
total_cache_write_tokens: 0,
|
||||
};
|
||||
this.data.metrics.agents[agentName] = agent;
|
||||
|
||||
@@ -184,6 +197,11 @@ export class MetricsTracker {
|
||||
cost_usd: result.cost_usd,
|
||||
success: result.success,
|
||||
timestamp: formatTimestamp(),
|
||||
...(result.input_tokens !== undefined && { input_tokens: result.input_tokens }),
|
||||
...(result.output_tokens !== undefined && { output_tokens: result.output_tokens }),
|
||||
...(result.cache_read_tokens !== undefined && { cache_read_tokens: result.cache_read_tokens }),
|
||||
...(result.cache_write_tokens !== undefined && { cache_write_tokens: result.cache_write_tokens }),
|
||||
...(result.turns !== undefined && { turns: result.turns }),
|
||||
};
|
||||
|
||||
if (result.model) {
|
||||
@@ -197,8 +215,12 @@ export class MetricsTracker {
|
||||
// 3. Append attempt to history
|
||||
agent.attempts.push(attempt);
|
||||
|
||||
// 4. Recalculate total cost across all attempts (includes failures)
|
||||
// 4. Recalculate totals across all attempts (includes failures)
|
||||
agent.total_cost_usd = agent.attempts.reduce((sum, a) => sum + a.cost_usd, 0);
|
||||
agent.total_input_tokens = agent.attempts.reduce((sum, a) => sum + (a.input_tokens ?? 0), 0);
|
||||
agent.total_output_tokens = agent.attempts.reduce((sum, a) => sum + (a.output_tokens ?? 0), 0);
|
||||
agent.total_cache_read_tokens = agent.attempts.reduce((sum, a) => sum + (a.cache_read_tokens ?? 0), 0);
|
||||
agent.total_cache_write_tokens = agent.attempts.reduce((sum, a) => sum + (a.cache_write_tokens ?? 0), 0);
|
||||
|
||||
// 5. Update agent status based on outcome
|
||||
if (result.success) {
|
||||
@@ -214,9 +236,9 @@ export class MetricsTracker {
|
||||
agent.checkpoint = result.checkpoint;
|
||||
}
|
||||
} else {
|
||||
if (result.isFinalAttempt) {
|
||||
agent.status = 'failed';
|
||||
}
|
||||
// A non-final failed attempt stays in-progress (Temporal will retry); only the
|
||||
// terminal attempt (or an unqualified failure) marks the agent failed.
|
||||
agent.status = result.isFinalAttempt === false ? 'in-progress' : 'failed';
|
||||
}
|
||||
|
||||
// 7. Clear active timer
|
||||
@@ -232,12 +254,12 @@ export class MetricsTracker {
|
||||
/**
|
||||
* Update session status
|
||||
*/
|
||||
async updateSessionStatus(status: 'in-progress' | 'completed' | 'failed' | 'cancelled'): Promise<void> {
|
||||
async updateSessionStatus(status: 'in-progress' | 'completed' | 'failed' | 'cancelled' | 'partial'): Promise<void> {
|
||||
if (!this.data) return;
|
||||
|
||||
this.data.session.status = status;
|
||||
|
||||
if (status === 'completed' || status === 'failed' || status === 'cancelled') {
|
||||
if (status === 'completed' || status === 'failed' || status === 'cancelled' || status === 'partial') {
|
||||
this.data.session.completedAt = formatTimestamp();
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
import { WORKSPACES_DIR } from '../paths.js';
|
||||
import { INTERNAL_DIR, WORKSPACES_DIR } from '../paths.js';
|
||||
import { ensureDirectory } from '../utils/file-io.js';
|
||||
|
||||
export type { SessionMetadata } from '../types/audit.js';
|
||||
@@ -35,8 +35,9 @@ export function generateSessionIdentifier(sessionMetadata: SessionMetadata): str
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate path to audit log directory for a session
|
||||
* Uses custom outputPath if provided, otherwise defaults to WORKSPACES_DIR
|
||||
* Generate path to a run directory for a session (its top level).
|
||||
* Uses custom outputPath if provided, otherwise defaults to WORKSPACES_DIR.
|
||||
* Only the final report lives here; all internals live under INTERNAL_DIR.
|
||||
*/
|
||||
export function generateAuditPath(sessionMetadata: SessionMetadata): string {
|
||||
const sessionIdentifier = generateSessionIdentifier(sessionMetadata);
|
||||
@@ -44,6 +45,14 @@ export function generateAuditPath(sessionMetadata: SessionMetadata): string {
|
||||
return path.join(baseDir, sessionIdentifier);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate path to the hidden internals directory inside a run directory.
|
||||
* Holds logs, prompts, session state, deliverables, and browser artifacts.
|
||||
*/
|
||||
export function generateInternalPath(sessionMetadata: SessionMetadata): string {
|
||||
return path.join(generateAuditPath(sessionMetadata), INTERNAL_DIR);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate path to agent log file
|
||||
*/
|
||||
@@ -53,47 +62,54 @@ export function generateLogPath(
|
||||
timestamp: number,
|
||||
attemptNumber: number,
|
||||
): string {
|
||||
const auditPath = generateAuditPath(sessionMetadata);
|
||||
const internalPath = generateInternalPath(sessionMetadata);
|
||||
const filename = `${timestamp}_${agentName}_attempt-${attemptNumber}.log`;
|
||||
return path.join(auditPath, 'agents', filename);
|
||||
return path.join(internalPath, 'agents', filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate path to prompt snapshot file
|
||||
*/
|
||||
export function generatePromptPath(sessionMetadata: SessionMetadata, agentName: string): string {
|
||||
const auditPath = generateAuditPath(sessionMetadata);
|
||||
return path.join(auditPath, 'prompts', `${agentName}.md`);
|
||||
const internalPath = generateInternalPath(sessionMetadata);
|
||||
return path.join(internalPath, 'prompts', `${agentName}.md`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate path to session.json file
|
||||
*/
|
||||
export function generateSessionJsonPath(sessionMetadata: SessionMetadata): string {
|
||||
const auditPath = generateAuditPath(sessionMetadata);
|
||||
return path.join(auditPath, 'session.json');
|
||||
const internalPath = generateInternalPath(sessionMetadata);
|
||||
return path.join(internalPath, 'session.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Path to the shared authenticated browser session saved by the preflight
|
||||
* validator and consumed by downstream agents via `_shared-session.txt`.
|
||||
*/
|
||||
export function authStateFile(sessionMetadata: SessionMetadata): string {
|
||||
return path.join(generateInternalPath(sessionMetadata), 'auth-state.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate path to workflow.log file
|
||||
*/
|
||||
export function generateWorkflowLogPath(sessionMetadata: SessionMetadata): string {
|
||||
const auditPath = generateAuditPath(sessionMetadata);
|
||||
return path.join(auditPath, 'workflow.log');
|
||||
const internalPath = generateInternalPath(sessionMetadata);
|
||||
return path.join(internalPath, 'workflow.log');
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize audit directory structure for a session
|
||||
* Creates: workspaces/{sessionId}/, agents/, prompts/, deliverables/
|
||||
* Initialize audit directory structure for a session.
|
||||
* Creates: workspaces/{sessionId}/.shannon/{agents,prompts}. The deliverables,
|
||||
* scratchpad, and browser dirs are created host-side and bind-mounted in.
|
||||
*/
|
||||
export async function initializeAuditStructure(sessionMetadata: SessionMetadata): Promise<void> {
|
||||
const auditPath = generateAuditPath(sessionMetadata);
|
||||
const agentsPath = path.join(auditPath, 'agents');
|
||||
const promptsPath = path.join(auditPath, 'prompts');
|
||||
const deliverablesPath = path.join(auditPath, 'deliverables');
|
||||
const internalPath = generateInternalPath(sessionMetadata);
|
||||
const agentsPath = path.join(internalPath, 'agents');
|
||||
const promptsPath = path.join(internalPath, 'prompts');
|
||||
|
||||
await ensureDirectory(auditPath);
|
||||
await ensureDirectory(internalPath);
|
||||
await ensureDirectory(agentsPath);
|
||||
await ensureDirectory(promptsPath);
|
||||
await ensureDirectory(deliverablesPath);
|
||||
}
|
||||
@@ -30,7 +30,7 @@ export interface AgentMetricsSummary {
|
||||
}
|
||||
|
||||
export interface WorkflowSummary {
|
||||
status: 'completed' | 'failed' | 'cancelled';
|
||||
status: 'completed' | 'failed' | 'cancelled' | 'partial';
|
||||
totalDurationMs: number;
|
||||
totalCostUsd: number;
|
||||
completedAgents: string[];
|
||||
@@ -77,18 +77,18 @@ export class WorkflowLogger {
|
||||
* Write header to log file
|
||||
*/
|
||||
private async writeHeader(): Promise<void> {
|
||||
const header = [
|
||||
const lines = [
|
||||
`================================================================================`,
|
||||
`Shannon Pentest - Workflow Log`,
|
||||
`Shannon Pentest - Scan Log`,
|
||||
`================================================================================`,
|
||||
`Workflow ID: ${this.workflowId ?? this.sessionMetadata.id}`,
|
||||
`Target URL: ${this.sessionMetadata.webUrl}`,
|
||||
`Started: ${formatTimestamp()}`,
|
||||
`================================================================================`,
|
||||
``,
|
||||
].join('\n');
|
||||
];
|
||||
|
||||
return this.logStream.write(header);
|
||||
lines.push(`================================================================================`, ``);
|
||||
|
||||
return this.logStream.write(lines.join('\n'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -120,7 +120,7 @@ export class WorkflowLogger {
|
||||
}
|
||||
|
||||
/**
|
||||
* Format timestamp for log line (local time, human readable)
|
||||
* Format timestamp for log line (UTC, human readable)
|
||||
*/
|
||||
private formatLogTime(): string {
|
||||
const now = new Date();
|
||||
@@ -323,7 +323,7 @@ export class WorkflowLogger {
|
||||
const lines: string[] = [
|
||||
'',
|
||||
'================================================================================',
|
||||
`Workflow ${status}`,
|
||||
`Scan ${status}`,
|
||||
'────────────────────────────────────────',
|
||||
`Workflow ID: ${this.workflowId ?? this.sessionMetadata.id}`,
|
||||
`Status: ${summary.status}`,
|
||||
|
||||
@@ -0,0 +1,445 @@
|
||||
// Copyright (C) 2025 Keygraph, Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License version 3
|
||||
// as published by the Free Software Foundation.
|
||||
|
||||
/**
|
||||
* Exploit Collector tool factory (parameterized by vulnerability class and
|
||||
* per-run valid-ID set).
|
||||
*
|
||||
* Exposes a single TypeBox-validated tool `add_exploit`, called once per
|
||||
* processed vulnerability by the 5 exploit-* agents (injection, xss, auth,
|
||||
* ssrf, authz). After the agent terminates, the host harvests
|
||||
* collector.getAll() and runs exploit-renderer to produce
|
||||
* {class}_exploitation_evidence.md. The collector state is the structured
|
||||
* output.
|
||||
*
|
||||
* Schema shape:
|
||||
* - The visible parameter schema is a single Type.Object with common fields
|
||||
* required, status as a string union, and per-status fields marked optional
|
||||
* at the tool layer (TypeBox cannot express a top-level discriminated union
|
||||
* as the flat tool parameters). Each field's `description` text explains
|
||||
* when it applies.
|
||||
* - True per-status field enforcement runs inside the tool handler via a
|
||||
* Type.Union([exploited, blocked]) re-validation using the TypeBox `Value`
|
||||
* API. Missing-field errors come back to the agent as structured issues
|
||||
* with retryable=true so it can fix and retry the call.
|
||||
*
|
||||
* Strict queue-ID validation: vulnerability_id is checked against the per-run
|
||||
* queue's known IDs in the handler. Hallucinated or typo'd IDs are rejected
|
||||
* with a structured error that includes the valid-ID list, letting the agent
|
||||
* recover locally.
|
||||
*
|
||||
* Each field's description carries the bullet labels and reproducibility
|
||||
* guidance, so the harness injects it into the agent's tool catalog.
|
||||
*/
|
||||
|
||||
import { defineTool, type ToolDefinition } from '@earendil-works/pi-coding-agent';
|
||||
import { type TSchema, Type } from 'typebox';
|
||||
import { Value } from 'typebox/value';
|
||||
import { stringEnum } from './schema.js';
|
||||
|
||||
// ============================================================================
|
||||
// CLASS DISCRIMINATOR
|
||||
// ============================================================================
|
||||
|
||||
export const EXPLOIT_VULN_CLASSES = ['injection', 'xss', 'auth', 'ssrf', 'authz'] as const;
|
||||
export type VulnClass = (typeof EXPLOIT_VULN_CLASSES)[number];
|
||||
|
||||
// ============================================================================
|
||||
// SCHEMA CONSTANTS
|
||||
// ============================================================================
|
||||
|
||||
const SEVERITY_VALUES = ['critical', 'high', 'medium', 'low'] as const;
|
||||
const CONFIDENCE_VALUES = ['high', 'medium', 'low'] as const;
|
||||
|
||||
const VALID_IDS_PREVIEW_LIMIT = 8;
|
||||
|
||||
function formatValidIdsPreview(validIds: ReadonlySet<string>): string {
|
||||
const list = [...validIds];
|
||||
const head = list.slice(0, VALID_IDS_PREVIEW_LIMIT).join(', ');
|
||||
return list.length > VALID_IDS_PREVIEW_LIMIT ? `${head}, … (${list.length} total)` : head;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PUBLIC TYPES (discriminated union — what consumers see)
|
||||
// ============================================================================
|
||||
|
||||
export type ExploitedExploit = {
|
||||
status: 'exploited';
|
||||
vulnerability_id: string;
|
||||
title: string;
|
||||
vulnerable_location: string;
|
||||
overview: string;
|
||||
prerequisites?: string | null;
|
||||
severity: (typeof SEVERITY_VALUES)[number];
|
||||
impact: string;
|
||||
exploitation_steps: string[];
|
||||
proof_of_impact: string;
|
||||
notes?: string | null;
|
||||
};
|
||||
|
||||
export type BlockedExploit = {
|
||||
status: 'blocked';
|
||||
vulnerability_id: string;
|
||||
title: string;
|
||||
vulnerable_location: string;
|
||||
overview: string;
|
||||
prerequisites?: string | null;
|
||||
confidence: (typeof CONFIDENCE_VALUES)[number];
|
||||
current_blocker: string;
|
||||
potential_impact: string;
|
||||
evidence_of_vulnerability: string;
|
||||
what_we_tried: string;
|
||||
how_this_would_be_exploited: string[];
|
||||
expected_impact: string;
|
||||
notes?: string | null;
|
||||
};
|
||||
|
||||
export type AddExploitInput = ExploitedExploit | BlockedExploit;
|
||||
|
||||
// ============================================================================
|
||||
// SCHEMA BUILDER
|
||||
// ============================================================================
|
||||
|
||||
export function buildSchemas(validIds: ReadonlySet<string>) {
|
||||
const vulnerabilityIdField = Type.String({
|
||||
minLength: 1,
|
||||
description:
|
||||
'Vulnerability identifier (e.g. "INJ-VULN-03"). Must match an ID from this run\'s ' +
|
||||
'{class}_exploitation_queue.json exactly — the collector rejects IDs not in the queue. ' +
|
||||
`Valid IDs for this run: ${formatValidIdsPreview(validIds)}.`,
|
||||
});
|
||||
|
||||
const titleField = Type.String({
|
||||
minLength: 1,
|
||||
description:
|
||||
'Descriptive vulnerability title (e.g. "SQL Injection — User Search", "IDOR — Unauthorized ' +
|
||||
'Access to User Orders"). Concise; encodes the vulnerability category and where it lives.',
|
||||
});
|
||||
|
||||
const vulnerableLocationField = Type.String({
|
||||
minLength: 1,
|
||||
description:
|
||||
'Endpoint or mechanism where the vulnerability exists (e.g. "GET /api/products?id=", ' + '"POST /login").',
|
||||
});
|
||||
|
||||
const overviewField = Type.String({
|
||||
minLength: 1,
|
||||
description:
|
||||
'Brief summary of the exploit itself — what the vulnerability is and how it was demonstrated ' +
|
||||
'(or how it would be demonstrated, for blocked findings). 1-3 sentences.',
|
||||
});
|
||||
|
||||
const prerequisitesField = Type.Optional(
|
||||
Type.Union([Type.String(), Type.Null()], {
|
||||
description:
|
||||
'Required setup, tools, or conditions to reproduce the exploit (e.g. authentication, ' +
|
||||
'specific role, prior application state). Omit or pass null when no prerequisites apply.',
|
||||
}),
|
||||
);
|
||||
|
||||
const notesField = Type.Optional(
|
||||
Type.Union([Type.String(), Type.Null()], {
|
||||
description:
|
||||
'Optional supplementary context — caveats, related findings, environmental observations. ' +
|
||||
'Free-form Markdown. Omit or pass null when N/A.',
|
||||
}),
|
||||
);
|
||||
|
||||
const statusField = stringEnum(['exploited', 'blocked'], {
|
||||
description:
|
||||
'Verdict bucket. Set to "exploited" only after reaching Proof of Exploitation Level 3+ with ' +
|
||||
'concrete impact evidence (extracted data, executed JavaScript, account takeover, internal ' +
|
||||
'service access). Set to "blocked" only for real vulnerabilities where external factors ' +
|
||||
'(NOT security defenses) prevented full exploitation. Findings where a security defense ' +
|
||||
'successfully prevented exploitation after exhaustive bypass attempts are FALSE POSITIVE — ' +
|
||||
'route those to your workspace tracking file, not this tool.',
|
||||
});
|
||||
|
||||
// Per-status fields. All optional at the flat shape layer because a single
|
||||
// Type.Object cannot express a top-level discriminated union; the handler
|
||||
// re-validates against the strict union below for true enforcement.
|
||||
const severityField = Type.Optional(
|
||||
Type.Union([stringEnum(SEVERITY_VALUES), Type.Null()], {
|
||||
description:
|
||||
'REQUIRED when status="exploited". Severity of the demonstrated impact. Critical = Level 4 ' +
|
||||
'(admin credentials extracted, sensitive data dumped, system commands executed, full account ' +
|
||||
'takeover). High = Level 3 (data extraction proven, authentication bypass confirmed, ' +
|
||||
'internal service access). Medium/Low based on impact narrowness or read-only access. Must ' +
|
||||
'reflect demonstrated impact, not theoretical potential.',
|
||||
}),
|
||||
);
|
||||
|
||||
const impactField = Type.Optional(
|
||||
Type.Union([Type.String({ minLength: 1 }), Type.Null()], {
|
||||
description:
|
||||
'REQUIRED when status="exploited". Business/security impact achieved by the exploit ' +
|
||||
'(e.g. "Extracted full user table including bcrypt password hashes for 1,247 users", ' +
|
||||
'"Achieved RCE as the application user; arbitrary shell commands executed"). Must describe ' +
|
||||
'what was actually demonstrated, not what could theoretically happen.',
|
||||
}),
|
||||
);
|
||||
|
||||
const exploitationStepsField = Type.Optional(
|
||||
Type.Union([Type.Array(Type.String({ minLength: 1 }), { minItems: 1 }), Type.Null()], {
|
||||
description:
|
||||
'REQUIRED when status="exploited". Ordered, reproducible exploitation steps — one Markdown ' +
|
||||
'blob per numbered step. Each step must include full URLs (protocol + domain + port + path ' +
|
||||
'+ params), complete payloads, and copy-paste-ready commands. Use clear placeholders for ' +
|
||||
'variable values like [SESSION_TOKEN], [DATABASE_NAME], [TABLE_NAME], [TARGET_USER_ID]. ' +
|
||||
'Write each step as natural Markdown — interleave prose with fenced code blocks (```bash, ' +
|
||||
'```http, etc.) as you would in a write-up. Steps must be detailed enough that someone ' +
|
||||
'unfamiliar with the application can follow without additional research.',
|
||||
}),
|
||||
);
|
||||
|
||||
const proofOfImpactField = Type.Optional(
|
||||
Type.Union([Type.String({ minLength: 1 }), Type.Null()], {
|
||||
description:
|
||||
'REQUIRED when status="exploited". Concrete evidence of successful exploitation — extracted ' +
|
||||
'data, achieved actions, captured request/response pairs, log excerpts. Markdown blob; ' +
|
||||
'interleave prose with fenced code blocks. Must show what the exploit demonstrably achieved, ' +
|
||||
'not theoretical impact.',
|
||||
}),
|
||||
);
|
||||
|
||||
const confidenceField = Type.Optional(
|
||||
Type.Union([stringEnum(CONFIDENCE_VALUES), Type.Null()], {
|
||||
description:
|
||||
'REQUIRED when status="blocked". Confidence that this finding is a real vulnerability that ' +
|
||||
'would be exploited if the external blocker were removed. High = code analysis strongly ' +
|
||||
'confirms vulnerability and partial exploitation (Level 1-2) succeeded. Medium = code ' +
|
||||
'analysis confirms but live evidence is partial. Low = signal-only; revisit if blocker is ' +
|
||||
'removed in a future run.',
|
||||
}),
|
||||
);
|
||||
|
||||
const currentBlockerField = Type.Optional(
|
||||
Type.Union([Type.String({ minLength: 1 }), Type.Null()], {
|
||||
description:
|
||||
'REQUIRED when status="blocked". What prevents full exploitation (e.g. "Server crashes after ' +
|
||||
'5 requests, blocking enumeration", "OAuth callback requires verified third-party email ' +
|
||||
'account we could not provision"). Must be an external operational constraint, not a ' +
|
||||
'security defense.',
|
||||
}),
|
||||
);
|
||||
|
||||
const potentialImpactField = Type.Optional(
|
||||
Type.Union([Type.String({ minLength: 1 }), Type.Null()], {
|
||||
description:
|
||||
'REQUIRED when status="blocked". What could be achieved if the blocker were removed (e.g. ' +
|
||||
'"Full database read access", "Account takeover of arbitrary user via reset-token leak"). ' +
|
||||
'Distinct from impact — this is the hypothetical outcome, not a demonstrated one.',
|
||||
}),
|
||||
);
|
||||
|
||||
const evidenceOfVulnerabilityField = Type.Optional(
|
||||
Type.Union([Type.String({ minLength: 1 }), Type.Null()], {
|
||||
description:
|
||||
'REQUIRED when status="blocked". Code snippets, response excerpts, or observed behavior ' +
|
||||
'proving the vulnerability is real. Markdown blob; interleave prose with fenced code blocks. ' +
|
||||
'This is what convinces the reader the finding is not a false positive despite incomplete ' +
|
||||
'exploitation.',
|
||||
}),
|
||||
);
|
||||
|
||||
const whatWeTriedField = Type.Optional(
|
||||
Type.Union([Type.String({ minLength: 1 }), Type.Null()], {
|
||||
description:
|
||||
'REQUIRED when status="blocked". Log of attempted exploitation techniques and why each was ' +
|
||||
'blocked. Each attempt should document the payload, the observed result, and the inferred ' +
|
||||
'blocker. Markdown blob; multiple attempts as a list or distinct paragraphs. Demonstrates ' +
|
||||
'exhaustive bypass effort per the Bypass Exhaustion Protocol.',
|
||||
}),
|
||||
);
|
||||
|
||||
const howThisWouldBeExploitedField = Type.Optional(
|
||||
Type.Union([Type.Array(Type.String({ minLength: 1 }), { minItems: 1 }), Type.Null()], {
|
||||
description:
|
||||
'REQUIRED when status="blocked". Ordered hypothetical exploitation steps assuming the blocker ' +
|
||||
'is removed — one Markdown blob per numbered step. Same reproducibility requirements as ' +
|
||||
'exploitation_steps: full URLs, complete payloads, copy-paste-ready commands. Frame the ' +
|
||||
'first step as "If [blocker] were removed: …".',
|
||||
}),
|
||||
);
|
||||
|
||||
const expectedImpactField = Type.Optional(
|
||||
Type.Union([Type.String({ minLength: 1 }), Type.Null()], {
|
||||
description:
|
||||
'REQUIRED when status="blocked". Specific data or access that would be compromised if ' +
|
||||
'exploitation succeeded (e.g. "Read access to all user profile data including PII; write ' +
|
||||
'access to user-owned resources"). Markdown blob.',
|
||||
}),
|
||||
);
|
||||
|
||||
// The flat shape passed to defineTool. pi uses this to build the agent's
|
||||
// tool catalog. Per-status enforcement happens in the handler via the
|
||||
// strict union below.
|
||||
const flatSchema = Type.Object({
|
||||
status: statusField,
|
||||
vulnerability_id: vulnerabilityIdField,
|
||||
title: titleField,
|
||||
vulnerable_location: vulnerableLocationField,
|
||||
overview: overviewField,
|
||||
prerequisites: prerequisitesField,
|
||||
notes: notesField,
|
||||
severity: severityField,
|
||||
impact: impactField,
|
||||
exploitation_steps: exploitationStepsField,
|
||||
proof_of_impact: proofOfImpactField,
|
||||
confidence: confidenceField,
|
||||
current_blocker: currentBlockerField,
|
||||
potential_impact: potentialImpactField,
|
||||
evidence_of_vulnerability: evidenceOfVulnerabilityField,
|
||||
what_we_tried: whatWeTriedField,
|
||||
how_this_would_be_exploited: howThisWouldBeExploitedField,
|
||||
expected_impact: expectedImpactField,
|
||||
});
|
||||
|
||||
// Strict per-status validation. Re-runs in the handler so missing fields
|
||||
// for the chosen status return a retryable error to the agent.
|
||||
const ExploitedSchema = Type.Object({
|
||||
status: Type.Literal('exploited'),
|
||||
vulnerability_id: vulnerabilityIdField,
|
||||
title: titleField,
|
||||
vulnerable_location: vulnerableLocationField,
|
||||
overview: overviewField,
|
||||
prerequisites: prerequisitesField,
|
||||
severity: stringEnum(SEVERITY_VALUES),
|
||||
impact: Type.String({ minLength: 1 }),
|
||||
exploitation_steps: Type.Array(Type.String({ minLength: 1 }), { minItems: 1 }),
|
||||
proof_of_impact: Type.String({ minLength: 1 }),
|
||||
notes: notesField,
|
||||
});
|
||||
|
||||
const BlockedSchema = Type.Object({
|
||||
status: Type.Literal('blocked'),
|
||||
vulnerability_id: vulnerabilityIdField,
|
||||
title: titleField,
|
||||
vulnerable_location: vulnerableLocationField,
|
||||
overview: overviewField,
|
||||
prerequisites: prerequisitesField,
|
||||
confidence: stringEnum(CONFIDENCE_VALUES),
|
||||
current_blocker: Type.String({ minLength: 1 }),
|
||||
potential_impact: Type.String({ minLength: 1 }),
|
||||
evidence_of_vulnerability: Type.String({ minLength: 1 }),
|
||||
what_we_tried: Type.String({ minLength: 1 }),
|
||||
how_this_would_be_exploited: Type.Array(Type.String({ minLength: 1 }), { minItems: 1 }),
|
||||
expected_impact: Type.String({ minLength: 1 }),
|
||||
notes: notesField,
|
||||
});
|
||||
|
||||
const StrictSchema = Type.Union([ExploitedSchema, BlockedSchema]);
|
||||
|
||||
return { flatSchema, StrictSchema };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// RESPONSE HELPERS
|
||||
// ============================================================================
|
||||
|
||||
function toolResult(payload: Record<string, unknown>) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(payload, null, 2) }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function successResult(data: Record<string, unknown>) {
|
||||
return toolResult({ status: 'success', ...data });
|
||||
}
|
||||
|
||||
function errorResult(message: string, errorType = 'ValidationError', retryable = true) {
|
||||
return toolResult({ status: 'error', message, errorType, retryable });
|
||||
}
|
||||
|
||||
function formatValueErrors(schema: TSchema, value: unknown): string {
|
||||
const issues: string[] = [];
|
||||
for (const err of Value.Errors(schema, value)) {
|
||||
const path =
|
||||
err.instancePath && err.instancePath.length > 0
|
||||
? err.instancePath.replace(/^\//, '').replace(/\//g, '.')
|
||||
: '(root)';
|
||||
issues.push(`- ${path}: ${err.message}`);
|
||||
}
|
||||
return issues.join('\n');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// COLLECTOR FACTORY
|
||||
// ============================================================================
|
||||
|
||||
export interface ExploitCollector {
|
||||
tools: ToolDefinition[];
|
||||
getAll(): AddExploitInput[];
|
||||
}
|
||||
|
||||
export interface CreateExploitCollectorOptions {
|
||||
vulnClass: VulnClass;
|
||||
validIds: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
export function createExploitCollector(options: CreateExploitCollectorOptions): ExploitCollector {
|
||||
const { vulnClass, validIds } = options;
|
||||
const exploits: AddExploitInput[] = [];
|
||||
const { flatSchema, StrictSchema } = buildSchemas(validIds);
|
||||
|
||||
const addExploitTool = defineTool({
|
||||
name: 'add_exploit',
|
||||
label: 'Add Exploit',
|
||||
description:
|
||||
`Record a single processed ${vulnClass} vulnerability as structured exploitation evidence. ` +
|
||||
'Call this once per vulnerability in your queue.json after reaching a definitive verdict ' +
|
||||
'(either successfully exploited or potential-but-blocked). The status field discriminates the ' +
|
||||
"two report buckets; required sub-fields differ per status (see each field's description for " +
|
||||
'which status requires it). Duplicate vulnerability_id calls are rejected — each vuln may only ' +
|
||||
'be recorded once. Vulnerability IDs not in the queue.json are rejected with a list of valid ' +
|
||||
'IDs. FALSE POSITIVE findings do NOT use this tool — they go to your workspace tracking file. ' +
|
||||
'After all queue vulnerabilities have been emitted, the host renderer assembles the ' +
|
||||
'deliverable Markdown from your recorded calls.',
|
||||
parameters: flatSchema,
|
||||
async execute(_toolCallId, input) {
|
||||
// Re-validate against the strict discriminated union for per-status enforcement.
|
||||
if (!Value.Check(StrictSchema, input)) {
|
||||
return errorResult(
|
||||
`Schema validation failed for status="${(input as { status?: string }).status}". ` +
|
||||
'Required-field issues:\n' +
|
||||
formatValueErrors(StrictSchema, input),
|
||||
'ValidationError',
|
||||
true,
|
||||
);
|
||||
}
|
||||
const typed = Value.Clean(StrictSchema, structuredClone(input)) as AddExploitInput;
|
||||
|
||||
// Reject IDs not in this run's queue (typo'd or hallucinated).
|
||||
if (!validIds.has(typed.vulnerability_id)) {
|
||||
return errorResult(
|
||||
`Vulnerability ID "${typed.vulnerability_id}" not in this run's queue. Valid IDs: ` +
|
||||
`${formatValidIdsPreview(validIds)}. ` +
|
||||
'Check the queue.json for the canonical ID — likely a typo or hallucinated ID.',
|
||||
'ValidationError',
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
const existing = exploits.find((e) => e.vulnerability_id === typed.vulnerability_id);
|
||||
if (existing) {
|
||||
return errorResult(
|
||||
`Vulnerability ${typed.vulnerability_id} has already been recorded. Each vulnerability ` +
|
||||
'may only be added once. Reach a final verdict before emitting.',
|
||||
'DuplicateError',
|
||||
false,
|
||||
);
|
||||
}
|
||||
exploits.push(typed);
|
||||
return successResult({ added: [typed.vulnerability_id], recorded_status: typed.status });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
tools: [addExploitTool],
|
||||
getAll: (): AddExploitInput[] => [...exploits],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
// Copyright (C) 2025 Keygraph, Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License version 3
|
||||
// as published by the Free Software Foundation.
|
||||
|
||||
/**
|
||||
* Finding Collector tools
|
||||
*
|
||||
* Collects structured findings from the report agent via a pi tool. The agent
|
||||
* calls `add_finding` once per finding with TypeBox-validated parameters. After
|
||||
* the agent finishes, the caller retrieves collected findings via `getAll()`
|
||||
* for downstream rendering (markdown, PDF, DB).
|
||||
*
|
||||
* The tool schema is mode-dependent: fields describing a demonstrated attack have no source in
|
||||
* an analysis-only run, and offering them would only make the agent invent them.
|
||||
*/
|
||||
|
||||
import { defineTool, type ToolDefinition } from '@earendil-works/pi-coding-agent';
|
||||
import { type Static, Type } from 'typebox';
|
||||
import { cleanInput, stringEnum } from './schema.js';
|
||||
|
||||
// ============================================================================
|
||||
// SCHEMA
|
||||
// ============================================================================
|
||||
|
||||
const OWASP_CATEGORY_VALUES = [
|
||||
'A01:2025 — Broken Access Control',
|
||||
'A02:2025 — Security Misconfiguration',
|
||||
'A03:2025 — Software Supply Chain Failures',
|
||||
'A04:2025 — Cryptographic Failures',
|
||||
'A05:2025 — Injection',
|
||||
'A06:2025 — Insecure Design',
|
||||
'A07:2025 — Authentication Failures',
|
||||
'A08:2025 — Software or Data Integrity Failures',
|
||||
'A09:2025 — Security Logging and Alerting Failures',
|
||||
'A10:2025 — Mishandling of Exceptional Conditions',
|
||||
] as const;
|
||||
|
||||
const SEVERITY_VALUES = ['critical', 'high', 'medium', 'low'] as const;
|
||||
const STATUS_VALUES = ['exploited', 'out_of_scope', 'blocked_by_constraints', 'false_positive'] as const;
|
||||
const CONFIDENCE_VALUES = ['high', 'medium', 'low'] as const;
|
||||
|
||||
const StepItemSchema = Type.Union([
|
||||
Type.Object({
|
||||
kind: Type.Literal('prose'),
|
||||
text: Type.String({ minLength: 1, description: 'Narrative prose for this item.' }),
|
||||
}),
|
||||
Type.Object({
|
||||
kind: Type.Literal('code'),
|
||||
block: Type.Object({
|
||||
language: Type.String({
|
||||
description: 'Language identifier for syntax highlighting (e.g., "bash", "http", "json").',
|
||||
}),
|
||||
content: Type.String({ minLength: 1, description: 'The code content.' }),
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
|
||||
const StructuredStepSchema = Type.Object({
|
||||
title: Type.Optional(
|
||||
Type.Union([Type.String(), Type.Null()], {
|
||||
description: 'Optional title for this step (e.g., "Send malicious payload").',
|
||||
}),
|
||||
),
|
||||
items: Type.Array(StepItemSchema, {
|
||||
minItems: 1,
|
||||
description: 'Ordered list of prose and code items that make up this step.',
|
||||
}),
|
||||
});
|
||||
|
||||
const CodeLocationSchema = Type.Object({
|
||||
file: Type.String({
|
||||
minLength: 1,
|
||||
description: 'Repository-relative path, no leading slash (e.g., "routes/search.ts").',
|
||||
}),
|
||||
start_line: Type.Optional(
|
||||
Type.Union([Type.Integer({ minimum: 1 }), Type.Null()], {
|
||||
description: '1-indexed line number. Omit when the deliverable gives only a file.',
|
||||
}),
|
||||
),
|
||||
end_line: Type.Optional(
|
||||
Type.Union([Type.Integer({ minimum: 1 }), Type.Null()], {
|
||||
description: 'End of the range, when the finding spans multiple lines.',
|
||||
}),
|
||||
),
|
||||
role: stringEnum(['sink', 'source', 'guard'], {
|
||||
description:
|
||||
'What this location is in the data flow. `sink` is where the vulnerability manifests, `source` ' +
|
||||
'where untrusted input enters, `guard` a check that is missing or misplaced.',
|
||||
}),
|
||||
symbol: Type.Optional(
|
||||
Type.Union([Type.String(), Type.Null()], {
|
||||
description: 'Enclosing function or method name, when known.',
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const HttpLocationSchema = Type.Object({
|
||||
method: Type.String({ minLength: 1, description: 'HTTP method (e.g., "GET", "POST").' }),
|
||||
url: Type.String({ minLength: 1, description: 'Full URL of the affected endpoint.' }),
|
||||
parameter: Type.Optional(
|
||||
Type.Union([Type.String(), Type.Null()], {
|
||||
description: 'The specific parameter carrying the payload, when the finding names one.',
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const AdditionalSectionSchema = Type.Object({
|
||||
heading: Type.String({
|
||||
minLength: 1,
|
||||
description: 'Section heading (e.g., "Real-World Attack Scenario").',
|
||||
}),
|
||||
items: Type.Array(StepItemSchema, {
|
||||
minItems: 1,
|
||||
description: 'Ordered prose and code items for this section.',
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* `severity` is recorded in both modes, but it does not mean the same thing in each: an exploit
|
||||
* run measures it from what the exploit demonstrated, an analysis run assesses it from the class
|
||||
* of flaw. The description says which, so the agent never presents an assessment as a measurement.
|
||||
*/
|
||||
function identityFields(exploit: boolean) {
|
||||
const severityDescription = exploit
|
||||
? 'Severity of the finding, based on the impact the exploit demonstrated.'
|
||||
: 'Severity of the finding, assessed from the vulnerability class and the impact it would have.';
|
||||
|
||||
return {
|
||||
severity: stringEnum(SEVERITY_VALUES, { description: severityDescription }),
|
||||
finding_id: Type.String({
|
||||
minLength: 1,
|
||||
description: 'Finding identifier (e.g., "AUTH-VULN-07", "INJ-VULN-03"). Must be unique per report.',
|
||||
}),
|
||||
title: Type.String({
|
||||
minLength: 1,
|
||||
description:
|
||||
'Descriptive name (e.g., "SQL Injection — User Search", "IDOR — Unauthorized Access to User Orders").',
|
||||
}),
|
||||
category: stringEnum(['Injection', 'XSS', 'Authentication', 'Authorization', 'SSRF'], {
|
||||
description:
|
||||
'From the finding_id prefix: INJ-VULN-xxx Injection, ' +
|
||||
'XSS-VULN-xxx XSS, AUTH-VULN-xxx Authentication, AUTHZ-VULN-xxx Authorization, ' +
|
||||
'SSRF-VULN-xxx SSRF.',
|
||||
}),
|
||||
owasp_category: stringEnum(OWASP_CATEGORY_VALUES, {
|
||||
description: 'OWASP Top Ten 2025 category.',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function locationFields() {
|
||||
return {
|
||||
vulnerable_location: Type.String({
|
||||
minLength: 1,
|
||||
description: 'Endpoint or code location where the vulnerability exists.',
|
||||
}),
|
||||
http_location: Type.Optional(
|
||||
Type.Union([HttpLocationSchema, Type.Null()], {
|
||||
description:
|
||||
'The HTTP request the finding is reached through, when the deliverable names one. Omit for ' +
|
||||
'findings with no network entry point.',
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** `impact` is described per mode: an analysis run demonstrated nothing, and implying otherwise invites fabrication. */
|
||||
function narrativeFields(exploit: boolean) {
|
||||
const impactDescription = exploit
|
||||
? 'What the exploit demonstrably achieved.'
|
||||
: 'What an attacker could achieve if this were exploited. State it as assessed, not demonstrated.';
|
||||
|
||||
return {
|
||||
overview: Type.String({
|
||||
minLength: 1,
|
||||
description: 'What the vulnerability is and why it matters. 2-3 sentences of professional prose.',
|
||||
}),
|
||||
impact: Type.String({ minLength: 1, description: impactDescription }),
|
||||
remediation: Type.String({
|
||||
minLength: 1,
|
||||
description: 'Specific, actionable fix guidance. Code-level or configuration-level.',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Fields that only mean something once an exploit has run. Absent from the analysis schema. */
|
||||
function exploitOnlyFields() {
|
||||
return {
|
||||
auth_state: Type.String({
|
||||
minLength: 1,
|
||||
description: 'Authentication state during testing (e.g., "Unauthenticated", "Any authenticated user").',
|
||||
}),
|
||||
prerequisites: Type.String({
|
||||
minLength: 1,
|
||||
description: 'What is needed to exploit the vulnerability (or "None").',
|
||||
}),
|
||||
exploitation_steps: Type.Array(StructuredStepSchema, {
|
||||
minItems: 1,
|
||||
description: 'Ordered exploitation steps. Each step has an optional title and prose/code items.',
|
||||
}),
|
||||
proof_of_impact: Type.Array(StepItemSchema, {
|
||||
minItems: 1,
|
||||
description: 'Evidence of what the exploit achieved — prose and code items.',
|
||||
}),
|
||||
status: Type.Optional(
|
||||
Type.Union([stringEnum(STATUS_VALUES), Type.Null()], {
|
||||
description: 'Finding status. Use "exploited" for confirmed exploits.',
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** Accompanies `severity` when nothing was exploited — the rating the analysis deliverable itself carries. */
|
||||
function analysisOnlyFields() {
|
||||
return {
|
||||
confidence: stringEnum(CONFIDENCE_VALUES, {
|
||||
description:
|
||||
'Confidence that this is a real, reachable vulnerability. Carry it over from the analysis ' +
|
||||
'deliverable rather than reassessing.',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function sharedOptionalFields() {
|
||||
return {
|
||||
notes: Type.Optional(
|
||||
Type.Union([Type.Array(StepItemSchema), Type.Null()], {
|
||||
description: 'Additional context as prose/code items.',
|
||||
}),
|
||||
),
|
||||
additional_sections: Type.Optional(
|
||||
Type.Union([Type.Array(AdditionalSectionSchema), Type.Null()], {
|
||||
description: 'Extra report sections that do not fit into other fields (e.g., "Real-World Attack Scenario").',
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAddFindingSchema(exploit: boolean) {
|
||||
return Type.Object({
|
||||
...identityFields(exploit),
|
||||
...(exploit ? exploitOnlyFields() : analysisOnlyFields()),
|
||||
...locationFields(),
|
||||
...narrativeFields(exploit),
|
||||
...sharedOptionalFields(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Superset of both modes, for typing only. Consumers must check presence rather than assume:
|
||||
* `report.json` from an analysis run has no `exploitation_steps` key at all. `severity` is the
|
||||
* exception — both modes record it, so it is required here too.
|
||||
*/
|
||||
const AddFindingSupersetSchema = Type.Object({
|
||||
...identityFields(true),
|
||||
code_locations: Type.Optional(Type.Array(CodeLocationSchema)),
|
||||
auth_state: Type.Optional(Type.String()),
|
||||
prerequisites: Type.Optional(Type.String()),
|
||||
exploitation_steps: Type.Optional(Type.Array(StructuredStepSchema)),
|
||||
proof_of_impact: Type.Optional(Type.Array(StepItemSchema)),
|
||||
status: Type.Optional(Type.Union([stringEnum(STATUS_VALUES), Type.Null()])),
|
||||
confidence: Type.Optional(Type.Union([stringEnum(CONFIDENCE_VALUES), Type.Null()])),
|
||||
...locationFields(),
|
||||
...narrativeFields(true),
|
||||
...sharedOptionalFields(),
|
||||
});
|
||||
|
||||
export type AddFindingInput = Static<typeof AddFindingSupersetSchema>;
|
||||
|
||||
// Re-export schema types for downstream consumers
|
||||
export type CodeLocation = Static<typeof CodeLocationSchema>;
|
||||
export type HttpLocation = Static<typeof HttpLocationSchema>;
|
||||
export type StepItem = Static<typeof StepItemSchema>;
|
||||
export type StructuredStep = Static<typeof StructuredStepSchema>;
|
||||
export type AdditionalSection = Static<typeof AdditionalSectionSchema>;
|
||||
|
||||
// ============================================================================
|
||||
// RESPONSE HELPERS
|
||||
// ============================================================================
|
||||
|
||||
function toolResult(payload: Record<string, unknown>) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(payload, null, 2) }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function successResult(data: Record<string, unknown>) {
|
||||
return toolResult({ status: 'success', ...data });
|
||||
}
|
||||
|
||||
function errorResult(message: string, errorType = 'ValidationError', retryable = true) {
|
||||
return toolResult({ status: 'error', message, errorType, retryable });
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// COLLECTOR FACTORY
|
||||
// ============================================================================
|
||||
|
||||
export interface FindingCollector {
|
||||
tools: ToolDefinition[];
|
||||
getAll(): AddFindingInput[];
|
||||
}
|
||||
|
||||
export function createFindingCollector(exploit: boolean): FindingCollector {
|
||||
const findings: AddFindingInput[] = [];
|
||||
const schema = buildAddFindingSchema(exploit);
|
||||
|
||||
const addFindingTool = defineTool({
|
||||
name: 'add_finding',
|
||||
label: 'Add Finding',
|
||||
description:
|
||||
'Record a single finding as structured data for report rendering and DB persistence. Call once per finding after grouping/dedup. Duplicate finding_ids are rejected.',
|
||||
parameters: schema,
|
||||
async execute(_toolCallId, input) {
|
||||
const existing = findings.find((f) => f.finding_id === input.finding_id);
|
||||
if (existing) {
|
||||
return errorResult(
|
||||
`Finding ${input.finding_id} has already been recorded. Each finding may only be added once.`,
|
||||
'DuplicateError',
|
||||
false,
|
||||
);
|
||||
}
|
||||
const typed = cleanInput(schema, input) as AddFindingInput;
|
||||
findings.push(typed);
|
||||
return successResult({ added: [typed.finding_id] });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
tools: [addFindingTool],
|
||||
getAll: (): AddFindingInput[] => [...findings],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,602 @@
|
||||
// Copyright (C) 2025 Keygraph, Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License version 3
|
||||
// as published by the Free Software Foundation.
|
||||
|
||||
/**
|
||||
* Pre-Recon Collector tools
|
||||
*
|
||||
* Exposes seven TypeBox-validated tools, one per section of the
|
||||
* pre_recon_deliverable.md report. Every tool is one-shot (write-once;
|
||||
* duplicate calls return DuplicateError). A skipped tool renders a placeholder
|
||||
* rather than failing the activity. After the agent finishes, the host calls
|
||||
* getAll() to harvest the typed payload bag, getCallStatus() to log the
|
||||
* per-run call pattern, and runs the deterministic renderer to produce the
|
||||
* deliverable Markdown.
|
||||
*
|
||||
* Each TypeBox schema's field-level descriptions carry the section guidance, so
|
||||
* the harness injects it into the agent's tool catalog.
|
||||
*/
|
||||
|
||||
import { defineTool, type ToolDefinition } from '@earendil-works/pi-coding-agent';
|
||||
import { type Static, Type } from 'typebox';
|
||||
import { cleanInput } from './schema.js';
|
||||
|
||||
// ============================================================================
|
||||
// SHARED SCHEMA
|
||||
// ============================================================================
|
||||
|
||||
export const SinkRefSchema = Type.Object({
|
||||
location: Type.String({
|
||||
minLength: 1,
|
||||
description:
|
||||
'File path with line number (e.g., "templates/render.js:34") or richer prose ' +
|
||||
'(e.g., "innerHTML at templates/render.js:34", "lines 45-67"). Must contain enough ' +
|
||||
'detail for a downstream agent to find the exact location.',
|
||||
}),
|
||||
sink_function: Type.String({
|
||||
minLength: 1,
|
||||
description: 'The sink function or property name (e.g., "innerHTML", "axios.get", "eval", "document.write").',
|
||||
}),
|
||||
notes: Type.Optional(
|
||||
Type.Union([Type.String(), Type.Null()], {
|
||||
description:
|
||||
'Optional context — render-context detail, attribute name, scope hints, or anything ' +
|
||||
'a downstream agent needs to act on this sink. Omit when the location and sink_function ' +
|
||||
'are sufficient on their own.',
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export type SinkRef = Static<typeof SinkRefSchema>;
|
||||
|
||||
// ============================================================================
|
||||
// PER-TOOL INPUT SCHEMAS
|
||||
// ============================================================================
|
||||
|
||||
export const ExecutiveSummaryInputSchema = Type.Object({
|
||||
text: Type.String({
|
||||
minLength: 1,
|
||||
description:
|
||||
"Provide a 2-3 paragraph overview of the application's security posture, highlighting " +
|
||||
'the most critical attack surfaces and architectural security decisions. Becomes ' +
|
||||
'Section 1 of the rendered deliverable.',
|
||||
}),
|
||||
});
|
||||
|
||||
export const ApplicationIntelligenceInputSchema = Type.Object({
|
||||
architecture: Type.Object(
|
||||
{
|
||||
framework_and_language: Type.String({
|
||||
minLength: 1,
|
||||
description: 'Framework and language details with their security implications.',
|
||||
}),
|
||||
architectural_pattern: Type.String({
|
||||
minLength: 1,
|
||||
description: 'Architectural pattern (monolith, microservices, hybrid) with trust boundary analysis.',
|
||||
}),
|
||||
critical_security_components: Type.String({
|
||||
minLength: 1,
|
||||
description: 'Critical security components with focus on auth, authz, and data protection.',
|
||||
}),
|
||||
},
|
||||
{
|
||||
description:
|
||||
'Architecture & Technology Stack — driven by the Architecture Scanner sub-agent. ' +
|
||||
'Becomes Section 2 of the rendered deliverable.',
|
||||
},
|
||||
),
|
||||
data_security: Type.Object(
|
||||
{
|
||||
database_security: Type.String({
|
||||
minLength: 1,
|
||||
description: 'Analyze encryption, access controls, and query safety in database interactions.',
|
||||
}),
|
||||
data_flow_security: Type.String({
|
||||
minLength: 1,
|
||||
description: 'Identify sensitive data paths and the protection mechanisms applied along them.',
|
||||
}),
|
||||
multi_tenant_isolation: Type.String({
|
||||
minLength: 1,
|
||||
description:
|
||||
'Assess tenant separation effectiveness. If the application is single-tenant, state that ' +
|
||||
'explicitly rather than leaving the field thin.',
|
||||
}),
|
||||
},
|
||||
{
|
||||
description:
|
||||
'Data Security & Storage — driven by the Data Security Auditor sub-agent. ' +
|
||||
'Becomes Section 4 of the rendered deliverable.',
|
||||
},
|
||||
),
|
||||
attack_surface: Type.Object(
|
||||
{
|
||||
external_entry_points: Type.String({
|
||||
minLength: 1,
|
||||
description: 'Detailed analysis of each public interface that is network-accessible.',
|
||||
}),
|
||||
internal_service_communication: Type.String({
|
||||
minLength: 1,
|
||||
description:
|
||||
'Trust relationships and security assumptions between network-reachable services. ' +
|
||||
'If the application is a single service with no internal RPC fabric, state that.',
|
||||
}),
|
||||
input_validation_patterns: Type.String({
|
||||
minLength: 1,
|
||||
description: 'How user input is handled and validated in network-accessible endpoints.',
|
||||
}),
|
||||
background_processing: Type.String({
|
||||
minLength: 1,
|
||||
description:
|
||||
'Async job security and privilege models for jobs triggered by network requests. ' +
|
||||
'If no async/background processing exists, state that.',
|
||||
}),
|
||||
},
|
||||
{
|
||||
description:
|
||||
'Attack Surface Analysis — driven by Entry Point Mapper + Architecture Scanner sub-agents. ' +
|
||||
'Only include entry points confirmed to be in-scope (network-reachable). ' +
|
||||
'Becomes Section 5 of the rendered deliverable.',
|
||||
},
|
||||
),
|
||||
infrastructure: Type.Object(
|
||||
{
|
||||
secrets_management: Type.String({
|
||||
minLength: 1,
|
||||
description: 'How secrets are stored, rotated, and accessed.',
|
||||
}),
|
||||
configuration_security: Type.String({
|
||||
minLength: 1,
|
||||
description:
|
||||
'Environment separation and secret handling. Specifically search for infrastructure ' +
|
||||
'configuration (e.g., Nginx, Kubernetes Ingress, CDN settings) that defines security ' +
|
||||
'headers like Strict-Transport-Security (HSTS) and Cache-Control, and report what was found.',
|
||||
}),
|
||||
external_dependencies: Type.String({
|
||||
minLength: 1,
|
||||
description: 'Third-party services and their security implications.',
|
||||
}),
|
||||
monitoring_and_logging: Type.String({
|
||||
minLength: 1,
|
||||
description: 'Security event visibility — what is logged, where it goes, and who can see it.',
|
||||
}),
|
||||
},
|
||||
{
|
||||
description: 'Infrastructure & Operational Security. Becomes Section 6 of the rendered deliverable.',
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
export const AuthDeepDiveInputSchema = Type.Object({
|
||||
authentication_mechanisms: Type.String({
|
||||
minLength: 1,
|
||||
description:
|
||||
'Authentication mechanisms and their security properties. MUST include an exhaustive list of ' +
|
||||
'all API endpoints used for authentication (e.g., login, logout, token refresh, password reset).',
|
||||
}),
|
||||
session_management: Type.String({
|
||||
minLength: 1,
|
||||
description:
|
||||
'Session management and token security. Pinpoint the exact file and line(s) of code where ' +
|
||||
'session cookie flags (HttpOnly, Secure, SameSite) are configured.',
|
||||
}),
|
||||
authz_model: Type.String({
|
||||
minLength: 1,
|
||||
description: 'Authorization model and potential bypass scenarios.',
|
||||
}),
|
||||
multi_tenancy: Type.String({
|
||||
minLength: 1,
|
||||
description: 'Multi-tenancy security implementation. If the application is single-tenant, state that explicitly.',
|
||||
}),
|
||||
sso_oauth_oidc: Type.Union([Type.String(), Type.Null()], {
|
||||
description:
|
||||
'SSO/OAuth/OIDC flows: identify the callback endpoints and locate the specific code that ' +
|
||||
'validates the state and nonce parameters. Set null only if the application has no SSO/OAuth/OIDC ' +
|
||||
'integration at all.',
|
||||
}),
|
||||
});
|
||||
|
||||
export const CodebaseIndexingInputSchema = Type.Object({
|
||||
text: Type.String({
|
||||
minLength: 1,
|
||||
description:
|
||||
"A detailed, multi-sentence paragraph describing the codebase's directory structure, " +
|
||||
'organization, and significant tools or conventions used (e.g., build orchestration, code ' +
|
||||
'generation, testing frameworks). Focus on how this structure impacts discoverability of ' +
|
||||
'security-relevant components.',
|
||||
}),
|
||||
});
|
||||
|
||||
export const CriticalFilePathsInputSchema = Type.Object({
|
||||
configuration: Type.Array(Type.String({ minLength: 1 }), {
|
||||
description: 'Configuration files (e.g., config/server.yaml, Dockerfile, docker-compose.yml).',
|
||||
}),
|
||||
authentication_and_authorization: Type.Array(Type.String({ minLength: 1 }), {
|
||||
description:
|
||||
'Auth/authz files (e.g., auth/jwt_middleware.go, internal/user/permissions.go, ' +
|
||||
'config/initializers/session_store.rb, src/services/oauth_callback.js).',
|
||||
}),
|
||||
api_and_routing: Type.Array(Type.String({ minLength: 1 }), {
|
||||
description:
|
||||
'API and routing files (e.g., cmd/api/main.go, internal/handlers/user_routes.go, ' +
|
||||
'ts/graphql/schema.graphql).',
|
||||
}),
|
||||
data_models_and_db: Type.Array(Type.String({ minLength: 1 }), {
|
||||
description:
|
||||
'Data model and DB interaction files (e.g., db/migrations/001_initial.sql, ' +
|
||||
'internal/models/user.go, internal/repository/sql_queries.go).',
|
||||
}),
|
||||
dependency_manifests: Type.Array(Type.String({ minLength: 1 }), {
|
||||
description: 'Dependency manifests (e.g., go.mod, package.json, requirements.txt).',
|
||||
}),
|
||||
sensitive_data_and_secrets: Type.Array(Type.String({ minLength: 1 }), {
|
||||
description:
|
||||
'Sensitive data and secrets handling (e.g., internal/utils/encryption.go, ' + 'internal/secrets/manager.go).',
|
||||
}),
|
||||
middleware_and_input_validation: Type.Array(Type.String({ minLength: 1 }), {
|
||||
description:
|
||||
'Middleware and input validation (e.g., internal/middleware/validator.go, ' +
|
||||
'internal/handlers/input_parsers.go).',
|
||||
}),
|
||||
logging_and_monitoring: Type.Array(Type.String({ minLength: 1 }), {
|
||||
description: 'Logging and monitoring (e.g., internal/logging/logger.go, config/monitoring.yaml).',
|
||||
}),
|
||||
infrastructure_and_deployment: Type.Array(Type.String({ minLength: 1 }), {
|
||||
description:
|
||||
'Infrastructure and deployment (e.g., infra/pulumi/main.go, kubernetes/deploy.yaml, ' +
|
||||
'nginx.conf, gateway-ingress.yaml).',
|
||||
}),
|
||||
});
|
||||
|
||||
export const XssSinksInputSchema = Type.Object({
|
||||
applicable: Type.Boolean({
|
||||
description:
|
||||
'False only if the application has no web frontend at all. Otherwise true, even if no ' +
|
||||
'sinks were found in a given category — empty arrays mean "scanned this category, no sinks found".',
|
||||
}),
|
||||
html_body: Type.Array(SinkRefSchema, {
|
||||
description:
|
||||
'HTML Body Context sinks: element.innerHTML, element.outerHTML, document.write(), ' +
|
||||
'document.writeln(), element.insertAdjacentHTML(), Range.createContextualFragment(), ' +
|
||||
'and jQuery sinks like add(), after(), append(), before(), html(), prepend(), replaceWith(), wrap().',
|
||||
}),
|
||||
html_attribute: Type.Array(SinkRefSchema, {
|
||||
description:
|
||||
'HTML Attribute Context sinks: event handlers (onclick, onerror, onmouseover, onload, onfocus), ' +
|
||||
'URL-based attributes (href, src, formaction, action, background, data), the style attribute, ' +
|
||||
'iframe srcdoc, and general attributes (value, id, class, name, alt) when quotes are escaped.',
|
||||
}),
|
||||
javascript: Type.Array(SinkRefSchema, {
|
||||
description:
|
||||
'JavaScript Context sinks: eval(), Function() constructor, setTimeout() / setInterval() ' +
|
||||
'with string arguments, and direct writes of user data into a <script> tag.',
|
||||
}),
|
||||
css: Type.Array(SinkRefSchema, {
|
||||
description:
|
||||
'CSS Context sinks: element.style properties (e.g., element.style.backgroundImage) and ' +
|
||||
'direct writes of user data into a <style> tag.',
|
||||
}),
|
||||
url: Type.Array(SinkRefSchema, {
|
||||
description:
|
||||
'URL Context sinks: location / window.location, location.href, location.replace(), ' +
|
||||
'location.assign(), window.open(), history.pushState(), history.replaceState(), ' +
|
||||
'URL.createObjectURL(), and jQuery selector $(userInput) in older versions.',
|
||||
}),
|
||||
});
|
||||
|
||||
export const SsrfSinksInputSchema = Type.Object({
|
||||
applicable: Type.Boolean({
|
||||
description:
|
||||
'False only if the application makes no outbound requests at all. Otherwise true, even if ' +
|
||||
'no sinks were found in a given category — empty arrays mean "scanned this category, no sinks found".',
|
||||
}),
|
||||
http_clients: Type.Array(SinkRefSchema, {
|
||||
description:
|
||||
'HTTP(S) clients: curl, requests (Python), axios (Node.js), fetch (JavaScript/Node.js), ' +
|
||||
'net/http (Go), HttpClient (Java/.NET), urllib (Python), RestTemplate, WebClient, OkHttp, Apache HttpClient.',
|
||||
}),
|
||||
raw_sockets: Type.Array(SinkRefSchema, {
|
||||
description:
|
||||
'Raw sockets and connect APIs: Socket.connect, net.Dial (Go), socket.connect (Python), ' +
|
||||
'TcpClient, UdpClient, NetworkStream, java.net.Socket, java.net.URL.openConnection().',
|
||||
}),
|
||||
url_openers: Type.Array(SinkRefSchema, {
|
||||
description:
|
||||
'URL openers and file includes: file_get_contents (PHP), fopen, include_once, require_once, ' +
|
||||
'new URL().openStream() (Java), urllib.urlopen (Python), fs.readFile with URLs, ' +
|
||||
'import() with dynamic URLs, loadHTML / loadXML with external sources.',
|
||||
}),
|
||||
redirect_handlers: Type.Array(SinkRefSchema, {
|
||||
description:
|
||||
'Redirect and "next URL" handlers: auto-follow redirects in HTTP clients, framework Location ' +
|
||||
'handlers (response.redirect), URL validation in redirect chains, "Continue to" / "Return URL" parameters.',
|
||||
}),
|
||||
headless_browsers: Type.Array(SinkRefSchema, {
|
||||
description:
|
||||
'Headless browsers and render engines: Puppeteer (page.goto, page.setContent), ' +
|
||||
'Playwright (page.navigate, page.route), Selenium WebDriver navigation, html-to-pdf converters ' +
|
||||
'(wkhtmltopdf, Puppeteer PDF), and SSR with external content.',
|
||||
}),
|
||||
media_processors: Type.Array(SinkRefSchema, {
|
||||
description:
|
||||
'Media processors: ImageMagick (convert, identify with URLs), GraphicsMagick, FFmpeg with ' +
|
||||
'network sources, wkhtmltopdf, Ghostscript with URL inputs, image optimization services with URL parameters.',
|
||||
}),
|
||||
link_preview: Type.Array(SinkRefSchema, {
|
||||
description:
|
||||
'Link preview and unfurlers: chat application link expanders, CMS link preview generators, ' +
|
||||
'oEmbed endpoint fetchers, social media card generators, URL metadata extractors.',
|
||||
}),
|
||||
webhook_testers: Type.Array(SinkRefSchema, {
|
||||
description:
|
||||
'Webhook testers and callback verifiers: "ping my webhook" functionality, outbound callback ' +
|
||||
'verification, health check notifications, event delivery confirmations, API endpoint validation tools.',
|
||||
}),
|
||||
sso_oidc_discovery: Type.Array(SinkRefSchema, {
|
||||
description:
|
||||
'SSO/OIDC discovery and JWKS fetchers: OpenID Connect discovery endpoints, JWKS fetchers, ' +
|
||||
'OAuth authorization server metadata, SAML metadata fetchers, federation metadata retrievers.',
|
||||
}),
|
||||
importers: Type.Array(SinkRefSchema, {
|
||||
description:
|
||||
'Importers and data loaders: "import from URL" functionality, CSV/JSON/XML remote loaders, ' +
|
||||
'RSS/Atom feed readers, API data synchronization, configuration file fetchers.',
|
||||
}),
|
||||
package_installers: Type.Array(SinkRefSchema, {
|
||||
description:
|
||||
'Package/plugin/theme installers: "install from URL" features, package managers with remote ' +
|
||||
'sources, plugin/theme downloaders, update mechanisms with remote checks, dependency resolution ' +
|
||||
'with external repos.',
|
||||
}),
|
||||
monitoring_and_health: Type.Array(SinkRefSchema, {
|
||||
description:
|
||||
'Monitoring and health check frameworks: URL pingers and uptime checkers, health check ' +
|
||||
'endpoints, monitoring probe systems, alerting webhook senders, performance testing tools.',
|
||||
}),
|
||||
cloud_metadata: Type.Array(SinkRefSchema, {
|
||||
description:
|
||||
'Cloud metadata helpers: AWS/GCP/Azure instance metadata callers, cloud service discovery ' +
|
||||
'mechanisms, container orchestration API clients, infrastructure metadata fetchers, service mesh ' +
|
||||
'configuration retrievers.',
|
||||
}),
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// EXPORTED TYPES
|
||||
// ============================================================================
|
||||
|
||||
export type ExecutiveSummaryInput = Static<typeof ExecutiveSummaryInputSchema>;
|
||||
export type ApplicationIntelligenceInput = Static<typeof ApplicationIntelligenceInputSchema>;
|
||||
export type AuthDeepDiveInput = Static<typeof AuthDeepDiveInputSchema>;
|
||||
export type CodebaseIndexingInput = Static<typeof CodebaseIndexingInputSchema>;
|
||||
export type CriticalFilePathsInput = Static<typeof CriticalFilePathsInputSchema>;
|
||||
export type XssSinksInput = Static<typeof XssSinksInputSchema>;
|
||||
export type SsrfSinksInput = Static<typeof SsrfSinksInputSchema>;
|
||||
|
||||
export interface PreReconData {
|
||||
readonly executive_summary?: ExecutiveSummaryInput;
|
||||
readonly application_intelligence?: ApplicationIntelligenceInput;
|
||||
readonly auth_deep_dive?: AuthDeepDiveInput;
|
||||
readonly codebase_indexing?: CodebaseIndexingInput;
|
||||
readonly critical_file_paths?: CriticalFilePathsInput;
|
||||
readonly xss_sinks?: XssSinksInput;
|
||||
readonly ssrf_sinks?: SsrfSinksInput;
|
||||
}
|
||||
|
||||
export const PRE_RECON_ONE_SHOT_TOOLS = [
|
||||
'set_executive_summary',
|
||||
'set_application_intelligence',
|
||||
'set_auth_deep_dive',
|
||||
'set_codebase_indexing',
|
||||
'set_critical_file_paths',
|
||||
'set_xss_sinks',
|
||||
'set_ssrf_sinks',
|
||||
] as const;
|
||||
|
||||
export type PreReconToolName = (typeof PRE_RECON_ONE_SHOT_TOOLS)[number];
|
||||
|
||||
export type PreReconToolStatus = 'called' | 'skipped';
|
||||
|
||||
export type PreReconCallStatus = Readonly<Record<PreReconToolName, PreReconToolStatus>>;
|
||||
|
||||
// ============================================================================
|
||||
// RESPONSE HELPERS
|
||||
// ============================================================================
|
||||
|
||||
function toolResult(payload: Record<string, unknown>) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(payload, null, 2) }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function successResult(data: Record<string, unknown>) {
|
||||
return toolResult({ status: 'success', ...data });
|
||||
}
|
||||
|
||||
function errorResult(message: string, errorType = 'ValidationError', retryable = true) {
|
||||
return toolResult({ status: 'error', message, errorType, retryable });
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// COLLECTOR FACTORY
|
||||
// ============================================================================
|
||||
|
||||
interface PreReconState {
|
||||
executive_summary?: ExecutiveSummaryInput;
|
||||
application_intelligence?: ApplicationIntelligenceInput;
|
||||
auth_deep_dive?: AuthDeepDiveInput;
|
||||
codebase_indexing?: CodebaseIndexingInput;
|
||||
critical_file_paths?: CriticalFilePathsInput;
|
||||
xss_sinks?: XssSinksInput;
|
||||
ssrf_sinks?: SsrfSinksInput;
|
||||
}
|
||||
|
||||
export interface PreReconCollector {
|
||||
tools: ToolDefinition[];
|
||||
getAll(): PreReconData;
|
||||
getCallStatus(): PreReconCallStatus;
|
||||
}
|
||||
|
||||
export function createPreReconCollector(): PreReconCollector {
|
||||
const state: PreReconState = {};
|
||||
|
||||
function alreadyCalled(toolName: PreReconToolName) {
|
||||
return errorResult(
|
||||
`${toolName} has already been called. Each set_* tool may only be called once per run.`,
|
||||
'DuplicateError',
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
const setExecutiveSummary = defineTool({
|
||||
name: 'set_executive_summary',
|
||||
label: 'Set Executive Summary',
|
||||
description:
|
||||
"Record the application's overall security posture as a short executive summary. " +
|
||||
'Call exactly once before terminating. Becomes Section 1 of the rendered deliverable. ' +
|
||||
'Duplicate calls are rejected.',
|
||||
parameters: ExecutiveSummaryInputSchema,
|
||||
async execute(_toolCallId, input) {
|
||||
if (state.executive_summary) return alreadyCalled('set_executive_summary');
|
||||
state.executive_summary = cleanInput(ExecutiveSummaryInputSchema, input);
|
||||
return successResult({ set: 'set_executive_summary' });
|
||||
},
|
||||
});
|
||||
|
||||
const setApplicationIntelligence = defineTool({
|
||||
name: 'set_application_intelligence',
|
||||
label: 'Set Application Intelligence',
|
||||
description:
|
||||
'Record the composite application intelligence — architecture, data security, attack surface, ' +
|
||||
'and infrastructure — in a single call. Call exactly once before terminating. ' +
|
||||
'Becomes Sections 2, 4, 5, and 6 of the rendered deliverable. Duplicate calls are rejected.',
|
||||
parameters: ApplicationIntelligenceInputSchema,
|
||||
async execute(_toolCallId, input) {
|
||||
if (state.application_intelligence) return alreadyCalled('set_application_intelligence');
|
||||
state.application_intelligence = cleanInput(ApplicationIntelligenceInputSchema, input);
|
||||
return successResult({ set: 'set_application_intelligence' });
|
||||
},
|
||||
});
|
||||
|
||||
const setAuthDeepDive = defineTool({
|
||||
name: 'set_auth_deep_dive',
|
||||
label: 'Set Auth Deep Dive',
|
||||
description:
|
||||
'Record the authentication & authorization deep dive. Call exactly once before terminating. ' +
|
||||
'Becomes Section 3 of the rendered deliverable. Duplicate calls are rejected.',
|
||||
parameters: AuthDeepDiveInputSchema,
|
||||
async execute(_toolCallId, input) {
|
||||
if (state.auth_deep_dive) return alreadyCalled('set_auth_deep_dive');
|
||||
state.auth_deep_dive = cleanInput(AuthDeepDiveInputSchema, input);
|
||||
return successResult({ set: 'set_auth_deep_dive' });
|
||||
},
|
||||
});
|
||||
|
||||
const setCodebaseIndexing = defineTool({
|
||||
name: 'set_codebase_indexing',
|
||||
label: 'Set Codebase Indexing',
|
||||
description:
|
||||
'Record the overall codebase indexing narrative. Call exactly once before terminating. ' +
|
||||
'Becomes Section 7 of the rendered deliverable. Duplicate calls are rejected.',
|
||||
parameters: CodebaseIndexingInputSchema,
|
||||
async execute(_toolCallId, input) {
|
||||
if (state.codebase_indexing) return alreadyCalled('set_codebase_indexing');
|
||||
state.codebase_indexing = cleanInput(CodebaseIndexingInputSchema, input);
|
||||
return successResult({ set: 'set_codebase_indexing' });
|
||||
},
|
||||
});
|
||||
|
||||
const setCriticalFilePaths = defineTool({
|
||||
name: 'set_critical_file_paths',
|
||||
label: 'Set Critical File Paths',
|
||||
description:
|
||||
'Record the catalog of critical file paths grouped by security relevance. Call exactly once ' +
|
||||
'before terminating. Becomes Section 8 of the rendered deliverable. The next agent uses this ' +
|
||||
'as a starting point for manual review. Duplicate calls are rejected.',
|
||||
parameters: CriticalFilePathsInputSchema,
|
||||
async execute(_toolCallId, input) {
|
||||
if (state.critical_file_paths) return alreadyCalled('set_critical_file_paths');
|
||||
state.critical_file_paths = cleanInput(CriticalFilePathsInputSchema, input);
|
||||
return successResult({ set: 'set_critical_file_paths' });
|
||||
},
|
||||
});
|
||||
|
||||
const setXssSinks = defineTool({
|
||||
name: 'set_xss_sinks',
|
||||
label: 'Set Xss Sinks',
|
||||
description:
|
||||
'Record discovered XSS sinks grouped by render context. Call exactly once before terminating. ' +
|
||||
'If the application has no web frontend at all, set applicable=false; otherwise populate each ' +
|
||||
'render-context array (empty arrays mean "scanned, no sinks of this kind"). This list drives ' +
|
||||
"the vuln-xss agent's testing todos downstream. Becomes Section 9 of the rendered deliverable. " +
|
||||
'Duplicate calls are rejected.',
|
||||
parameters: XssSinksInputSchema,
|
||||
async execute(_toolCallId, input) {
|
||||
if (state.xss_sinks) return alreadyCalled('set_xss_sinks');
|
||||
state.xss_sinks = cleanInput(XssSinksInputSchema, input);
|
||||
return successResult({ set: 'set_xss_sinks' });
|
||||
},
|
||||
});
|
||||
|
||||
const setSsrfSinks = defineTool({
|
||||
name: 'set_ssrf_sinks',
|
||||
label: 'Set Ssrf Sinks',
|
||||
description:
|
||||
'Record discovered SSRF sinks grouped by sink category. Call exactly once before terminating. ' +
|
||||
'If the application makes no outbound requests at all, set applicable=false; otherwise populate ' +
|
||||
'each category array (empty arrays mean "scanned, no sinks of this kind"). This list drives ' +
|
||||
"the vuln-ssrf agent's testing todos downstream. Becomes Section 10 of the rendered deliverable. " +
|
||||
'Duplicate calls are rejected.',
|
||||
parameters: SsrfSinksInputSchema,
|
||||
async execute(_toolCallId, input) {
|
||||
if (state.ssrf_sinks) return alreadyCalled('set_ssrf_sinks');
|
||||
state.ssrf_sinks = cleanInput(SsrfSinksInputSchema, input);
|
||||
return successResult({ set: 'set_ssrf_sinks' });
|
||||
},
|
||||
});
|
||||
|
||||
function statusOf<K extends PreReconToolName>(key: K): PreReconToolStatus {
|
||||
const flagMap: Record<PreReconToolName, unknown> = {
|
||||
set_executive_summary: state.executive_summary,
|
||||
set_application_intelligence: state.application_intelligence,
|
||||
set_auth_deep_dive: state.auth_deep_dive,
|
||||
set_codebase_indexing: state.codebase_indexing,
|
||||
set_critical_file_paths: state.critical_file_paths,
|
||||
set_xss_sinks: state.xss_sinks,
|
||||
set_ssrf_sinks: state.ssrf_sinks,
|
||||
};
|
||||
return flagMap[key] ? 'called' : 'skipped';
|
||||
}
|
||||
|
||||
return {
|
||||
tools: [
|
||||
setExecutiveSummary,
|
||||
setApplicationIntelligence,
|
||||
setAuthDeepDive,
|
||||
setCodebaseIndexing,
|
||||
setCriticalFilePaths,
|
||||
setXssSinks,
|
||||
setSsrfSinks,
|
||||
],
|
||||
getAll: (): PreReconData => ({
|
||||
...(state.executive_summary && { executive_summary: state.executive_summary }),
|
||||
...(state.application_intelligence && { application_intelligence: state.application_intelligence }),
|
||||
...(state.auth_deep_dive && { auth_deep_dive: state.auth_deep_dive }),
|
||||
...(state.codebase_indexing && { codebase_indexing: state.codebase_indexing }),
|
||||
...(state.critical_file_paths && { critical_file_paths: state.critical_file_paths }),
|
||||
...(state.xss_sinks && { xss_sinks: state.xss_sinks }),
|
||||
...(state.ssrf_sinks && { ssrf_sinks: state.ssrf_sinks }),
|
||||
}),
|
||||
getCallStatus: (): PreReconCallStatus => ({
|
||||
set_executive_summary: statusOf('set_executive_summary'),
|
||||
set_application_intelligence: statusOf('set_application_intelligence'),
|
||||
set_auth_deep_dive: statusOf('set_auth_deep_dive'),
|
||||
set_codebase_indexing: statusOf('set_codebase_indexing'),
|
||||
set_critical_file_paths: statusOf('set_critical_file_paths'),
|
||||
set_xss_sinks: statusOf('set_xss_sinks'),
|
||||
set_ssrf_sinks: statusOf('set_ssrf_sinks'),
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,876 @@
|
||||
// Copyright (C) 2025 Keygraph, Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License version 3
|
||||
// as published by the Free Software Foundation.
|
||||
|
||||
/**
|
||||
* Recon Collector tools
|
||||
*
|
||||
* Exposes nine TypeBox-validated tools that feed the recon_deliverable.md
|
||||
* renderer — eight one-shot `set_*` tools, one per deliverable section, plus a
|
||||
* multi-call `add_endpoints` tool that lets the agent split a large API
|
||||
* inventory across calls (the only catalog whose realistic payload threatens
|
||||
* the per-turn output cap).
|
||||
*
|
||||
* A skipped tool renders a "not provided" placeholder in that section rather
|
||||
* than failing the activity. getCallStatus() exposes the per-run call pattern
|
||||
* for logging. Each schema's field-level descriptions carry the section
|
||||
* guidance, so pi injects it into the agent's tool catalog.
|
||||
*/
|
||||
|
||||
import { defineTool, type ToolDefinition } from '@earendil-works/pi-coding-agent';
|
||||
import { type Static, Type } from 'typebox';
|
||||
import { type SinkRef, SinkRefSchema } from './pre-recon-collector.js';
|
||||
import { cleanInput, stringEnum } from './schema.js';
|
||||
|
||||
// ============================================================================
|
||||
// PER-TOOL INPUT SCHEMAS
|
||||
// ============================================================================
|
||||
|
||||
export const ExecutiveSummaryInputSchema = Type.Object({
|
||||
text: Type.String({
|
||||
minLength: 1,
|
||||
description:
|
||||
"A brief overview of the application's purpose, core technology stack " +
|
||||
'(e.g., Next.js, Cloudflare), and the primary user-facing components that ' +
|
||||
'constitute the attack surface. Becomes Section 1 of the rendered deliverable.',
|
||||
}),
|
||||
});
|
||||
|
||||
export const TechnologyStackInputSchema = Type.Object({
|
||||
frontend: Type.String({
|
||||
minLength: 1,
|
||||
description: 'Framework, key libraries, and authentication libraries used on the frontend.',
|
||||
}),
|
||||
backend: Type.String({
|
||||
minLength: 1,
|
||||
description: 'Language, framework, and key dependencies used on the backend.',
|
||||
}),
|
||||
infrastructure: Type.String({
|
||||
minLength: 1,
|
||||
description: 'Hosting provider, CDN, database type, and other infrastructure components.',
|
||||
}),
|
||||
});
|
||||
|
||||
export const AuthenticationInputSchema = Type.Object({
|
||||
session_flow: Type.Object(
|
||||
{
|
||||
entry_points: Type.String({
|
||||
minLength: 1,
|
||||
description: 'Authentication entry points (e.g., /login, /register, /auth/sso).',
|
||||
}),
|
||||
mechanism: Type.String({
|
||||
minLength: 1,
|
||||
description:
|
||||
'Describe the step-by-step authentication process: credential submission, token generation, ' +
|
||||
'cookie setting, redirects, etc.',
|
||||
}),
|
||||
code_pointers: Type.String({
|
||||
minLength: 1,
|
||||
description:
|
||||
'Pointers to the primary files and functions in the codebase that manage authentication and ' +
|
||||
'session logic.',
|
||||
}),
|
||||
},
|
||||
{
|
||||
description:
|
||||
'Authentication & Session Management Flow — overall entry points, mechanism, and code pointers. ' +
|
||||
'Becomes Section 3 of the rendered deliverable.',
|
||||
},
|
||||
),
|
||||
role_assignment: Type.Object(
|
||||
{
|
||||
role_determination: Type.String({
|
||||
minLength: 1,
|
||||
description: 'How roles are assigned post-authentication — database lookup, JWT claims, external service, etc.',
|
||||
}),
|
||||
default_role: Type.String({
|
||||
minLength: 1,
|
||||
description: 'What role new users get by default.',
|
||||
}),
|
||||
role_upgrade_path: Type.String({
|
||||
minLength: 1,
|
||||
description:
|
||||
'How users can gain higher privileges — admin approval, self-service, automatic, etc. ' +
|
||||
'If no upgrade path exists, state that.',
|
||||
}),
|
||||
code_implementation: Type.String({
|
||||
minLength: 1,
|
||||
description: 'Where role assignment logic is implemented (file paths and functions).',
|
||||
}),
|
||||
},
|
||||
{
|
||||
description: 'Role Assignment Process — how roles are determined post-authentication. Becomes Section 3.1.',
|
||||
},
|
||||
),
|
||||
privilege_storage: Type.Object(
|
||||
{
|
||||
storage_location: Type.String({
|
||||
minLength: 1,
|
||||
description: 'Where user privileges are stored — JWT claims, session data, database, external service.',
|
||||
}),
|
||||
validation_points: Type.String({
|
||||
minLength: 1,
|
||||
description: 'Where role checks happen — middleware, decorators, inline checks.',
|
||||
}),
|
||||
cache_session_persistence: Type.String({
|
||||
minLength: 1,
|
||||
description: 'How long privileges are cached, and when they are refreshed.',
|
||||
}),
|
||||
code_pointers: Type.String({
|
||||
minLength: 1,
|
||||
description: 'Files that handle privilege validation.',
|
||||
}),
|
||||
},
|
||||
{
|
||||
description:
|
||||
'Privilege Storage & Validation — where privileges live and where they are checked. ' + 'Becomes Section 3.2.',
|
||||
},
|
||||
),
|
||||
role_switching_impersonation: Type.Object(
|
||||
{
|
||||
applicable: Type.Boolean({
|
||||
description:
|
||||
'False only if the application has no impersonation, sudo-mode, or role-switching features ' +
|
||||
'at all. When false, the other fields in this object may be null.',
|
||||
}),
|
||||
impersonation_features: Type.Union([Type.String(), Type.Null()], {
|
||||
description:
|
||||
'Any ability for admins or higher-privilege users to impersonate other users. Pass null when ' +
|
||||
'applicable is false.',
|
||||
}),
|
||||
role_switching: Type.Union([Type.String(), Type.Null()], {
|
||||
description: 'Temporary privilege elevation mechanisms like "sudo mode". Pass null when applicable is false.',
|
||||
}),
|
||||
audit_trail: Type.Union([Type.String(), Type.Null()], {
|
||||
description:
|
||||
'Whether role switches or impersonation events are logged, and where. Pass null when applicable is false.',
|
||||
}),
|
||||
code_implementation: Type.Union([Type.String(), Type.Null()], {
|
||||
description:
|
||||
'Where these features are implemented (file paths and functions). Pass null when applicable is false.',
|
||||
}),
|
||||
},
|
||||
{
|
||||
description:
|
||||
'Role Switching & Impersonation — impersonation, sudo mode, audit trails. Becomes Section 3.3. ' +
|
||||
'Set applicable=false if no such features exist; the other fields may be null in that case.',
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
const HTTP_METHOD_VALUES = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD', 'WS'] as const;
|
||||
|
||||
const EndpointSchema = Type.Object({
|
||||
method: stringEnum(HTTP_METHOD_VALUES, {
|
||||
description: 'HTTP method. Use WS for WebSocket upgrade endpoints.',
|
||||
}),
|
||||
path: Type.String({
|
||||
minLength: 1,
|
||||
description: 'Endpoint path with parameter placeholders, e.g. "/api/users/{user_id}".',
|
||||
}),
|
||||
required_role: Type.String({
|
||||
minLength: 1,
|
||||
description: 'Minimum role needed (anon, user, admin, etc.).',
|
||||
}),
|
||||
object_id_parameters: Type.Array(Type.String(), {
|
||||
description: 'Parameters that identify specific objects (user_id, order_id, etc.). Empty array if none.',
|
||||
}),
|
||||
authorization_mechanism: Type.String({
|
||||
minLength: 1,
|
||||
description:
|
||||
'How access is controlled — middleware, decorator, inline check. ' +
|
||||
'E.g. "Bearer Token + ownership check", "requireAuth() + requireAdmin()", "None".',
|
||||
}),
|
||||
description: Type.String({
|
||||
minLength: 1,
|
||||
description: "Brief description of the endpoint's purpose.",
|
||||
}),
|
||||
code_pointer: Type.String({
|
||||
minLength: 1,
|
||||
description: 'File path and (where possible) line number of the handler. E.g. "auth.controller.ts:45".',
|
||||
}),
|
||||
});
|
||||
|
||||
export const AddEndpointsInputSchema = Type.Object({
|
||||
endpoints: Type.Array(EndpointSchema, {
|
||||
description:
|
||||
'A batch of network-accessible API endpoints to append to the catalog. Include only endpoints ' +
|
||||
'reachable through the deployed application — exclude CLI tools, dev-only routes, build scripts. ' +
|
||||
'Duplicate (method, path) pairs across calls are skipped as no-ops; the response reports which ' +
|
||||
'were added vs. skipped.',
|
||||
}),
|
||||
});
|
||||
|
||||
export const InputVectorsInputSchema = Type.Object({
|
||||
url_parameters: Type.Array(Type.String({ minLength: 1 }), {
|
||||
description:
|
||||
'URL parameter input vectors — each entry should identify the parameter and (where possible) ' +
|
||||
'the file:line of the handler. E.g. "?redirect_url= @ auth.controller.ts:88".',
|
||||
}),
|
||||
post_body_fields: Type.Array(Type.String({ minLength: 1 }), {
|
||||
description:
|
||||
'POST/PUT body field input vectors (JSON or form). E.g. "username @ login.handler.ts:34", ' +
|
||||
'"profile.description @ users.controller.ts:120".',
|
||||
}),
|
||||
http_headers: Type.Array(Type.String({ minLength: 1 }), {
|
||||
description:
|
||||
'HTTP header input vectors. Include both standard headers consumed by app code (e.g., ' +
|
||||
'X-Forwarded-For) and custom application headers.',
|
||||
}),
|
||||
cookie_values: Type.Array(Type.String({ minLength: 1 }), {
|
||||
description: 'Cookie-based input vectors. E.g. "preferences_cookie @ middleware/prefs.ts:22".',
|
||||
}),
|
||||
});
|
||||
|
||||
const ENTITY_TYPE_VALUES = ['ExternAsset', 'Service', 'Identity', 'DataStore', 'AdminPlane', 'ThirdParty'] as const;
|
||||
|
||||
const ENTITY_ZONE_VALUES = ['Internet', 'Edge', 'App', 'Data', 'Admin', 'BuildCI', 'ThirdParty'] as const;
|
||||
|
||||
const DATA_LABEL_VALUES = ['PII', 'Tokens', 'Payments', 'Secrets', 'Public'] as const;
|
||||
|
||||
const FLOW_CHANNEL_VALUES = ['HTTP', 'HTTPS', 'TCP', 'Message', 'File', 'Token'] as const;
|
||||
|
||||
const GUARD_CATEGORY_VALUES = [
|
||||
'Auth',
|
||||
'Network',
|
||||
'Protocol',
|
||||
'Env',
|
||||
'RateLimit',
|
||||
'Authorization',
|
||||
'ObjectOwnership',
|
||||
] as const;
|
||||
|
||||
const EntityMetadataPairSchema = Type.Object({
|
||||
key: Type.String({
|
||||
minLength: 1,
|
||||
description: 'Metadata key (e.g., "Hosts", "Endpoints", "Engine", "Issuer").',
|
||||
}),
|
||||
value: Type.String({
|
||||
minLength: 1,
|
||||
description: 'Metadata value for this key.',
|
||||
}),
|
||||
});
|
||||
|
||||
const EntitySchema = Type.Object({
|
||||
title: Type.String({
|
||||
minLength: 1,
|
||||
description: 'Unique short name for the entity (e.g., "ExampleWebApp", "PostgreSQL-DB", "IdentityProvider").',
|
||||
}),
|
||||
type: stringEnum(ENTITY_TYPE_VALUES, {
|
||||
description:
|
||||
'Entity type. ExternAsset = client-side asset; Service = backend service; Identity = identity ' +
|
||||
'provider; DataStore = database / cache / object store; AdminPlane = admin/control surface; ' +
|
||||
'ThirdParty = external integration.',
|
||||
}),
|
||||
zone: stringEnum(ENTITY_ZONE_VALUES, {
|
||||
description:
|
||||
'Trust zone. Internet = public; Edge = CDN/WAF/reverse-proxy tier; App = application/business logic; ' +
|
||||
'Data = persistent storage; Admin = administrative surface; BuildCI = build/CI/CD infrastructure; ' +
|
||||
'ThirdParty = external trust domain.',
|
||||
}),
|
||||
tech: Type.String({
|
||||
minLength: 1,
|
||||
description: 'Short technology/framework description (e.g., "Node/Express", "Postgres 14", "AWS S3").',
|
||||
}),
|
||||
data: Type.Array(stringEnum(DATA_LABEL_VALUES), {
|
||||
description: 'Data labels handled by this entity. Empty array if the entity handles only Public data.',
|
||||
}),
|
||||
notes: Type.String({
|
||||
description: 'Freeform context (e.g., "public-facing", "stores sensitive user data"). Empty string if none.',
|
||||
}),
|
||||
metadata: Type.Array(EntityMetadataPairSchema, {
|
||||
description:
|
||||
'Ordered key/value pairs of technical metadata for this entity. Becomes the Section 6.2 row ' +
|
||||
'rendered as "Key: Value; Key: Value; …". Example pairs for a service: Hosts, Endpoints, Auth, ' +
|
||||
'Dependencies; for a datastore: Engine, Exposure, Consumers, Credentials.',
|
||||
}),
|
||||
});
|
||||
|
||||
const FlowSchema = Type.Object({
|
||||
from: Type.String({
|
||||
minLength: 1,
|
||||
description: 'Source entity title — must match a title from the entities array.',
|
||||
}),
|
||||
to: Type.String({
|
||||
minLength: 1,
|
||||
description: 'Destination entity title — must match a title from the entities array.',
|
||||
}),
|
||||
channel: stringEnum(FLOW_CHANNEL_VALUES, { description: 'Transport channel for this flow.' }),
|
||||
path_port: Type.String({
|
||||
minLength: 1,
|
||||
description: 'Path and/or port for this flow. E.g. ":443 /api/users/me", ":5432", "queue: orders".',
|
||||
}),
|
||||
guards: Type.Array(Type.String({ minLength: 1 }), {
|
||||
description:
|
||||
'Guard names that gate this flow. Each should match a name from the guards array. Empty array ' +
|
||||
'means no guards apply (publicly accessible).',
|
||||
}),
|
||||
touches: Type.Array(stringEnum(DATA_LABEL_VALUES), {
|
||||
description: 'Data labels this flow carries. Empty array if only Public data flows.',
|
||||
}),
|
||||
});
|
||||
|
||||
const GuardSchema = Type.Object({
|
||||
name: Type.String({
|
||||
minLength: 1,
|
||||
description: 'Short guard identifier (e.g., "auth:user", "ownership:user", "vpc-only", "mtls").',
|
||||
}),
|
||||
category: stringEnum(GUARD_CATEGORY_VALUES, {
|
||||
description:
|
||||
'Guard category. Auth = authentication identity; Authorization = role/scope check; ' +
|
||||
'ObjectOwnership = ownership-based check; Network = network-level restriction; ' +
|
||||
'Protocol = protocol-level requirement; Env = environment-bound restriction; ' +
|
||||
'RateLimit = throttling.',
|
||||
}),
|
||||
statement: Type.String({
|
||||
minLength: 1,
|
||||
description: 'One-sentence description of what this guard enforces.',
|
||||
}),
|
||||
});
|
||||
|
||||
export const NetworkMapInputSchema = Type.Object({
|
||||
entities: Type.Array(EntitySchema, {
|
||||
description:
|
||||
'All major components of the system. Becomes Section 6.1 (Entities) and Section 6.2 ' +
|
||||
'(Entity Metadata, split per-entity from the metadata field).',
|
||||
}),
|
||||
flows: Type.Array(FlowSchema, {
|
||||
description:
|
||||
'How entities communicate. Becomes Section 6.3. The from/to fields cross-reference entities ' +
|
||||
'by title; the guards field cross-references guards by name.',
|
||||
}),
|
||||
guards: Type.Array(GuardSchema, {
|
||||
description: 'Catalog of guards referenced by flows. Becomes Section 6.4.',
|
||||
}),
|
||||
});
|
||||
|
||||
const RoleSchema = Type.Object({
|
||||
name: Type.String({
|
||||
minLength: 1,
|
||||
description: 'Role name (e.g., "anon", "user", "admin", "team_admin").',
|
||||
}),
|
||||
privilege_level: Type.Integer({
|
||||
minimum: 0,
|
||||
maximum: 10,
|
||||
description: 'Privilege rank from 0 (lowest, anonymous) to 10 (highest, full admin).',
|
||||
}),
|
||||
scope_domain: Type.String({
|
||||
minLength: 1,
|
||||
description: 'Scope of this role: Global, Org, Team, Project, etc.',
|
||||
}),
|
||||
code_implementation: Type.String({
|
||||
minLength: 1,
|
||||
description: 'Where this role is defined or checked (middleware, decorator, file:line, etc.).',
|
||||
}),
|
||||
default_landing_page: Type.String({
|
||||
minLength: 1,
|
||||
description: 'Default landing page or route after authentication. Use "N/A" for roles without a UI.',
|
||||
}),
|
||||
accessible_route_patterns: Type.Array(Type.String({ minLength: 1 }), {
|
||||
description: 'Route patterns this role can access. Empty array if the role has no UI access.',
|
||||
}),
|
||||
authentication_method: Type.String({
|
||||
minLength: 1,
|
||||
description: 'How this role authenticates: "None" (anon), "Session/JWT", "Session/JWT + role claim", etc.',
|
||||
}),
|
||||
middleware_guards: Type.String({
|
||||
minLength: 1,
|
||||
description: 'Middleware and guards that enforce this role (e.g., "requireAuth() + requireAdmin()").',
|
||||
}),
|
||||
permission_checks: Type.String({
|
||||
minLength: 1,
|
||||
description: 'How permission checks are expressed in code (e.g., "req.user.role === \'admin\'").',
|
||||
}),
|
||||
storage_location: Type.String({
|
||||
minLength: 1,
|
||||
description: 'Where this role is stored at runtime (JWT claims, session data, etc.).',
|
||||
}),
|
||||
});
|
||||
|
||||
const PrivilegeLatticeSchema = Type.Object({
|
||||
ordering_diagram: Type.String({
|
||||
minLength: 1,
|
||||
description:
|
||||
'ASCII diagram showing role ordering. Use → for "can access resources of". ' + 'E.g. "anon → user → admin".',
|
||||
}),
|
||||
parallel_isolation_notes: Type.String({
|
||||
description:
|
||||
'Notes on parallel isolation between roles using ||. E.g. "team_admin || dept_admin (both > user, ' +
|
||||
'but isolated from each other)". Empty string if no parallel isolation exists.',
|
||||
}),
|
||||
role_switching_notes: Type.Optional(
|
||||
Type.Union([Type.String(), Type.Null()], {
|
||||
description:
|
||||
'Optional pointer to impersonation, sudo mode, or role-switching mechanisms documented in ' +
|
||||
'set_authentication.role_switching_impersonation. Null/omitted if no such mechanisms exist.',
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export const RoleArchitectureInputSchema = Type.Object({
|
||||
roles: Type.Array(RoleSchema, {
|
||||
description:
|
||||
'All distinct privilege levels found in the application. Becomes Sections 7.1 (Discovered Roles), ' +
|
||||
'7.3 (Role Entry Points), and 7.4 (Role-to-Code Mapping), split by the renderer per-role.',
|
||||
}),
|
||||
privilege_lattice: Type.Object(PrivilegeLatticeSchema.properties, {
|
||||
description: 'The role hierarchy showing dominance and parallel isolation. Becomes Section 7.2.',
|
||||
}),
|
||||
});
|
||||
|
||||
const PRIORITY_VALUES = ['High', 'Medium', 'Low'] as const;
|
||||
|
||||
const HorizontalCandidateSchema = Type.Object({
|
||||
priority: stringEnum(PRIORITY_VALUES, {
|
||||
description: 'Priority: High, Medium, or Low, based on data sensitivity (title-case literals).',
|
||||
}),
|
||||
endpoint_pattern: Type.String({
|
||||
minLength: 1,
|
||||
description: 'Endpoint pattern with the object identifier. E.g. "/api/orders/{order_id}".',
|
||||
}),
|
||||
object_id_parameter: Type.String({
|
||||
minLength: 1,
|
||||
description: 'The parameter name that identifies the target object (e.g., "order_id", "user_id").',
|
||||
}),
|
||||
data_type: Type.String({
|
||||
minLength: 1,
|
||||
description: 'Type of data exposed: user_data, financial, admin_config, user_files, etc.',
|
||||
}),
|
||||
sensitivity: Type.String({
|
||||
minLength: 1,
|
||||
description: 'One-line description of what is at risk (e.g., "User can access other users\' orders").',
|
||||
}),
|
||||
});
|
||||
|
||||
const VerticalCandidateSchema = Type.Object({
|
||||
target_role: Type.String({
|
||||
minLength: 1,
|
||||
description: 'Role required to access this endpoint (the role being escalated to).',
|
||||
}),
|
||||
endpoint_pattern: Type.String({
|
||||
minLength: 1,
|
||||
description: 'Endpoint pattern that requires elevated privileges. E.g. "/admin/*", "/api/admin/users".',
|
||||
}),
|
||||
functionality: Type.String({
|
||||
minLength: 1,
|
||||
description: 'What the endpoint does (e.g., "Administrative functions", "User management").',
|
||||
}),
|
||||
risk_level: stringEnum(PRIORITY_VALUES, {
|
||||
description: 'Risk level: High, Medium, or Low (title-case literals).',
|
||||
}),
|
||||
});
|
||||
|
||||
const ContextCandidateSchema = Type.Object({
|
||||
workflow: Type.String({
|
||||
minLength: 1,
|
||||
description: 'Multi-step workflow name (e.g., "Checkout", "Onboarding", "Password Reset").',
|
||||
}),
|
||||
endpoint: Type.String({
|
||||
minLength: 1,
|
||||
description: 'Endpoint that assumes a prior workflow state. E.g. "/api/checkout/confirm".',
|
||||
}),
|
||||
expected_prior_state: Type.String({
|
||||
minLength: 1,
|
||||
description: 'What state should already exist before this endpoint is called.',
|
||||
}),
|
||||
bypass_potential: Type.String({
|
||||
minLength: 1,
|
||||
description: 'What an attacker could achieve by skipping the prior state.',
|
||||
}),
|
||||
});
|
||||
|
||||
export const AuthzCandidatesInputSchema = Type.Object({
|
||||
horizontal: Type.Array(HorizontalCandidateSchema, {
|
||||
description:
|
||||
"Endpoints with object identifiers that could allow horizontal access to other users' " +
|
||||
'resources. Becomes Section 8.1. The renderer assigns stable AUTHZ-CAND-NN IDs.',
|
||||
}),
|
||||
vertical: Type.Array(VerticalCandidateSchema, {
|
||||
description:
|
||||
'Endpoints that require higher privileges and could be targets for vertical escalation. ' +
|
||||
'Becomes Section 8.2. Exclude endpoints intentionally shared across roles.',
|
||||
}),
|
||||
context: Type.Array(ContextCandidateSchema, {
|
||||
description: 'Multi-step workflow endpoints that assume prior steps were completed. Becomes Section 8.3.',
|
||||
}),
|
||||
});
|
||||
|
||||
export const InjectionSourcesInputSchema = Type.Object({
|
||||
applicable: Type.Boolean({
|
||||
description:
|
||||
'False only if the application has no network-accessible code paths reaching dangerous sinks ' +
|
||||
'at all. Otherwise true, even if no sources were found in a given category — empty arrays mean ' +
|
||||
'"scanned this category, no sources found".',
|
||||
}),
|
||||
command_injection: Type.Array(SinkRefSchema, {
|
||||
description:
|
||||
'Command injection sources: data flowing from a user-controlled origin into a program variable ' +
|
||||
'that is eventually interpolated into a shell or system command string (within network-accessible ' +
|
||||
'code paths).',
|
||||
}),
|
||||
sql_injection: Type.Array(SinkRefSchema, {
|
||||
description:
|
||||
'SQL injection sources: user-controllable input that reaches a database query string (within ' +
|
||||
'network-accessible code paths).',
|
||||
}),
|
||||
lfi_rfi: Type.Array(SinkRefSchema, {
|
||||
description:
|
||||
'Local/Remote File Inclusion sources: user-controllable input passed to include/require/load ' +
|
||||
'functions that resolve to filesystem or remote paths (within network-accessible code paths).',
|
||||
}),
|
||||
path_traversal: Type.Array(SinkRefSchema, {
|
||||
description:
|
||||
'Path traversal sources: user-controllable input that influences file paths in read/write ' +
|
||||
'operations (fopen, readFile, etc.) within network-accessible code paths.',
|
||||
}),
|
||||
ssti: Type.Array(SinkRefSchema, {
|
||||
description:
|
||||
'Server-Side Template Injection sources: user-controllable input embedded in template ' +
|
||||
'expressions or template content within network-accessible code paths.',
|
||||
}),
|
||||
deserialization: Type.Array(SinkRefSchema, {
|
||||
description:
|
||||
'Insecure deserialization sources: user-controllable input passed to deserialization functions ' +
|
||||
'within network-accessible code paths.',
|
||||
}),
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// EXPORTED TYPES
|
||||
// ============================================================================
|
||||
|
||||
export type ExecutiveSummaryInput = Static<typeof ExecutiveSummaryInputSchema>;
|
||||
export type TechnologyStackInput = Static<typeof TechnologyStackInputSchema>;
|
||||
export type AuthenticationInput = Static<typeof AuthenticationInputSchema>;
|
||||
export type AddEndpointsInput = Static<typeof AddEndpointsInputSchema>;
|
||||
export type Endpoint = Static<typeof EndpointSchema>;
|
||||
export type InputVectorsInput = Static<typeof InputVectorsInputSchema>;
|
||||
export type NetworkMapInput = Static<typeof NetworkMapInputSchema>;
|
||||
export type Entity = Static<typeof EntitySchema>;
|
||||
export type Flow = Static<typeof FlowSchema>;
|
||||
export type Guard = Static<typeof GuardSchema>;
|
||||
export type RoleArchitectureInput = Static<typeof RoleArchitectureInputSchema>;
|
||||
export type Role = Static<typeof RoleSchema>;
|
||||
export type PrivilegeLattice = Static<typeof PrivilegeLatticeSchema>;
|
||||
export type AuthzCandidatesInput = Static<typeof AuthzCandidatesInputSchema>;
|
||||
export type HorizontalCandidate = Static<typeof HorizontalCandidateSchema>;
|
||||
export type VerticalCandidate = Static<typeof VerticalCandidateSchema>;
|
||||
export type ContextCandidate = Static<typeof ContextCandidateSchema>;
|
||||
export type InjectionSourcesInput = Static<typeof InjectionSourcesInputSchema>;
|
||||
export type Priority = (typeof PRIORITY_VALUES)[number];
|
||||
|
||||
export interface ReconData {
|
||||
readonly executive_summary?: ExecutiveSummaryInput;
|
||||
readonly technology_stack?: TechnologyStackInput;
|
||||
readonly authentication?: AuthenticationInput;
|
||||
readonly endpoints?: readonly Endpoint[];
|
||||
readonly input_vectors?: InputVectorsInput;
|
||||
readonly network_map?: NetworkMapInput;
|
||||
readonly role_architecture?: RoleArchitectureInput;
|
||||
readonly authz_candidates?: AuthzCandidatesInput;
|
||||
readonly injection_sources?: InjectionSourcesInput;
|
||||
}
|
||||
|
||||
export const RECON_ONE_SHOT_TOOLS = [
|
||||
'set_executive_summary',
|
||||
'set_technology_stack',
|
||||
'set_authentication',
|
||||
'set_input_vectors',
|
||||
'set_network_map',
|
||||
'set_role_architecture',
|
||||
'set_authz_candidates',
|
||||
'set_injection_sources',
|
||||
] as const;
|
||||
|
||||
export type ReconOneShotToolName = (typeof RECON_ONE_SHOT_TOOLS)[number];
|
||||
|
||||
export type ReconToolStatus = 'called' | 'skipped';
|
||||
|
||||
export interface ReconCallStatus {
|
||||
readonly set_executive_summary: ReconToolStatus;
|
||||
readonly set_technology_stack: ReconToolStatus;
|
||||
readonly set_authentication: ReconToolStatus;
|
||||
readonly add_endpoints: { readonly calls: number; readonly endpoints_seen: number };
|
||||
readonly set_input_vectors: ReconToolStatus;
|
||||
readonly set_network_map: ReconToolStatus;
|
||||
readonly set_role_architecture: ReconToolStatus;
|
||||
readonly set_authz_candidates: ReconToolStatus;
|
||||
readonly set_injection_sources: ReconToolStatus;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// RESPONSE HELPERS
|
||||
// ============================================================================
|
||||
|
||||
function toolResult(payload: Record<string, unknown>) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(payload, null, 2) }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function successResult(data: Record<string, unknown>) {
|
||||
return toolResult({ status: 'success', ...data });
|
||||
}
|
||||
|
||||
function errorResult(message: string, errorType = 'ValidationError', retryable = true) {
|
||||
return toolResult({ status: 'error', message, errorType, retryable });
|
||||
}
|
||||
|
||||
function endpointKey(method: string, path: string): string {
|
||||
return `${method} ${path}`;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// COLLECTOR FACTORY
|
||||
// ============================================================================
|
||||
|
||||
interface ReconState {
|
||||
executive_summary?: ExecutiveSummaryInput;
|
||||
technology_stack?: TechnologyStackInput;
|
||||
authentication?: AuthenticationInput;
|
||||
input_vectors?: InputVectorsInput;
|
||||
network_map?: NetworkMapInput;
|
||||
role_architecture?: RoleArchitectureInput;
|
||||
authz_candidates?: AuthzCandidatesInput;
|
||||
injection_sources?: InjectionSourcesInput;
|
||||
}
|
||||
|
||||
export interface ReconCollector {
|
||||
tools: ToolDefinition[];
|
||||
getAll(): ReconData;
|
||||
getCallStatus(): ReconCallStatus;
|
||||
}
|
||||
|
||||
export function createReconCollector(): ReconCollector {
|
||||
const state: ReconState = {};
|
||||
|
||||
const endpoints: Endpoint[] = [];
|
||||
const seenEndpointKeys = new Set<string>();
|
||||
let addEndpointsCalls = 0;
|
||||
|
||||
function alreadyCalled(toolName: ReconOneShotToolName) {
|
||||
return errorResult(
|
||||
`${toolName} has already been called. Each set_* tool may only be called once per run.`,
|
||||
'DuplicateError',
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
const setExecutiveSummary = defineTool({
|
||||
name: 'set_executive_summary',
|
||||
label: 'Set Executive Summary',
|
||||
description:
|
||||
"Record the application's executive summary: purpose, core technology stack, and primary " +
|
||||
'user-facing components. Call exactly once before terminating. Becomes Section 1 of the rendered ' +
|
||||
'deliverable. Duplicate calls are rejected.',
|
||||
parameters: ExecutiveSummaryInputSchema,
|
||||
async execute(_toolCallId, input) {
|
||||
if (state.executive_summary) return alreadyCalled('set_executive_summary');
|
||||
state.executive_summary = cleanInput(ExecutiveSummaryInputSchema, input);
|
||||
return successResult({ set: 'set_executive_summary' });
|
||||
},
|
||||
});
|
||||
|
||||
const setTechnologyStack = defineTool({
|
||||
name: 'set_technology_stack',
|
||||
label: 'Set Technology Stack',
|
||||
description:
|
||||
'Record the technology and service map: frontend, backend, and infrastructure. Call exactly once ' +
|
||||
'before terminating. Becomes Section 2 of the rendered deliverable. Duplicate calls are rejected.',
|
||||
parameters: TechnologyStackInputSchema,
|
||||
async execute(_toolCallId, input) {
|
||||
if (state.technology_stack) return alreadyCalled('set_technology_stack');
|
||||
state.technology_stack = cleanInput(TechnologyStackInputSchema, input);
|
||||
return successResult({ set: 'set_technology_stack' });
|
||||
},
|
||||
});
|
||||
|
||||
const setAuthentication = defineTool({
|
||||
name: 'set_authentication',
|
||||
label: 'Set Authentication',
|
||||
description:
|
||||
'Record the authentication and session management architecture: session flow, role assignment, ' +
|
||||
'privilege storage, and role switching/impersonation. Call exactly once before terminating. ' +
|
||||
'Becomes Sections 3, 3.1, 3.2, and 3.3 of the rendered deliverable. Set ' +
|
||||
'role_switching_impersonation.applicable=false (with the other fields null) if no such features ' +
|
||||
'exist. Duplicate calls are rejected.',
|
||||
parameters: AuthenticationInputSchema,
|
||||
async execute(_toolCallId, input) {
|
||||
if (state.authentication) return alreadyCalled('set_authentication');
|
||||
state.authentication = cleanInput(AuthenticationInputSchema, input);
|
||||
return successResult({ set: 'set_authentication' });
|
||||
},
|
||||
});
|
||||
|
||||
const addEndpoints = defineTool({
|
||||
name: 'add_endpoints',
|
||||
label: 'Add Endpoints',
|
||||
description:
|
||||
'Append a batch of network-accessible API endpoints to the catalog. May be called multiple times — ' +
|
||||
'each call appends. Use a single call for small inventories, or split across 2-3 calls for large ' +
|
||||
'inventories (50+ endpoints) to keep individual payloads comfortable. Duplicate (method, path) ' +
|
||||
'pairs across calls are skipped as no-ops; the response reports added vs. skipped. Becomes ' +
|
||||
'Section 4 of the rendered deliverable and drives vuln-authz / vuln-injection todos downstream. ' +
|
||||
'The renderer sorts by (path, method) before rendering, so emission order does not affect output.',
|
||||
parameters: AddEndpointsInputSchema,
|
||||
async execute(_toolCallId, input) {
|
||||
addEndpointsCalls += 1;
|
||||
const added: string[] = [];
|
||||
const skipped: string[] = [];
|
||||
for (const ep of input.endpoints) {
|
||||
const key = endpointKey(ep.method, ep.path);
|
||||
if (seenEndpointKeys.has(key)) {
|
||||
skipped.push(key);
|
||||
continue;
|
||||
}
|
||||
seenEndpointKeys.add(key);
|
||||
endpoints.push(cleanInput(EndpointSchema, ep));
|
||||
added.push(key);
|
||||
}
|
||||
return successResult({
|
||||
set: 'add_endpoints',
|
||||
added: added.length,
|
||||
duplicates_skipped: skipped,
|
||||
total_accumulated: endpoints.length,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const setInputVectors = defineTool({
|
||||
name: 'set_input_vectors',
|
||||
label: 'Set Input Vectors',
|
||||
description:
|
||||
'Record potential input vectors grouped by source: URL parameters, POST body fields, HTTP headers, ' +
|
||||
'and cookie values. Call exactly once before terminating. Becomes Section 5 of the rendered ' +
|
||||
'deliverable. Drives downstream vulnerability analysis. Duplicate calls are rejected.',
|
||||
parameters: InputVectorsInputSchema,
|
||||
async execute(_toolCallId, input) {
|
||||
if (state.input_vectors) return alreadyCalled('set_input_vectors');
|
||||
state.input_vectors = cleanInput(InputVectorsInputSchema, input);
|
||||
return successResult({ set: 'set_input_vectors' });
|
||||
},
|
||||
});
|
||||
|
||||
const setNetworkMap = defineTool({
|
||||
name: 'set_network_map',
|
||||
label: 'Set Network Map',
|
||||
description:
|
||||
'Record the network and interaction map: entities, flows, and guards. Call exactly once before ' +
|
||||
'terminating. Becomes Sections 6.1 (Entities), 6.2 (Entity Metadata), 6.3 (Flows), and 6.4 ' +
|
||||
'(Guards Directory) of the rendered deliverable. The renderer splits the entities array into ' +
|
||||
'the 6.1 and 6.2 tables and sorts each array deterministically. Duplicate calls are rejected.',
|
||||
parameters: NetworkMapInputSchema,
|
||||
async execute(_toolCallId, input) {
|
||||
if (state.network_map) return alreadyCalled('set_network_map');
|
||||
state.network_map = cleanInput(NetworkMapInputSchema, input);
|
||||
return successResult({ set: 'set_network_map' });
|
||||
},
|
||||
});
|
||||
|
||||
const setRoleArchitecture = defineTool({
|
||||
name: 'set_role_architecture',
|
||||
label: 'Set Role Architecture',
|
||||
description:
|
||||
'Record the role and privilege architecture: discovered roles and the privilege lattice. Call ' +
|
||||
'exactly once before terminating. Becomes Sections 7.1 (Discovered Roles), 7.2 (Privilege Lattice), ' +
|
||||
'7.3 (Role Entry Points), and 7.4 (Role-to-Code Mapping) of the rendered deliverable. The renderer ' +
|
||||
'splits the roles array into the per-section tables. Duplicate calls are rejected.',
|
||||
parameters: RoleArchitectureInputSchema,
|
||||
async execute(_toolCallId, input) {
|
||||
if (state.role_architecture) return alreadyCalled('set_role_architecture');
|
||||
state.role_architecture = cleanInput(RoleArchitectureInputSchema, input);
|
||||
return successResult({ set: 'set_role_architecture' });
|
||||
},
|
||||
});
|
||||
|
||||
const setAuthzCandidates = defineTool({
|
||||
name: 'set_authz_candidates',
|
||||
label: 'Set Authz Candidates',
|
||||
description:
|
||||
'Record authorization vulnerability candidates: horizontal escalation, vertical escalation, and ' +
|
||||
'context-based candidates. Call exactly once before terminating. Becomes Sections 8.1, 8.2, and ' +
|
||||
'8.3 of the rendered deliverable. The renderer assigns stable AUTHZ-CAND-NN IDs across the three ' +
|
||||
'sub-arrays in horizontal → vertical → context order, which vuln-authz reads as its todo list. ' +
|
||||
'Duplicate calls are rejected.',
|
||||
parameters: AuthzCandidatesInputSchema,
|
||||
async execute(_toolCallId, input) {
|
||||
if (state.authz_candidates) return alreadyCalled('set_authz_candidates');
|
||||
state.authz_candidates = cleanInput(AuthzCandidatesInputSchema, input);
|
||||
return successResult({ set: 'set_authz_candidates' });
|
||||
},
|
||||
});
|
||||
|
||||
const setInjectionSources = defineTool({
|
||||
name: 'set_injection_sources',
|
||||
label: 'Set Injection Sources',
|
||||
description:
|
||||
'Record discovered injection sources grouped by vulnerability class. Call exactly once before ' +
|
||||
'terminating. If the application has no network-accessible code paths to dangerous sinks, set ' +
|
||||
'applicable=false; otherwise populate each category array (empty arrays mean "scanned, no sources ' +
|
||||
'of this kind"). Becomes Section 9 of the rendered deliverable. Drives the vuln-injection agent\'s ' +
|
||||
'todos downstream. Duplicate calls are rejected.',
|
||||
parameters: InjectionSourcesInputSchema,
|
||||
async execute(_toolCallId, input) {
|
||||
if (state.injection_sources) return alreadyCalled('set_injection_sources');
|
||||
state.injection_sources = cleanInput(InjectionSourcesInputSchema, input);
|
||||
return successResult({ set: 'set_injection_sources' });
|
||||
},
|
||||
});
|
||||
|
||||
function statusOf<K extends ReconOneShotToolName>(key: K): ReconToolStatus {
|
||||
const flagMap: Record<ReconOneShotToolName, unknown> = {
|
||||
set_executive_summary: state.executive_summary,
|
||||
set_technology_stack: state.technology_stack,
|
||||
set_authentication: state.authentication,
|
||||
set_input_vectors: state.input_vectors,
|
||||
set_network_map: state.network_map,
|
||||
set_role_architecture: state.role_architecture,
|
||||
set_authz_candidates: state.authz_candidates,
|
||||
set_injection_sources: state.injection_sources,
|
||||
};
|
||||
return flagMap[key] ? 'called' : 'skipped';
|
||||
}
|
||||
|
||||
return {
|
||||
tools: [
|
||||
setExecutiveSummary,
|
||||
setTechnologyStack,
|
||||
setAuthentication,
|
||||
addEndpoints,
|
||||
setInputVectors,
|
||||
setNetworkMap,
|
||||
setRoleArchitecture,
|
||||
setAuthzCandidates,
|
||||
setInjectionSources,
|
||||
],
|
||||
getAll: (): ReconData => ({
|
||||
...(state.executive_summary && { executive_summary: state.executive_summary }),
|
||||
...(state.technology_stack && { technology_stack: state.technology_stack }),
|
||||
...(state.authentication && { authentication: state.authentication }),
|
||||
...(endpoints.length > 0 && { endpoints }),
|
||||
...(state.input_vectors && { input_vectors: state.input_vectors }),
|
||||
...(state.network_map && { network_map: state.network_map }),
|
||||
...(state.role_architecture && { role_architecture: state.role_architecture }),
|
||||
...(state.authz_candidates && { authz_candidates: state.authz_candidates }),
|
||||
...(state.injection_sources && { injection_sources: state.injection_sources }),
|
||||
}),
|
||||
getCallStatus: (): ReconCallStatus => ({
|
||||
set_executive_summary: statusOf('set_executive_summary'),
|
||||
set_technology_stack: statusOf('set_technology_stack'),
|
||||
set_authentication: statusOf('set_authentication'),
|
||||
add_endpoints: { calls: addEndpointsCalls, endpoints_seen: endpoints.length },
|
||||
set_input_vectors: statusOf('set_input_vectors'),
|
||||
set_network_map: statusOf('set_network_map'),
|
||||
set_role_architecture: statusOf('set_role_architecture'),
|
||||
set_authz_candidates: statusOf('set_authz_candidates'),
|
||||
set_injection_sources: statusOf('set_injection_sources'),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// Re-exported here so the renderer can import the shared sink type without
|
||||
// depending on pre-recon's collector by name.
|
||||
export type { SinkRef };
|
||||
@@ -0,0 +1,29 @@
|
||||
// Copyright (C) 2025 Keygraph, Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License version 3
|
||||
// as published by the Free Software Foundation.
|
||||
|
||||
import { type Static, type TSchema, type TSchemaOptions, Type } from 'typebox';
|
||||
import { Value } from 'typebox/value';
|
||||
|
||||
/**
|
||||
* String-literal enum schema whose `Static` resolves to the exact value union.
|
||||
*
|
||||
* Mapping `Type.Literal` over an array loses tuple typing (`Static` widens to
|
||||
* `never`), so enums are authored as a JSON-Schema `{ type: 'string', enum }`
|
||||
* via `Type.Unsafe` — the same shape the previous Zod `z.enum` schemas produced,
|
||||
* and validated at runtime by pi's TypeBox checker.
|
||||
*/
|
||||
export function stringEnum<const T extends readonly string[]>(values: T, options: TSchemaOptions = {}) {
|
||||
return Type.Unsafe<T[number]>({ ...options, type: 'string', enum: [...values] });
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips keys not declared on `schema` before storage, matching the previous
|
||||
* Zod bridge's `safeParse()` behavior (Zod objects silently strip unknown
|
||||
* keys by default; TypeBox schemas accept them unless explicitly cleaned).
|
||||
*/
|
||||
export function cleanInput<T extends TSchema>(schema: T, input: Static<T>): Static<T> {
|
||||
return Value.Clean(schema, structuredClone(input)) as Static<T>;
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
// Copyright (C) 2025 Keygraph, Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License version 3
|
||||
// as published by the Free Software Foundation.
|
||||
|
||||
/**
|
||||
* Vuln Collector tools (factory parameterized by vulnerability class).
|
||||
*
|
||||
* Exposes 4 one-shot, TypeBox-validated tools per vuln agent (injection, xss,
|
||||
* auth, ssrf, authz) that feed a deterministic renderer producing
|
||||
* {class}_analysis_deliverable.md:
|
||||
* - set_findings_summary — §1 executive summary + §2 dominant patterns
|
||||
* - set_strategic_intelligence — §3, per-class schema
|
||||
* - set_safe_vectors — §4, shared schema across classes
|
||||
* - set_blind_spots — §5, shared schema across classes
|
||||
*
|
||||
* Only set_strategic_intelligence varies by class; the collector branches on
|
||||
* vulnClass to assemble the right schema. The other 3 tools are identical
|
||||
* across classes.
|
||||
*
|
||||
* Skipped tools surface as renderer placeholders, not activity failures.
|
||||
* getCallStatus() exposes the per-run call pattern for logging. Each schema's
|
||||
* field-level descriptions carry the section guidance, so the agent's tool
|
||||
* catalog surfaces it.
|
||||
*/
|
||||
|
||||
import { defineTool, type ToolDefinition } from '@earendil-works/pi-coding-agent';
|
||||
import { type Static, type TObject, Type } from 'typebox';
|
||||
import { cleanInput } from './schema.js';
|
||||
|
||||
// ============================================================================
|
||||
// CLASS DISCRIMINATOR
|
||||
// ============================================================================
|
||||
|
||||
export const VULN_CLASSES = ['injection', 'xss', 'auth', 'ssrf', 'authz'] as const;
|
||||
export type VulnClass = (typeof VULN_CLASSES)[number];
|
||||
|
||||
// ============================================================================
|
||||
// SHARED SCHEMAS — set_findings_summary, set_safe_vectors, set_blind_spots
|
||||
// ============================================================================
|
||||
|
||||
const PatternSchema = Type.Object({
|
||||
name: Type.String({
|
||||
minLength: 1,
|
||||
description:
|
||||
'Concise pattern name, e.g. "Weak Session Management", "Reflected XSS in Search Parameter", ' +
|
||||
'"Insufficient URL Validation".',
|
||||
}),
|
||||
description: Type.String({
|
||||
minLength: 1,
|
||||
description: 'One- to two-sentence description of the pattern observed in the codebase.',
|
||||
}),
|
||||
implication: Type.String({
|
||||
minLength: 1,
|
||||
description: 'One- to two-sentence implication for exploitation — what does this pattern enable an attacker to do.',
|
||||
}),
|
||||
representative_finding_ids: Type.Array(Type.String({ minLength: 1 }), {
|
||||
minItems: 1,
|
||||
description:
|
||||
'IDs of findings that exhibit this pattern (e.g. ["AUTH-VULN-01", "AUTH-VULN-02"]). Must match ' +
|
||||
'IDs the agent has assigned in the structured-output exploitation queue.',
|
||||
}),
|
||||
});
|
||||
|
||||
export const FindingsSummaryInputSchema = Type.Object({
|
||||
key_outcome: Type.String({
|
||||
minLength: 1,
|
||||
description:
|
||||
'One to two sentences capturing the headline result of your analysis — what was found and its ' +
|
||||
'severity profile (e.g. "Several high-confidence SQL injection vulnerabilities were identified; ' +
|
||||
'all findings have been passed to the exploitation phase"). Becomes Section 1 of the rendered ' +
|
||||
'deliverable.',
|
||||
}),
|
||||
patterns: Type.Array(PatternSchema, {
|
||||
description:
|
||||
'Complete list of dominant patterns observed across findings. Pass all patterns in one call. ' +
|
||||
'Empty array is acceptable if no recurring patterns were observed — the deliverable will render ' +
|
||||
'"No dominant patterns identified" for Section 2 in that case.',
|
||||
}),
|
||||
});
|
||||
|
||||
export const SafeVectorInputSchema = Type.Object({
|
||||
subject: Type.String({
|
||||
minLength: 1,
|
||||
description:
|
||||
'The specific subject of analysis. For injection/xss runs, the input parameter name (e.g. ' +
|
||||
'"username", "redirect_url"). For auth/ssrf runs, the component or flow name (e.g. ' +
|
||||
'"Password Hashing", "Webhook Configuration"). For authz runs, the endpoint (e.g. ' +
|
||||
'"POST /api/auth/logout"). The renderer maps this to the class-appropriate column header.',
|
||||
}),
|
||||
location: Type.String({
|
||||
minLength: 1,
|
||||
description:
|
||||
'File path with line number (e.g. "controllers/authController.js:45") or endpoint URL (e.g. ' +
|
||||
'"/profile"). For authz runs, this is the guard location specifically (e.g. ' +
|
||||
'"middleware/auth.js:45"). The renderer maps this to the class-appropriate column header.',
|
||||
}),
|
||||
defense_mechanism: Type.String({
|
||||
minLength: 1,
|
||||
description:
|
||||
'The robust defense observed (e.g. "Prepared Statement (Parameter Binding)", "HTML Entity ' +
|
||||
'Encoding", "Strict URL Whitelist Validation", "bcrypt.compare for constant-time check").',
|
||||
}),
|
||||
render_context: Type.Optional(
|
||||
Type.Union([Type.String(), Type.Null()], {
|
||||
description:
|
||||
'XSS-only: the DOM render context for the validated vector — one of HTML_BODY, HTML_ATTRIBUTE, ' +
|
||||
'JAVASCRIPT_STRING, URL_PARAM, CSS_VALUE. Omit (or pass null) for non-XSS classes; the renderer ' +
|
||||
'only emits this column for the XSS deliverable.',
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export const SafeVectorsInputSchema = Type.Object({
|
||||
vectors: Type.Array(SafeVectorInputSchema, {
|
||||
description:
|
||||
'All input vectors / components / endpoints that were analyzed and confirmed to have robust, ' +
|
||||
'context-appropriate defenses. Empty array is acceptable but unusual — the deliverable will ' +
|
||||
'render "No vectors confirmed secure during analysis" for Section 4 in that case. Becomes ' +
|
||||
'Section 4 of the rendered deliverable. The renderer sorts by (subject, location) before ' +
|
||||
'rendering, so emission order does not affect output.',
|
||||
}),
|
||||
});
|
||||
|
||||
export const BlindSpotItemSchema = Type.Object({
|
||||
heading: Type.String({
|
||||
minLength: 1,
|
||||
description:
|
||||
'Short heading for the blind spot (e.g. "Untraced Asynchronous Flows", ' +
|
||||
'"Limited Visibility into Stored Procedures", "Minified JavaScript Bundle").',
|
||||
}),
|
||||
description: Type.String({
|
||||
minLength: 1,
|
||||
description:
|
||||
'One to three sentences describing the analysis gap — what could not be traced, why, and what ' +
|
||||
'the residual risk is.',
|
||||
}),
|
||||
});
|
||||
|
||||
export const BlindSpotsInputSchema = Type.Object({
|
||||
items: Type.Array(BlindSpotItemSchema, {
|
||||
description:
|
||||
'Analysis constraints, untraced code paths, or other coverage gaps that should be noted. ' +
|
||||
'Empty array is acceptable on high-coverage runs — the deliverable will render "No analysis ' +
|
||||
'constraints or blind spots identified" for Section 5 in that case. Becomes Section 5 of the ' +
|
||||
'rendered deliverable.',
|
||||
}),
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// PER-CLASS set_strategic_intelligence SCHEMAS (flat — no nesting)
|
||||
// ============================================================================
|
||||
|
||||
const InjectionStrategicIntelSchema = Type.Object({
|
||||
defensive_evasion_waf: Type.String({
|
||||
minLength: 1,
|
||||
description:
|
||||
'WAF behavior observed during analysis: active rules, common payloads blocked, identified ' +
|
||||
'bypasses (e.g. "WAF blocks UNION SELECT but not time-based blind injection"). Write ' +
|
||||
'"Not applicable — no WAF observed" if none was detected.',
|
||||
}),
|
||||
error_based_potential: Type.String({
|
||||
minLength: 1,
|
||||
description:
|
||||
'Whether endpoints leak verbose database errors that enable error-based injection (e.g. ' +
|
||||
'"/api/products returns verbose PostgreSQL error messages, prime target for error-based ' +
|
||||
'exploitation"). Write "Not applicable" if no injection findings exist.',
|
||||
}),
|
||||
confirmed_database_technology: Type.String({
|
||||
minLength: 1,
|
||||
description:
|
||||
'Database engine(s) confirmed via error syntax or function calls (e.g. "PostgreSQL, confirmed ' +
|
||||
'via pg_sleep() and verbose error syntax"). Drives payload selection downstream. Write ' +
|
||||
'"Not applicable" if no DB sinks in scope.',
|
||||
}),
|
||||
});
|
||||
|
||||
const XssStrategicIntelSchema = Type.Object({
|
||||
csp_analysis: Type.String({
|
||||
minLength: 1,
|
||||
description:
|
||||
'Content Security Policy observed and its bypassability: current policy text, critical bypasses ' +
|
||||
"(e.g. \"script-src 'self' https://trusted-cdn.com — the trusted CDN hosts vulnerable AngularJS, " +
|
||||
'enabling client-side template injection bypass"). Write "Not applicable — no CSP header served" ' +
|
||||
'if none.',
|
||||
}),
|
||||
cookie_security: Type.String({
|
||||
minLength: 1,
|
||||
description:
|
||||
'Session cookie security observations: HttpOnly, Secure, SameSite flags, and storage mechanism ' +
|
||||
'(e.g. "Primary session cookie `sessionid` is missing HttpOnly; tokens are also stored in ' +
|
||||
'localStorage, both accessible to JavaScript"). Drives exfiltration strategy.',
|
||||
}),
|
||||
});
|
||||
|
||||
const AuthStrategicIntelSchema = Type.Object({
|
||||
authentication_method: Type.String({
|
||||
minLength: 1,
|
||||
description:
|
||||
'How users authenticate: JWT, session cookie, OAuth, SAML, etc. Include any algorithm or library ' +
|
||||
'details (e.g. "JWT (RS256) with hardcoded private key in lib/insecurity.ts:23").',
|
||||
}),
|
||||
session_token_details: Type.String({
|
||||
minLength: 1,
|
||||
description:
|
||||
'Where tokens live and how they are protected: cookie name, storage mechanism (cookie vs ' +
|
||||
'localStorage), cookie flags, expiration (e.g. "JWT stored in localStorage under key `token`; ' +
|
||||
'cookie copy lacks HttpOnly/Secure/SameSite; 6-hour TTL with no revocation").',
|
||||
}),
|
||||
password_policy: Type.String({
|
||||
minLength: 1,
|
||||
description:
|
||||
'Observed server-side password policy and storage: complexity rules, hashing algorithm, salt, ' +
|
||||
'(e.g. "MD5 without salt via crypto.createHash; no server-side complexity policy; client-side ' +
|
||||
'5-char minimum trivially bypassed").',
|
||||
}),
|
||||
});
|
||||
|
||||
const SsrfStrategicIntelSchema = Type.Object({
|
||||
http_client_library: Type.String({
|
||||
minLength: 1,
|
||||
description:
|
||||
'HTTP client library/libraries used for outbound requests (e.g. "axios 1.6", "node-fetch", ' +
|
||||
'"requests", "HttpClient (Spring)"). Include version where it informs known bypass techniques.',
|
||||
}),
|
||||
request_architecture: Type.String({
|
||||
minLength: 1,
|
||||
description:
|
||||
'How outbound requests are constructed and routed: proxy/middleware patterns, internal routing ' +
|
||||
'rules (e.g. "Webhook URLs are POSTed directly without an outbound proxy; redirects are ' +
|
||||
'followed by default with no maxRedirects limit").',
|
||||
}),
|
||||
internal_services: Type.String({
|
||||
minLength: 1,
|
||||
description:
|
||||
'Internal endpoints, services, or cloud-metadata addresses discovered during analysis that an ' +
|
||||
'SSRF could reach (e.g. "169.254.169.254 (AWS IMDS), internal admin API at admin.internal:8443, ' +
|
||||
'PostgreSQL on localhost:5432").',
|
||||
}),
|
||||
});
|
||||
|
||||
const AuthzStrategicIntelSchema = Type.Object({
|
||||
session_management_architecture: Type.String({
|
||||
minLength: 1,
|
||||
description:
|
||||
'Session and authentication architecture relevant to authorization decisions: where user identity ' +
|
||||
'comes from, whether the user ID is trusted by downstream guards (e.g. "JWT tokens in cookies; ' +
|
||||
'user ID extracted from `req.user.id` and used directly in DB queries without ownership ' +
|
||||
're-validation").',
|
||||
}),
|
||||
role_permission_model: Type.String({
|
||||
minLength: 1,
|
||||
description:
|
||||
'Roles, capabilities, and where they live: identified roles, their privilege levels, and where ' +
|
||||
'role/permission data is stored (e.g. "Three roles: user, moderator, admin. Role embedded in ' +
|
||||
'JWT and database; checks inconsistent — many admin routes only check `req.user` presence").',
|
||||
}),
|
||||
resource_access_patterns: Type.String({
|
||||
minLength: 1,
|
||||
description:
|
||||
'How resource IDs flow through the system and ownership patterns: e.g. "Most endpoints use path ' +
|
||||
'parameters for resource IDs (/api/users/{id}); IDs are passed to DB queries without ownership ' +
|
||||
'validation". Critical for IDOR exploitation.',
|
||||
}),
|
||||
workflow_implementation: Type.String({
|
||||
minLength: 1,
|
||||
description:
|
||||
'Multi-step processes and state transitions: how workflow stages are tracked, whether prior-state ' +
|
||||
'checks are enforced (e.g. "Multi-step processes use status fields in database; status ' +
|
||||
'transitions do not verify prior state completion"). Drives context-based authz exploitation.',
|
||||
}),
|
||||
});
|
||||
|
||||
export const STRATEGIC_INTEL_SCHEMAS: Record<VulnClass, TObject> = {
|
||||
injection: InjectionStrategicIntelSchema,
|
||||
xss: XssStrategicIntelSchema,
|
||||
auth: AuthStrategicIntelSchema,
|
||||
ssrf: SsrfStrategicIntelSchema,
|
||||
authz: AuthzStrategicIntelSchema,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// EXPORTED TYPES
|
||||
// ============================================================================
|
||||
|
||||
export type Pattern = Static<typeof PatternSchema>;
|
||||
export type FindingsSummaryInput = Static<typeof FindingsSummaryInputSchema>;
|
||||
export type SafeVectorInput = Static<typeof SafeVectorInputSchema>;
|
||||
export type SafeVectorsInput = Static<typeof SafeVectorsInputSchema>;
|
||||
export type BlindSpotItem = Static<typeof BlindSpotItemSchema>;
|
||||
export type BlindSpotsInput = Static<typeof BlindSpotsInputSchema>;
|
||||
|
||||
export type InjectionStrategicIntel = Static<typeof InjectionStrategicIntelSchema>;
|
||||
export type XssStrategicIntel = Static<typeof XssStrategicIntelSchema>;
|
||||
export type AuthStrategicIntel = Static<typeof AuthStrategicIntelSchema>;
|
||||
export type SsrfStrategicIntel = Static<typeof SsrfStrategicIntelSchema>;
|
||||
export type AuthzStrategicIntel = Static<typeof AuthzStrategicIntelSchema>;
|
||||
|
||||
// Discriminated by the agent class context — the renderer reads only the
|
||||
// sub-fields that apply to the active class.
|
||||
export type StrategicIntelligenceInput =
|
||||
| InjectionStrategicIntel
|
||||
| XssStrategicIntel
|
||||
| AuthStrategicIntel
|
||||
| SsrfStrategicIntel
|
||||
| AuthzStrategicIntel;
|
||||
|
||||
export interface VulnCollectorData {
|
||||
readonly findings_summary?: FindingsSummaryInput;
|
||||
readonly strategic_intelligence?: StrategicIntelligenceInput;
|
||||
readonly safe_vectors?: SafeVectorsInput;
|
||||
readonly blind_spots?: BlindSpotsInput;
|
||||
}
|
||||
|
||||
export const VULN_TOOLS = [
|
||||
'set_findings_summary',
|
||||
'set_strategic_intelligence',
|
||||
'set_safe_vectors',
|
||||
'set_blind_spots',
|
||||
] as const;
|
||||
|
||||
export type VulnToolName = (typeof VULN_TOOLS)[number];
|
||||
|
||||
export type VulnToolStatus = 'called' | 'skipped';
|
||||
|
||||
export type VulnCallStatus = Readonly<Record<VulnToolName, VulnToolStatus>>;
|
||||
|
||||
// ============================================================================
|
||||
// RESPONSE HELPERS
|
||||
// ============================================================================
|
||||
|
||||
function toolResult(payload: Record<string, unknown>) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(payload, null, 2) }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function successResult(data: Record<string, unknown>) {
|
||||
return toolResult({ status: 'success', ...data });
|
||||
}
|
||||
|
||||
function errorResult(message: string, errorType = 'ValidationError', retryable = true) {
|
||||
return toolResult({ status: 'error', message, errorType, retryable });
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// COLLECTOR FACTORY
|
||||
// ============================================================================
|
||||
|
||||
interface VulnState {
|
||||
findings_summary?: FindingsSummaryInput;
|
||||
strategic_intelligence?: StrategicIntelligenceInput;
|
||||
safe_vectors?: SafeVectorsInput;
|
||||
blind_spots?: BlindSpotsInput;
|
||||
}
|
||||
|
||||
export interface VulnCollector {
|
||||
tools: ToolDefinition[];
|
||||
getAll(): VulnCollectorData;
|
||||
getCallStatus(): VulnCallStatus;
|
||||
}
|
||||
|
||||
export function createVulnCollector(vulnClass: VulnClass): VulnCollector {
|
||||
const state: VulnState = {};
|
||||
|
||||
function alreadyCalled(toolName: VulnToolName) {
|
||||
return errorResult(
|
||||
`${toolName} has already been called. Each tool may only be called once per run.`,
|
||||
'DuplicateError',
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
const setFindingsSummary = defineTool({
|
||||
name: 'set_findings_summary',
|
||||
label: 'Set Findings Summary',
|
||||
description:
|
||||
'Record the executive summary headline and the dominant vulnerability patterns observed across ' +
|
||||
'your findings. Call exactly once before terminating. Becomes Section 1 (key outcome) and ' +
|
||||
'Section 2 (patterns) of the rendered deliverable — this is the load-bearing emission for the ' +
|
||||
'narrative .md and is required. Duplicate calls return "already called" and are no-ops. Empty ' +
|
||||
'patterns array is acceptable (renders as "No dominant patterns identified") but key_outcome ' +
|
||||
'is always required.',
|
||||
parameters: FindingsSummaryInputSchema,
|
||||
async execute(_toolCallId, input) {
|
||||
if (state.findings_summary) return alreadyCalled('set_findings_summary');
|
||||
state.findings_summary = cleanInput(FindingsSummaryInputSchema, input);
|
||||
return successResult({ set: 'set_findings_summary' });
|
||||
},
|
||||
});
|
||||
|
||||
const intelSchema = STRATEGIC_INTEL_SCHEMAS[vulnClass];
|
||||
const setStrategicIntelligence = defineTool({
|
||||
name: 'set_strategic_intelligence',
|
||||
label: 'Set Strategic Intelligence',
|
||||
description:
|
||||
`Record the environmental and defensive intelligence relevant to exploiting the ${vulnClass} ` +
|
||||
'findings. Call exactly once before terminating. Becomes Section 3 of the rendered deliverable ' +
|
||||
`and is the section the downstream exploit-${vulnClass} agent reads for strategic context. ` +
|
||||
'Required. Duplicate calls return "already called" and are no-ops. Write "Not applicable" as ' +
|
||||
'the field value when a sub-field does not apply to this run (rather than omitting).',
|
||||
parameters: intelSchema,
|
||||
async execute(_toolCallId, input) {
|
||||
if (state.strategic_intelligence) return alreadyCalled('set_strategic_intelligence');
|
||||
state.strategic_intelligence = cleanInput(intelSchema, input) as unknown as StrategicIntelligenceInput;
|
||||
return successResult({ set: 'set_strategic_intelligence' });
|
||||
},
|
||||
});
|
||||
|
||||
const setSafeVectors = defineTool({
|
||||
name: 'set_safe_vectors',
|
||||
label: 'Set Safe Vectors',
|
||||
description:
|
||||
'Record the input vectors, components, or endpoints that were analyzed and confirmed to have ' +
|
||||
'robust, context-appropriate defenses. Call exactly once before terminating. Becomes Section 4 ' +
|
||||
'of the rendered deliverable. Recommended (empty array is acceptable on runs where no vectors ' +
|
||||
'were validated as safe, but explicit emission is preferred). The renderer sorts by ' +
|
||||
'(subject, location) before rendering, so emission order does not affect output. Duplicate ' +
|
||||
'calls return "already called" and are no-ops.',
|
||||
parameters: SafeVectorsInputSchema,
|
||||
async execute(_toolCallId, input) {
|
||||
if (state.safe_vectors) return alreadyCalled('set_safe_vectors');
|
||||
state.safe_vectors = cleanInput(SafeVectorsInputSchema, input);
|
||||
return successResult({ set: 'set_safe_vectors', count: input.vectors.length });
|
||||
},
|
||||
});
|
||||
|
||||
const setBlindSpots = defineTool({
|
||||
name: 'set_blind_spots',
|
||||
label: 'Set Blind Spots',
|
||||
description:
|
||||
'Record analysis constraints, untraced code paths, or other coverage gaps. Call exactly once ' +
|
||||
'before terminating. Becomes Section 5 of the rendered deliverable. Recommended (empty array ' +
|
||||
'is acceptable on high-coverage runs, but explicit emission is preferred — readers expect ' +
|
||||
'either documented gaps or an explicit "no gaps" signal). Duplicate calls return "already ' +
|
||||
'called" and are no-ops.',
|
||||
parameters: BlindSpotsInputSchema,
|
||||
async execute(_toolCallId, input) {
|
||||
if (state.blind_spots) return alreadyCalled('set_blind_spots');
|
||||
state.blind_spots = cleanInput(BlindSpotsInputSchema, input);
|
||||
return successResult({ set: 'set_blind_spots', count: input.items.length });
|
||||
},
|
||||
});
|
||||
|
||||
function statusOf<K extends VulnToolName>(key: K): VulnToolStatus {
|
||||
const flagMap: Record<VulnToolName, unknown> = {
|
||||
set_findings_summary: state.findings_summary,
|
||||
set_strategic_intelligence: state.strategic_intelligence,
|
||||
set_safe_vectors: state.safe_vectors,
|
||||
set_blind_spots: state.blind_spots,
|
||||
};
|
||||
return flagMap[key] ? 'called' : 'skipped';
|
||||
}
|
||||
|
||||
return {
|
||||
tools: [setFindingsSummary, setStrategicIntelligence, setSafeVectors, setBlindSpots],
|
||||
getAll: (): VulnCollectorData => ({
|
||||
...(state.findings_summary && { findings_summary: state.findings_summary }),
|
||||
...(state.strategic_intelligence && { strategic_intelligence: state.strategic_intelligence }),
|
||||
...(state.safe_vectors && { safe_vectors: state.safe_vectors }),
|
||||
...(state.blind_spots && { blind_spots: state.blind_spots }),
|
||||
}),
|
||||
getCallStatus: (): VulnCallStatus => ({
|
||||
set_findings_summary: statusOf('set_findings_summary'),
|
||||
set_strategic_intelligence: statusOf('set_strategic_intelligence'),
|
||||
set_safe_vectors: statusOf('set_safe_vectors'),
|
||||
set_blind_spots: statusOf('set_blind_spots'),
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -10,7 +10,13 @@ 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 {
|
||||
ALL_VULN_CLASSES,
|
||||
type Authentication,
|
||||
type Config,
|
||||
type DistributedConfig,
|
||||
type Rule,
|
||||
} from './types/config.js';
|
||||
import { ErrorCode } from './types/errors.js';
|
||||
|
||||
// Handle ESM/CJS interop for ajv-formats using require
|
||||
@@ -306,6 +312,39 @@ export const parseConfigYAML = (yamlContent: string): Config => {
|
||||
return config as Config;
|
||||
};
|
||||
|
||||
function checkDeprecatedFields(config: Config): void {
|
||||
const messages: string[] = [];
|
||||
|
||||
const checkRules = (rules: unknown, where: string): void => {
|
||||
if (!Array.isArray(rules)) return;
|
||||
rules.forEach((rule, idx) => {
|
||||
if (typeof rule !== 'object' || rule === null) return;
|
||||
const r = rule as Record<string, unknown>;
|
||||
if (r.type === 'path') {
|
||||
messages.push(`rules.${where}[${idx}].type: 'path' has been renamed to 'url_path'.`);
|
||||
}
|
||||
if ('url_path' in r && !('value' in r)) {
|
||||
messages.push(`rules.${where}[${idx}]: the rule field 'url_path' has been renamed to 'value'.`);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const raw = config as Record<string, unknown>;
|
||||
const rules = raw.rules as { avoid?: unknown; focus?: unknown } | undefined;
|
||||
checkRules(rules?.avoid, 'avoid');
|
||||
checkRules(rules?.focus, 'focus');
|
||||
|
||||
if (messages.length > 0) {
|
||||
throw new PentestError(
|
||||
`Configuration uses deprecated fields. Please update:\n - ${messages.join('\n - ')}`,
|
||||
'config',
|
||||
false,
|
||||
{ deprecatedFields: messages },
|
||||
ErrorCode.CONFIG_VALIDATION_FAILED,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const validateConfig = (config: Config): void => {
|
||||
if (!config || typeof config !== 'object') {
|
||||
throw new PentestError(
|
||||
@@ -327,6 +366,8 @@ const validateConfig = (config: Config): void => {
|
||||
);
|
||||
}
|
||||
|
||||
checkDeprecatedFields(config);
|
||||
|
||||
const isValid = validateSchema(config);
|
||||
if (!isValid) {
|
||||
const errors = validateSchema.errors || [];
|
||||
@@ -342,10 +383,16 @@ const validateConfig = (config: Config): void => {
|
||||
|
||||
performSecurityValidation(config);
|
||||
|
||||
if (!config.rules && !config.authentication && !config.description) {
|
||||
console.warn(
|
||||
'⚠️ Configuration file contains no rules, authentication, or description. The pentest will run without any scoping restrictions or login capabilities.',
|
||||
);
|
||||
const hasAnySteering =
|
||||
!!config.rules ||
|
||||
!!config.authentication ||
|
||||
!!config.description ||
|
||||
!!config.vuln_classes ||
|
||||
config.exploit !== undefined ||
|
||||
!!config.report ||
|
||||
!!config.rules_of_engagement;
|
||||
if (!hasAnySteering) {
|
||||
console.warn('⚠️ Configuration file contains no steering fields. The pentest will run with all defaults.');
|
||||
} 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.');
|
||||
}
|
||||
@@ -381,15 +428,6 @@ const performSecurityValidation = (config: Config): void => {
|
||||
ErrorCode.CONFIG_VALIDATION_FAILED,
|
||||
);
|
||||
}
|
||||
if (pattern.test(auth.credentials.password)) {
|
||||
throw new PentestError(
|
||||
`authentication.credentials.password contains potentially dangerous pattern: ${pattern.source}`,
|
||||
'config',
|
||||
false,
|
||||
{ field: 'credentials.password', pattern: pattern.source },
|
||||
ErrorCode.CONFIG_VALIDATION_FAILED,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -432,6 +470,34 @@ const performSecurityValidation = (config: Config): void => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (config.rules_of_engagement) {
|
||||
for (const pattern of DANGEROUS_PATTERNS) {
|
||||
if (pattern.test(config.rules_of_engagement)) {
|
||||
throw new PentestError(
|
||||
`rules_of_engagement contains potentially dangerous pattern: ${pattern.source}`,
|
||||
'config',
|
||||
false,
|
||||
{ field: 'rules_of_engagement', pattern: pattern.source },
|
||||
ErrorCode.CONFIG_VALIDATION_FAILED,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (config.report?.guidance) {
|
||||
for (const pattern of DANGEROUS_PATTERNS) {
|
||||
if (pattern.test(config.report.guidance)) {
|
||||
throw new PentestError(
|
||||
`report.guidance contains potentially dangerous pattern: ${pattern.source}`,
|
||||
'config',
|
||||
false,
|
||||
{ field: 'report.guidance', pattern: pattern.source },
|
||||
ErrorCode.CONFIG_VALIDATION_FAILED,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const validateRulesSecurity = (rules: Rule[] | undefined, ruleType: string): void => {
|
||||
@@ -439,16 +505,16 @@ const validateRulesSecurity = (rules: Rule[] | undefined, ruleType: string): voi
|
||||
|
||||
rules.forEach((rule, index) => {
|
||||
for (const pattern of DANGEROUS_PATTERNS) {
|
||||
if (pattern.test(rule.url_path)) {
|
||||
if (pattern.test(rule.value)) {
|
||||
throw new PentestError(
|
||||
`rules.${ruleType}[${index}].url_path contains potentially dangerous pattern: ${pattern.source}`,
|
||||
`rules.${ruleType}[${index}].value contains potentially dangerous pattern: ${pattern.source}`,
|
||||
'config',
|
||||
false,
|
||||
{ field: `rules.${ruleType}[${index}].url_path`, pattern: pattern.source },
|
||||
{ field: `rules.${ruleType}[${index}].value`, pattern: pattern.source },
|
||||
ErrorCode.CONFIG_VALIDATION_FAILED,
|
||||
);
|
||||
}
|
||||
if (pattern.test(rule.description)) {
|
||||
if (rule.description !== undefined && pattern.test(rule.description)) {
|
||||
throw new PentestError(
|
||||
`rules.${ruleType}[${index}].description contains potentially dangerous pattern: ${pattern.source}`,
|
||||
'config',
|
||||
@@ -464,13 +530,25 @@ const validateRulesSecurity = (rules: Rule[] | undefined, ruleType: string): voi
|
||||
};
|
||||
|
||||
const validateRuleTypeSpecific = (rule: Rule, ruleType: string, index: number): void => {
|
||||
const field = `rules.${ruleType}[${index}].url_path`;
|
||||
const field = `rules.${ruleType}[${index}].value`;
|
||||
|
||||
switch (rule.type) {
|
||||
case 'path':
|
||||
if (!rule.url_path.startsWith('/')) {
|
||||
case 'url_path':
|
||||
if (!rule.value.startsWith('/')) {
|
||||
throw new PentestError(
|
||||
`${field} for type 'path' must start with '/'`,
|
||||
`${field} for type 'url_path' must start with '/'`,
|
||||
'config',
|
||||
false,
|
||||
{ field, ruleType: rule.type },
|
||||
ErrorCode.CONFIG_VALIDATION_FAILED,
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'code_path':
|
||||
if (rule.value.includes('://')) {
|
||||
throw new PentestError(
|
||||
`${field} for type 'code_path' must not contain a URL protocol (got '${rule.value}')`,
|
||||
'config',
|
||||
false,
|
||||
{ field, ruleType: rule.type },
|
||||
@@ -482,7 +560,7 @@ const validateRuleTypeSpecific = (rule: Rule, ruleType: string, index: number):
|
||||
case 'subdomain':
|
||||
case 'domain':
|
||||
// Basic domain validation - no slashes allowed
|
||||
if (rule.url_path.includes('/')) {
|
||||
if (rule.value.includes('/')) {
|
||||
throw new PentestError(
|
||||
`${field} for type '${rule.type}' cannot contain '/' characters`,
|
||||
'config',
|
||||
@@ -492,7 +570,7 @@ const validateRuleTypeSpecific = (rule: Rule, ruleType: string, index: number):
|
||||
);
|
||||
}
|
||||
// Must contain at least one dot for domains
|
||||
if (rule.type === 'domain' && !rule.url_path.includes('.')) {
|
||||
if (rule.type === 'domain' && !rule.value.includes('.')) {
|
||||
throw new PentestError(
|
||||
`${field} for type 'domain' must be a valid domain name`,
|
||||
'config',
|
||||
@@ -505,7 +583,7 @@ const validateRuleTypeSpecific = (rule: Rule, ruleType: string, index: number):
|
||||
|
||||
case 'method': {
|
||||
const allowedMethods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'];
|
||||
if (!allowedMethods.includes(rule.url_path.toUpperCase())) {
|
||||
if (!allowedMethods.includes(rule.value.toUpperCase())) {
|
||||
throw new PentestError(
|
||||
`${field} for type 'method' must be one of: ${allowedMethods.join(', ')}`,
|
||||
'config',
|
||||
@@ -518,7 +596,7 @@ const validateRuleTypeSpecific = (rule: Rule, ruleType: string, index: number):
|
||||
}
|
||||
|
||||
case 'header':
|
||||
if (!rule.url_path.match(/^[a-zA-Z0-9\-_]+$/)) {
|
||||
if (!rule.value.match(/^[a-zA-Z0-9\-_]+$/)) {
|
||||
throw new PentestError(
|
||||
`${field} for type 'header' must be a valid header name (alphanumeric, hyphens, underscores only)`,
|
||||
'config',
|
||||
@@ -530,7 +608,7 @@ const validateRuleTypeSpecific = (rule: Rule, ruleType: string, index: number):
|
||||
break;
|
||||
|
||||
case 'parameter':
|
||||
if (!rule.url_path.match(/^[a-zA-Z0-9\-_]+$/)) {
|
||||
if (!rule.value.match(/^[a-zA-Z0-9\-_]+$/)) {
|
||||
throw new PentestError(
|
||||
`${field} for type 'parameter' must be a valid parameter name (alphanumeric, hyphens, underscores only)`,
|
||||
'config',
|
||||
@@ -546,13 +624,13 @@ const validateRuleTypeSpecific = (rule: Rule, ruleType: string, index: number):
|
||||
const checkForDuplicates = (rules: Rule[], ruleType: string): void => {
|
||||
const seen = new Set<string>();
|
||||
rules.forEach((rule, index) => {
|
||||
const key = `${rule.type}:${rule.url_path}`;
|
||||
const key = `${rule.type}:${rule.value}`;
|
||||
if (seen.has(key)) {
|
||||
throw new PentestError(
|
||||
`Duplicate rule found in rules.${ruleType}[${index}]: ${rule.type} '${rule.url_path}'`,
|
||||
`Duplicate rule found in rules.${ruleType}[${index}]: ${rule.type} '${rule.value}'`,
|
||||
'config',
|
||||
false,
|
||||
{ field: `rules.${ruleType}[${index}]`, ruleType: rule.type, urlPath: rule.url_path },
|
||||
{ field: `rules.${ruleType}[${index}]`, ruleType: rule.type, value: rule.value },
|
||||
ErrorCode.CONFIG_VALIDATION_FAILED,
|
||||
);
|
||||
}
|
||||
@@ -561,16 +639,16 @@ const checkForDuplicates = (rules: Rule[], ruleType: string): void => {
|
||||
};
|
||||
|
||||
const checkForConflicts = (avoidRules: Rule[] = [], focusRules: Rule[] = []): void => {
|
||||
const avoidSet = new Set(avoidRules.map((rule) => `${rule.type}:${rule.url_path}`));
|
||||
const avoidSet = new Set(avoidRules.map((rule) => `${rule.type}:${rule.value}`));
|
||||
|
||||
focusRules.forEach((rule, index) => {
|
||||
const key = `${rule.type}:${rule.url_path}`;
|
||||
const key = `${rule.type}:${rule.value}`;
|
||||
if (avoidSet.has(key)) {
|
||||
throw new PentestError(
|
||||
`Conflicting rule found: rules.focus[${index}] '${rule.url_path}' also exists in rules.avoid`,
|
||||
`Conflicting rule found: rules.focus[${index}] '${rule.value}' also exists in rules.avoid`,
|
||||
'config',
|
||||
false,
|
||||
{ field: `rules.focus[${index}]`, urlPath: rule.url_path },
|
||||
{ field: `rules.focus[${index}]`, value: rule.value },
|
||||
ErrorCode.CONFIG_VALIDATION_FAILED,
|
||||
);
|
||||
}
|
||||
@@ -578,11 +656,15 @@ const checkForConflicts = (avoidRules: Rule[] = [], focusRules: Rule[] = []): vo
|
||||
};
|
||||
|
||||
const sanitizeRule = (rule: Rule): Rule => {
|
||||
return {
|
||||
description: rule.description.trim(),
|
||||
const sanitized: Rule = {
|
||||
type: rule.type.toLowerCase().trim() as Rule['type'],
|
||||
url_path: rule.url_path.trim(),
|
||||
value: rule.value.trim(),
|
||||
};
|
||||
const description = rule.description?.trim();
|
||||
if (description) {
|
||||
sanitized.description = description;
|
||||
}
|
||||
return sanitized;
|
||||
};
|
||||
|
||||
export const distributeConfig = (config: Config | null): DistributedConfig => {
|
||||
@@ -591,11 +673,29 @@ export const distributeConfig = (config: Config | null): DistributedConfig => {
|
||||
const authentication = config?.authentication || null;
|
||||
const description = config?.description?.trim() || '';
|
||||
|
||||
const vuln_classes =
|
||||
config?.vuln_classes && config.vuln_classes.length > 0 ? [...config.vuln_classes] : [...ALL_VULN_CLASSES];
|
||||
|
||||
const exploit = config?.exploit !== undefined ? config.exploit === 'true' : true;
|
||||
|
||||
const report = {
|
||||
sarif: config?.report?.sarif === 'true',
|
||||
...(config?.report?.min_severity && { min_severity: config.report.min_severity }),
|
||||
...(config?.report?.min_confidence && { min_confidence: config.report.min_confidence }),
|
||||
...(config?.report?.guidance && { guidance: config.report.guidance.trim() }),
|
||||
};
|
||||
|
||||
const rules_of_engagement = config?.rules_of_engagement?.trim() ?? '';
|
||||
|
||||
return {
|
||||
avoid: avoid.map(sanitizeRule),
|
||||
focus: focus.map(sanitizeRule),
|
||||
authentication: authentication ? sanitizeAuthentication(authentication) : null,
|
||||
description,
|
||||
vuln_classes,
|
||||
exploit,
|
||||
report,
|
||||
rules_of_engagement,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -605,8 +705,19 @@ const sanitizeAuthentication = (auth: Authentication): Authentication => {
|
||||
login_url: auth.login_url.trim(),
|
||||
credentials: {
|
||||
username: auth.credentials.username.trim(),
|
||||
password: auth.credentials.password,
|
||||
...(auth.credentials.totp_secret && { totp_secret: auth.credentials.totp_secret.trim() }),
|
||||
...(auth.credentials.password && { password: auth.credentials.password }),
|
||||
...(auth.credentials.totp_secret && {
|
||||
totp_secret: auth.credentials.totp_secret.replace(/\s/g, ''),
|
||||
}),
|
||||
...(auth.credentials.email_login && {
|
||||
email_login: {
|
||||
address: auth.credentials.email_login.address.trim(),
|
||||
password: auth.credentials.email_login.password,
|
||||
...(auth.credentials.email_login.totp_secret && {
|
||||
totp_secret: auth.credentials.email_login.totp_secret.replace(/\s/g, ''),
|
||||
}),
|
||||
},
|
||||
}),
|
||||
},
|
||||
...(auth.login_flow && { login_flow: auth.login_flow.map((step) => step.trim()) }),
|
||||
success_condition: {
|
||||
|
||||
@@ -30,22 +30,13 @@ export interface CheckpointProvider {
|
||||
* Return { skip: true, metrics } to skip the agent (e.g., output files already exist).
|
||||
* Return { skip: false } to run normally.
|
||||
*/
|
||||
shouldSkipAgent(
|
||||
agentName: string,
|
||||
repoPath: string,
|
||||
deliverablesSubdir: string,
|
||||
): Promise<SkipDecision>;
|
||||
shouldSkipAgent(agentName: string, repoPath: string, deliverablesSubdir: string): Promise<SkipDecision>;
|
||||
|
||||
/**
|
||||
* Called after an agent activity succeeds.
|
||||
* Receives pipeline state and optional file context for artifact persistence.
|
||||
*/
|
||||
onAgentComplete(
|
||||
agentName: string,
|
||||
phase: string,
|
||||
state: PipelineState,
|
||||
context?: CheckpointContext,
|
||||
): Promise<void>;
|
||||
onAgentComplete(agentName: string, phase: string, state: PipelineState, context?: CheckpointContext): Promise<void>;
|
||||
}
|
||||
|
||||
/** Default no-op implementation — no external checkpointing. */
|
||||
|
||||
@@ -11,11 +11,7 @@ import type { ActivityInput } from '../temporal/activities.js';
|
||||
import type { VulnType } from '../types/agents.js';
|
||||
|
||||
export interface FindingsProvider {
|
||||
mergeFindingsIntoQueue(
|
||||
repoPath: string,
|
||||
vulnType: VulnType,
|
||||
input: ActivityInput,
|
||||
): Promise<{ mergedCount: number }>;
|
||||
mergeFindingsIntoQueue(repoPath: string, vulnType: VulnType, input: ActivityInput): Promise<{ mergedCount: number }>;
|
||||
}
|
||||
|
||||
/** Default no-op implementation — no external findings to merge. */
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* Consumers can provide alternate implementations via the DI container.
|
||||
*/
|
||||
|
||||
export type { CheckpointProvider, CheckpointContext, SkipDecision } from './checkpoint-provider.js';
|
||||
export type { CheckpointContext, CheckpointProvider, SkipDecision } from './checkpoint-provider.js';
|
||||
export { NoOpCheckpointProvider } from './checkpoint-provider.js';
|
||||
export type { FindingsProvider } from './findings-provider.js';
|
||||
export { NoOpFindingsProvider } from './findings-provider.js';
|
||||
|
||||
@@ -9,12 +9,60 @@ 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');
|
||||
|
||||
/** Bundled Typst template that renders report.json into the PDF report. */
|
||||
export const TYPST_TEMPLATE = path.join(WORKER_ROOT, 'templates', 'typst', 'report.typ');
|
||||
|
||||
/** Compiled pi extension dir that enforces bounded `bash` timeouts (resolved from dist/) */
|
||||
export const BASH_TIMEOUT_EXTENSION_DIR = path.join(import.meta.dirname, 'ai', 'extensions', 'bash-timeout');
|
||||
|
||||
/** Default deliverables subdirectory relative to repoPath */
|
||||
export const DEFAULT_DELIVERABLES_SUBDIR = '.shannon/deliverables';
|
||||
|
||||
/** Default audit log directory */
|
||||
export const DEFAULT_AUDIT_DIR = './workspaces';
|
||||
|
||||
/**
|
||||
* Hidden subdirectory inside each run directory that holds all internals
|
||||
* (logs, prompts, session state, deliverables, browser artifacts). Keeps the
|
||||
* run folder's top level clean so only the final report is visible.
|
||||
*/
|
||||
export const INTERNAL_DIR = '.shannon';
|
||||
|
||||
/** Filename of the assembled report inside the deliverables dir (internal, source of the surfaced copy) */
|
||||
export const ASSEMBLED_REPORT_FILENAME = 'comprehensive_security_assessment_report.md';
|
||||
|
||||
/** Filename of the compiled PDF report inside the deliverables dir (internal, source of the surfaced copy) */
|
||||
export const ASSEMBLED_REPORT_PDF_FILENAME = 'comprehensive_security_assessment_report.pdf';
|
||||
|
||||
/** Filename of the human-facing PDF report surfaced at the run directory root */
|
||||
export const FINAL_REPORT_PDF_FILENAME = 'Security-Assessment-Report.pdf';
|
||||
|
||||
/** Filename of the human-facing markdown report surfaced at the run directory root, alongside the PDF */
|
||||
export const FINAL_REPORT_MD_FILENAME = 'Security-Assessment-Report.md';
|
||||
|
||||
/** Structured findings the report agent emits; the markdown report is rendered from it. */
|
||||
export const REPORT_JSON_FILENAME = 'report.json';
|
||||
|
||||
/** SARIF 2.1.0 log, written only for exploit=true runs when report.sarif is enabled. */
|
||||
export const SARIF_FILENAME = 'report.sarif';
|
||||
|
||||
/**
|
||||
* Resolve the session.json path for a run directory, preferring the current
|
||||
* `.shannon/` location and falling back to the legacy run-root location so
|
||||
* pre-restructure workspaces remain listable and resumable.
|
||||
*/
|
||||
export function resolveSessionJsonPath(runDir: string): string {
|
||||
const current = path.join(runDir, INTERNAL_DIR, 'session.json');
|
||||
if (fs.existsSync(current)) {
|
||||
return current;
|
||||
}
|
||||
const legacy = path.join(runDir, 'session.json');
|
||||
if (fs.existsSync(legacy)) {
|
||||
return legacy;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the deliverables directory for a given repoPath and optional subdir override.
|
||||
* @param repoPath - Absolute path to the target repository
|
||||
|
||||
@@ -9,8 +9,7 @@
|
||||
/**
|
||||
* generate-totp CLI
|
||||
*
|
||||
* Generates 6-digit TOTP codes for authentication.
|
||||
* Replaces the MCP generate_totp tool.
|
||||
* Generates a TOTP code for the target's MFA.
|
||||
* Based on RFC 6238 (TOTP) and RFC 4226 (HOTP).
|
||||
*
|
||||
* Usage:
|
||||
@@ -82,6 +81,26 @@ function generateTOTP(secret: string, timeStep: number = 30, digits: number = 6)
|
||||
return generateHOTP(secret, counter, digits);
|
||||
}
|
||||
|
||||
// === Help ===
|
||||
|
||||
function printHelp(): void {
|
||||
console.log(
|
||||
`generate-totp - emit a current 6-digit TOTP code for a base32-encoded secret.
|
||||
|
||||
Usage:
|
||||
generate-totp --secret <BASE32>
|
||||
generate-totp --help
|
||||
|
||||
Options:
|
||||
--secret Base32-encoded TOTP shared secret (characters A-Z, 2-7).
|
||||
-h, --help Show this help and exit.
|
||||
|
||||
Output:
|
||||
JSON to stdout. On success: {"status":"success","totpCode":"123456","expiresIn":<sec>}.
|
||||
On error: {"status":"error","message":"...","retryable":false} (exit 1).`,
|
||||
);
|
||||
}
|
||||
|
||||
// === Argument Parsing ===
|
||||
|
||||
function parseSecret(argv: string[]): string {
|
||||
@@ -97,6 +116,11 @@ function parseSecret(argv: string[]): string {
|
||||
// === Main ===
|
||||
|
||||
function main(): void {
|
||||
if (process.argv.includes('--help') || process.argv.includes('-h')) {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
const secret = parseSecret(process.argv);
|
||||
|
||||
if (!secret) {
|
||||
@@ -104,8 +128,12 @@ function main(): void {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Strip base32 padding ('=') and whitespace so grouped/padded secrets
|
||||
// (e.g. "JBSW Y3DP" or "...PXP=") pass validation instead of being rejected.
|
||||
const normalizedSecret = secret.replace(/[=\s]/g, '');
|
||||
|
||||
const base32Regex = /^[A-Z2-7]+$/i;
|
||||
if (!base32Regex.test(secret)) {
|
||||
if (!base32Regex.test(normalizedSecret)) {
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
status: 'error',
|
||||
@@ -117,7 +145,7 @@ function main(): void {
|
||||
}
|
||||
|
||||
try {
|
||||
const totpCode = generateTOTP(secret);
|
||||
const totpCode = generateTOTP(normalizedSecret);
|
||||
const expiresIn = 30 - (Math.floor(Date.now() / 1000) % 30);
|
||||
|
||||
console.log(
|
||||
|
||||
Loaded 100 of 160 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user