diff --git a/.claude/commands/debug.md b/.claude/commands/debug.md index 5dc2a8e..d8539ca 100644 --- a/.claude/commands/debug.md +++ b/.claude/commands/debug.md @@ -22,21 +22,21 @@ You are debugging an issue. Follow this structured approach to avoid spinning in **Session audit logs:** ```bash # Find most recent session -ls -lt audit-logs/ | head -5 +ls -lt workspaces/ | head -5 # Check session metrics and errors -cat audit-logs//session.json | jq '.errors, .agentMetrics' +cat workspaces//session.json | jq '.errors, .agentMetrics' # Check agent execution logs -ls -lt audit-logs//agents/ -cat audit-logs//agents/.log +ls -lt workspaces//agents/ +cat workspaces//agents/.log ``` ## Step 3: Trace the Call Path For Shannon, trace through these layers: -1. **Temporal Client** → `src/temporal/client.ts` - Workflow initiation +1. **Worker + Client** → `src/temporal/worker.ts` - Combined worker + workflow submission 2. **Workflow** → `src/temporal/workflows.ts` - Pipeline orchestration 3. **Activities** → `src/temporal/activities.ts` - Thin wrappers: heartbeat, error classification 4. **Container** → `src/services/container.ts` - Per-workflow DI @@ -72,7 +72,7 @@ For Shannon, trace through these layers: npx playwright install chromium # Check MCP server startup (look for connection errors) -grep -i "mcp\|playwright" audit-logs//agents/*.log +grep -i "mcp\|playwright" workspaces//agents/*.log ``` **Git State Issues:** diff --git a/.dockerignore b/.dockerignore index deaa1cc..d076197 100644 --- a/.dockerignore +++ b/.dockerignore @@ -46,6 +46,13 @@ temp/ ehthumbs.db Thumbs.db +# CLI package (runs on host, not in container) +# Keep cli/package.json so npm workspaces resolve and npm ci works with the root lockfile +cli/src/ +cli/dist/ +cli/infra/ +cli/tsconfig.json + # Docker files (avoid recursive copying) Dockerfile* docker-compose*.yml diff --git a/.env.example b/.env.example index 7e16af1..e843035 100644 --- a/.env.example +++ b/.env.example @@ -71,7 +71,7 @@ ANTHROPIC_API_KEY=your-api-key-here # CLAUDE_CODE_USE_VERTEX=1 # CLOUD_ML_REGION=us-east5 # ANTHROPIC_VERTEX_PROJECT_ID=your-gcp-project-id -# GOOGLE_APPLICATION_CREDENTIALS=./credentials/gcp-sa-key.json +# GOOGLE_APPLICATION_CREDENTIALS=./credentials/google-sa-key.json # ============================================================================= # Available Models diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 70022ab..45a2dd2 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -69,7 +69,7 @@ body: Issues without this information may be difficult to triage. - - Check the audit logs at: `./audit-logs/target_url_shannon-123/workflow.log` + - Check the logs at: `./workspaces/target_url_shannon-123/workflow.log` Use `grep` or search to identify errors. Paste the relevant error output below. - Temporal: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..5377a3a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,165 @@ +name: Release + +on: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: release-main + cancel-in-progress: false + +jobs: + preflight: + name: Preflight + runs-on: ubuntu-latest + permissions: + contents: write + outputs: + should_release: ${{ steps.probe.outputs.should_release }} + version: ${{ steps.probe.outputs.version }} + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Install dependencies + run: npm clean-install + + - name: Verify dependency signatures + run: npm audit signatures + + - name: Probe semantic-release + id: probe + shell: bash + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + + npx semantic-release@25 --dry-run --no-ci 2>&1 | tee semantic-release.log + + if grep -q "The next release version is" semantic-release.log; then + echo "should_release=true" >> "$GITHUB_OUTPUT" + VERSION=$(grep -oE "The next release version is [0-9]+\.[0-9]+\.[0-9]+" semantic-release.log | grep -oE "[0-9]+\.[0-9]+\.[0-9]+") + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + else + echo "should_release=false" >> "$GITHUB_OUTPUT" + fi + + publish: + name: Publish Docker and npm + needs: preflight + if: needs.preflight.outputs.should_release == 'true' + runs-on: ubuntu-latest + environment: release-publish + permissions: + contents: read + id-token: write + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Log in to Docker Hub + uses: docker/login-action@v4 + with: + username: ${{ vars.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push Docker image + id: build + uses: docker/build-push-action@v7 + with: + context: . + push: true + platforms: linux/amd64,linux/arm64 + provenance: mode=max + sbom: true + tags: | + keygraph/shannon:${{ needs.preflight.outputs.version }} + keygraph/shannon:latest + + - name: Install cosign + uses: sigstore/cosign-installer@v4.1.0 + + - name: Sign Docker image + run: cosign sign --yes keygraph/shannon@${{ steps.build.outputs.digest }} + + - name: Verify Docker image signature + run: | + sleep 10 + cosign verify \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + --certificate-identity https://github.com/${{ github.repository }}/.github/workflows/release.yml@${{ github.ref }} \ + keygraph/shannon@${{ steps.build.outputs.digest }} + + - name: Configure npm registry + uses: actions/setup-node@v6 + with: + node-version: 24 + registry-url: https://registry.npmjs.org + + - name: Install dependencies + run: npm clean-install + + - name: Verify dependency signatures + run: npm audit signatures + + - name: Set CLI package version + run: npm version "${{ needs.preflight.outputs.version }}" --workspace cli --no-git-tag-version --allow-same-version + + - name: Sync lockfile with bumped versions + run: npm i --package-lock-only + + - name: Build CLI + run: npm run build:cli + + - name: Publish npm package + working-directory: cli + run: | + if npm view "@keygraph/shannon@${{ needs.preflight.outputs.version }}" version 2>/dev/null; then + echo "Version already published, skipping" + else + npm publish --access public + fi + + release: + name: Create GitHub release + needs: [preflight, publish] + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Install dependencies + run: npm clean-install + + - name: Create GitHub release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: npx semantic-release@25 diff --git a/.github/workflows/rollback.yml b/.github/workflows/rollback.yml new file mode 100644 index 0000000..00129aa --- /dev/null +++ b/.github/workflows/rollback.yml @@ -0,0 +1,124 @@ +name: Rollback + +on: + workflow_dispatch: + inputs: + version: + description: "Version to move npm latest and Docker latest to (example: 1.4.2)" + required: true + type: string + +permissions: + contents: read + +concurrency: + group: rollback-latest-${{ github.event.inputs.version }} + cancel-in-progress: false + +jobs: + rollback: + name: Roll back npm and Docker latest + runs-on: ubuntu-latest + environment: release-rollback + + steps: + - name: Checkout tags + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Fetch all tags + run: git fetch --force --tags + + - name: Validate target version + id: target + shell: bash + run: | + set -euo pipefail + + VERSION="${{ inputs.version }}" + VERSION="${VERSION#v}" + + case "$VERSION" in + ''|*[!0-9.]*) + echo "Invalid version: $VERSION" + exit 1 + ;; + esac + + if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Version must be in semver format X.Y.Z" + exit 1 + fi + + if ! git rev-parse "refs/tags/v$VERSION" >/dev/null 2>&1; then + echo "Git tag v$VERSION does not exist" + exit 1 + fi + + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + registry-url: https://registry.npmjs.org + + - name: Verify npm package version exists + run: npm view "@keygraph/shannon@${{ steps.target.outputs.version }}" version + + - name: Show current npm dist-tags + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_ROLLBACK_TOKEN }} + run: npm dist-tag ls @keygraph/shannon + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Log in to Docker Hub + uses: docker/login-action@v4 + with: + username: ${{ vars.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Verify Docker image tag exists + run: docker buildx imagetools inspect "keygraph/shannon:${{ steps.target.outputs.version }}" + + - name: Install cosign + uses: sigstore/cosign-installer@v4.1.0 + + - name: Verify Docker image signature before rollback + run: | + cosign verify \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + --certificate-identity "https://github.com/${{ github.repository }}/.github/workflows/release.yml@refs/heads/main" \ + "keygraph/shannon:${{ steps.target.outputs.version }}" + + - name: Move Docker latest + run: | + docker buildx imagetools create \ + --tag "keygraph/shannon:latest" \ + "keygraph/shannon:${{ steps.target.outputs.version }}" + + - name: Move npm latest + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_ROLLBACK_TOKEN }} + run: npm dist-tag add "@keygraph/shannon@${{ steps.target.outputs.version }}" latest + + - name: Show final npm dist-tags + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_ROLLBACK_TOKEN }} + run: npm dist-tag ls @keygraph/shannon + + - name: Verify Docker latest now points to target + run: docker buildx imagetools inspect "keygraph/shannon:latest" + + - name: Write summary + run: | + { + echo "## Rollback latest" + echo "" + echo "- Target version: \`${{ steps.target.outputs.version }}\`" + echo "- npm package: \`@keygraph/shannon\`" + echo "- Docker image: \`keygraph/shannon\`" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.gitignore b/.gitignore index 1c1aa3a..3ba3126 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ node_modules/ .env -audit-logs/ +workspaces/ credentials/ dist/ repos/ diff --git a/.releaserc.json b/.releaserc.json new file mode 100644 index 0000000..d874d78 --- /dev/null +++ b/.releaserc.json @@ -0,0 +1,15 @@ +{ + "branches": ["main"], + "plugins": [ + "@semantic-release/commit-analyzer", + "@semantic-release/release-notes-generator", + ["@semantic-release/npm", { + "npmPublish": false + }], + ["@semantic-release/github", { + "successCommentCondition": false, + "failCommentCondition": false, + "releasedLabels": false + }] + ] +} diff --git a/CLAUDE.md b/CLAUDE.md index 13d6c82..7ca6ce9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,42 +4,109 @@ AI-powered penetration testing agent for defensive security analysis. Automates ## Commands -**Prerequisites:** Docker, Anthropic API key in `.env` +**Prerequisites:** Docker, AI provider credentials (`.env` for local, `shn setup` or env vars for npx) + +### Dual CLI + +Shannon supports two CLI modes, auto-detected based on the current working directory: + +| | **npx** (`npx @keygraph/shannon`) | **Local** (`./shannon`) | +|---|---|---| +| **Install** | Zero-install via npm | Clone the repo | +| **Image** | Pulled from Docker Hub (`keygraph/shannon:`) | Built locally (`shannon-worker`) | +| **State** | `~/.shannon/` | Project directory | +| **Credentials** | `~/.shannon/config.toml` (via `shn setup`) or env vars | `./.env` | +| **Config** | `~/.shannon/config.toml` (via `shn setup`) | N/A | +| **Prompts** | Bundled in Docker image | Mounted from `./prompts/` (live-editable) | + +Mode detection: `./shannon` sets `SHANNON_LOCAL=1` env var; `cli/src/mode.ts` checks this to select local vs npx mode. + +### npx Quick Start + +```bash +# Configure credentials (interactive wizard) +npx @keygraph/shannon setup + +# Or export env vars directly (non-interactive / CI) +export ANTHROPIC_API_KEY=your-key + +# Run +npx @keygraph/shannon start -u -r /path/to/repo +``` + +### Local (Development) Quick Start ```bash # Setup -cp .env.example .env && edit .env # Set ANTHROPIC_API_KEY +echo "ANTHROPIC_API_KEY=your-key" > .env -# Prepare repo (REPO is a folder name inside ./repos/, not an absolute path) -git clone https://github.com/org/repo.git ./repos/my-repo -# or symlink: ln -s /path/to/existing/repo ./repos/my-repo +# Build (auto-runs if image missing) +./shannon build # Run -./shannon start URL= REPO=my-repo -./shannon start URL= REPO=my-repo CONFIG=./configs/my-config.yaml +./shannon start -u -r my-repo +./shannon start -u -r my-repo -c ./configs/my-config.yaml +./shannon start -u -r /any/path/to/repo +``` + +### Common Commands + +```bash +# Setup (npx mode only — one-time credential configuration) +npx @keygraph/shannon setup # Workspaces & Resume -./shannon start URL= REPO=my-repo WORKSPACE=my-audit # New named workspace -./shannon start URL= REPO=my-repo WORKSPACE=my-audit # Resume (same command) -./shannon start URL= REPO=my-repo WORKSPACE= # Resume auto-named run -./shannon workspaces # List all workspaces +./shannon start -u -r my-repo -w my-audit # New named workspace +./shannon start -u -r my-repo -w my-audit # Resume (same command) +./shannon workspaces # List all workspaces # Monitor -./shannon logs # Real-time worker logs +./shannon logs # Tail workflow log +./shannon status # Show running workers # Temporal Web UI: http://localhost:8233 # Stop -./shannon stop # Preserves workflow data -./shannon stop CLEAN=true # Full cleanup including volumes +./shannon stop # Preserves workflow data +./shannon stop --clean # Full cleanup including volumes (confirms first) -# Build -npm run build +# Image management +./shannon build [--no-cache] # Local mode: build worker image +npx @keygraph/shannon update # npx mode: pull latest image +npx @keygraph/shannon uninstall # npx mode: remove ~/.shannon/ (confirms first) + +# Build TypeScript (development) +npm run build:all # Build core + CLI + MCP server ``` -**Options:** `CONFIG=` (YAML config), `OUTPUT=` (default: `./audit-logs/`), `WORKSPACE=` (named workspace; auto-resumes if exists), `PIPELINE_TESTING=true` (minimal prompts, 10s retries), `REBUILD=true` (force Docker rebuild), `ROUTER=true` (multi-model routing via [claude-code-router](https://github.com/musistudio/claude-code-router)) +TypeScript compiler options are shared via `tsconfig.base.json` at the root. All three packages extend it, overriding only `rootDir` and `outDir`. Shared devDependencies (`typescript`, `@types/node`) are hoisted to the root workspace. + +**Options:** `-c ` (YAML config), `-o ` (output directory), `-w ` (named workspace; auto-resumes if exists), `--pipeline-testing` (minimal prompts, 10s retries), `--router` (multi-model routing via [claude-code-router](https://github.com/musistudio/claude-code-router)) ## Architecture +### CLI Package (`cli/`) +Published as `@keygraph/shannon` on npm. Contains only Docker orchestration logic — no Temporal SDK, business logic, or prompts. + +- `cli/src/index.ts` — CLI dispatcher (`setup`, `start`, `stop`, `logs`, `workspaces`, `status`, `build`, `update`, `uninstall`, `info`) +- `cli/src/mode.ts` — Auto-detection: local mode if `Dockerfile` + `docker-compose.yml` + `prompts/` exist in CWD +- `cli/src/docker.ts` — Compose lifecycle, image pull/build, ephemeral `docker run` worker spawning +- `cli/src/home.ts` — State directory management (`~/.shannon/` for npx, `./` for local) +- `cli/src/env.ts` — `.env` loading, TOML fallback (npx only) via `cli/src/config/resolver.ts`, credential validation, env flag building +- `cli/src/config/resolver.ts` — Cascading config (npx only): env vars → `~/.shannon/config.toml` (parsed with `smol-toml`) +- `cli/src/config/writer.ts` — TOML serialization and secure file persistence (0o600) +- `cli/src/commands/setup.ts` — Interactive TUI wizard (`@clack/prompts`) for provider credential setup (npx only) +- `cli/src/paths.ts` — Repo/config path resolution (bare name → `./repos/`, or any absolute/relative path) +- `cli/src/commands/` — Command handlers +- `cli/infra/compose.yml` — Bundled Temporal + router compose file for npx mode +- `shannon` — Node.js entry point (`#!/usr/bin/env node`) that delegates to `cli/dist/index.js` + +### Docker Architecture +Infra (Temporal + router) runs via `docker-compose.yml`. Workers are ephemeral `docker run --rm` containers, one per scan, each with a unique task queue and isolated volume mounts. + +- `docker-compose.yml` — Infra only: `shannon-temporal` (port 7233/8233) and `shannon-router` (port 3456, optional via profile). Network: `shannon-net` +- `Dockerfile` — 2-stage build (builder + Chainguard Wolfi runtime). Entrypoint: `CMD ["node", "dist/temporal/worker.js"]` +- No `docker-compose.docker.yml` — host gateway handled via `--add-host` flag in CLI + ### Core Modules - `src/session-manager.ts` — Agent definitions (`AGENTS` record). Agent types in `src/types/agents.ts` - `src/config-parser.ts` — YAML config parsing with JSON Schema validation @@ -55,8 +122,7 @@ Durable workflow orchestration with crash recovery, queryable progress, intellig - `src/temporal/activities.ts` — Thin wrappers — heartbeat loop, error classification, container lifecycle. Business logic delegated to `src/services/` - `src/temporal/activity-logger.ts` — `TemporalActivityLogger` implementation of `ActivityLogger` interface - `src/temporal/summary-mapper.ts` — Maps `PipelineSummary` to `WorkflowSummary` -- `src/temporal/worker.ts` — Worker entry point -- `src/temporal/client.ts` — CLI client for starting workflows +- `src/temporal/worker.ts` — Combined worker + client entry point (per-invocation task queue, submits workflow, waits for result) - `src/temporal/shared.ts` — Types, interfaces, query definitions ### Five-Phase Pipeline @@ -67,12 +133,12 @@ Durable workflow orchestration with crash recovery, queryable progress, intellig 5. **Reporting** (`report`) — Executive-level security report ### Supporting Systems -- **Configuration** — YAML configs in `configs/` with JSON Schema validation (`config-schema.json`). Supports auth settings, MFA/TOTP, and per-app testing parameters +- **Configuration** — YAML configs in `configs/` with JSON Schema validation (`config-schema.json`). Supports auth settings, MFA/TOTP, and per-app testing parameters. Credential resolution — local mode: env vars → `./.env`; npx mode: env vars → `~/.shannon/config.toml` (via `shn setup`) - **Prompts** — Per-phase templates in `prompts/` with variable substitution (`{{TARGET_URL}}`, `{{CONFIG_CONTEXT}}`). Shared partials in `prompts/shared/` via `src/services/prompt-manager.ts` - **SDK Integration** — Uses `@anthropic-ai/claude-agent-sdk` with `maxTurns: 10_000` and `bypassPermissions` mode. Playwright MCP for browser automation, TOTP generation via MCP tool. Login flow template at `prompts/shared/login-instructions.txt` supports form, SSO, API, and basic auth -- **Audit System** — Crash-safe append-only logging in `audit-logs/{hostname}_{sessionId}/`. Tracks session metrics, per-agent logs, prompts, and deliverables. WorkflowLogger (`audit/workflow-logger.ts`) provides unified human-readable per-workflow logs, backed by LogStream (`audit/log-stream.ts`) shared stream primitive +- **Audit System** — Crash-safe append-only logging in `workspaces/{hostname}_{sessionId}/`. Tracks session metrics, per-agent logs, prompts, and deliverables. WorkflowLogger (`audit/workflow-logger.ts`) provides unified human-readable per-workflow logs, backed by LogStream (`audit/log-stream.ts`) shared stream primitive - **Deliverables** — Saved to `deliverables/` in the target repo via the `save_deliverable` MCP tool -- **Workspaces & Resume** — Named workspaces via `WORKSPACE=` or auto-named from URL+timestamp. Resume passes `--workspace` to the Temporal client (`src/temporal/client.ts`), which loads `session.json` to detect completed agents. `loadResumeState()` in `src/temporal/activities.ts` validates deliverable existence, restores git checkpoints, and cleans up incomplete deliverables. Workspace listing via `src/temporal/workspaces.ts` +- **Workspaces & Resume** — Named workspaces via `-w ` or auto-named from URL+timestamp. Resume detects completed agents via `session.json`. `loadResumeState()` in `src/temporal/activities.ts` validates deliverable existence, restores git checkpoints, and cleans up incomplete deliverables. Workspace listing via `src/temporal/workspaces.ts` ## Development Notes @@ -85,7 +151,7 @@ Durable workflow orchestration with crash recovery, queryable progress, intellig ### Modifying Prompts - Variable substitution: `{{TARGET_URL}}`, `{{CONFIG_CONTEXT}}`, `{{LOGIN_INSTRUCTIONS}}` - Shared partials in `prompts/shared/` included via `src/services/prompt-manager.ts` -- Test with `PIPELINE_TESTING=true` for fast iteration +- Test with `--pipeline-testing` for fast iteration ### Key Design Patterns - **Configuration-Driven** — YAML configs with JSON Schema validation @@ -94,6 +160,7 @@ Durable workflow orchestration with crash recovery, queryable progress, intellig - **Modular Error Handling** — `ErrorCode` enum, `Result` for explicit error propagation, automatic retry (3 attempts per agent) - **Services Boundary** — Activities are thin Temporal wrappers; `src/services/` owns business logic, accepts `ActivityLogger`, returns `Result`. No Temporal imports in services - **DI Container** — Per-workflow in `src/services/container.ts`. `AuditSession` excluded (parallel safety) +- **Ephemeral Workers** — Each scan runs in its own `docker run --rm` container with a per-invocation task queue. Temporal routes activities by queue name, so per-scan queues ensure activities never land on a worker with the wrong repo mounted ### Security Defensive security tool only. Use only on systems you own or have explicit permission to test. @@ -142,18 +209,22 @@ Comments must be **timeless** — no references to this conversation, refactorin ## Key Files -**Entry Points:** `src/temporal/workflows.ts`, `src/temporal/activities.ts`, `src/temporal/worker.ts`, `src/temporal/client.ts` +**CLI:** `shannon` (entry point), `cli/src/index.ts` (dispatcher), `cli/src/docker.ts` (orchestration), `cli/src/mode.ts` (auto-detection) + +**Entry Points:** `src/temporal/workflows.ts`, `src/temporal/activities.ts`, `src/temporal/worker.ts` **Core Logic:** `src/session-manager.ts`, `src/ai/claude-executor.ts`, `src/config-parser.ts`, `src/services/`, `src/audit/` -**Config:** `shannon` (CLI), `docker-compose.yml`, `configs/`, `prompts/` +**Config:** `docker-compose.yml`, `cli/infra/compose.yml`, `configs/`, `prompts/`, `tsconfig.base.json` (shared compiler options) + +**CI/CD:** `.github/workflows/release.yml` (semantic-release: npm publish + Docker push + GitHub release), `.github/workflows/rollback.yml` (rollback npm and Docker latest tags) ## Troubleshooting -- **"Repository not found"** — `REPO` must be a folder name inside `./repos/`, not an absolute path. Clone or symlink your repo there first: `ln -s /path/to/repo ./repos/my-repo` +- **"Repository not found"** — Pass a bare name (`-r my-repo`) for `./repos/my-repo`, or a path (`-r /path/to/repo`) for any directory - **"Temporal not ready"** — Wait for health check or `docker compose logs temporal` -- **Worker not processing** — Check `docker compose ps` -- **Reset state** — `./shannon stop CLEAN=true` +- **Worker not processing** — Check `docker ps --filter "name=shannon-worker-"` +- **Reset state** — `./shannon stop --clean` - **Local apps unreachable** — Use `host.docker.internal` instead of `localhost` -- **Missing tools** — Use `PIPELINE_TESTING=true` to skip nmap/subfinder/whatweb (graceful degradation) +- **Missing tools** — Use `--pipeline-testing` to skip nmap/subfinder/whatweb (graceful degradation) - **Container permissions** — On Linux, may need `sudo` for docker commands diff --git a/Dockerfile b/Dockerfile index a78a210..dacff6e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -50,6 +50,17 @@ RUN git clone --depth 1 https://github.com/urbanadventurer/WhatWeb.git /opt/what # Install Python-based tools RUN pip3 install --no-cache-dir schemathesis +# Build Node.js application in builder to avoid QEMU emulation failures in CI +WORKDIR /app +COPY package*.json ./ +COPY cli/package.json ./cli/ +COPY mcp-server/package*.json ./mcp-server/ +RUN npm ci && npm cache clean --force +COPY . . +RUN cd mcp-server && npm run build && cd .. && npm run build +RUN npm prune --production && \ + cd mcp-server && npm prune --production + # Runtime stage - Minimal production image FROM cgr.dev/chainguard/wolfi-base:latest AS runtime @@ -108,29 +119,13 @@ RUN addgroup -g 1001 pentest && \ # Set working directory WORKDIR /app -# Copy package files first for better caching -COPY package*.json ./ -COPY mcp-server/package*.json ./mcp-server/ - -# Install Node.js dependencies (including devDependencies for TypeScript build) -RUN npm ci && \ - cd mcp-server && npm ci && cd .. && \ - npm cache clean --force - -# Copy application source code -COPY . . - -# Build TypeScript (mcp-server first, then main project) -RUN cd mcp-server && npm run build && cd .. && npm run build - -# Remove devDependencies after build to reduce image size -RUN npm prune --production && \ - cd mcp-server && npm prune --production +# Copy built application from builder +COPY --from=builder /app /app RUN npm install -g @anthropic-ai/claude-code # Create directories for session data and ensure proper permissions -RUN mkdir -p /app/sessions /app/deliverables /app/repos /app/configs && \ +RUN mkdir -p /app/sessions /app/deliverables /app/repos /app/configs /app/workspaces && \ mkdir -p /tmp/.cache /tmp/.config /tmp/.npm && \ chmod 777 /app && \ chmod 777 /tmp/.cache && \ @@ -157,5 +152,4 @@ RUN git config --global user.email "agent@localhost" && \ git config --global user.name "Pentest Agent" && \ git config --global --add safe.directory '*' -# Set entrypoint -ENTRYPOINT ["node", "dist/shannon.js"] +CMD ["node", "dist/temporal/worker.js"] diff --git a/README.md b/README.md index 271d5ce..e667fe8 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,8 @@ Shannon Pro supports a self-hosted runner model (similar to GitHub Actions self- - [Product Line](#-product-line) - [Setup & Usage Instructions](#-setup--usage-instructions) - [Prerequisites](#prerequisites) - - [Quick Start](#quick-start) + - [Quick Start (npx)](#quick-start-npx--no-clone-required) + - [Quick Start (Clone & Build)](#quick-start-clone--build) - [Monitoring Progress](#monitoring-progress) - [Stopping Shannon](#stopping-shannon) - [Usage Examples](#usage-examples) @@ -145,6 +146,7 @@ Shannon Pro supports a self-hosted runner model (similar to GitHub Actions self- ### Prerequisites - **Docker** - Container runtime ([Install Docker](https://docs.docker.com/get-docker/)) +- **Node.js 18+** - Required for npx mode ([Install Node.js](https://nodejs.org/)) - **AI Provider Credentials** (choose one): - **Anthropic API key** (recommended) - Get from [Anthropic Console](https://console.anthropic.com) - **Claude Code OAuth token** @@ -152,7 +154,22 @@ Shannon Pro supports a self-hosted runner model (similar to GitHub Actions self- - **Google Vertex AI** - Route through Google Cloud Vertex AI (see [Google Vertex AI](#google-vertex-ai)) - **[EXPERIMENTAL - UNSUPPORTED] Alternative providers via Router Mode** - OpenAI or Google Gemini via OpenRouter (see [Router Mode](#experimental---unsupported-router-mode-alternative-providers)) -### Quick Start +### Quick Start (npx — no clone required) + +```bash +# 1. Configure credentials (interactive wizard — one-time setup) +npx @keygraph/shannon setup + +# Or export env vars directly (non-interactive / CI) +export ANTHROPIC_API_KEY=your-api-key + +# 2. Run a pentest +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. + +### Quick Start (Clone & Build) ```bash # 1. Clone Shannon @@ -161,30 +178,31 @@ cd shannon # 2. Configure credentials (choose one method) -# Option A: Export environment variables -export ANTHROPIC_API_KEY="your-api-key" # or CLAUDE_CODE_OAUTH_TOKEN -export CLAUDE_CODE_MAX_OUTPUT_TOKENS=64000 # recommended - -# Option B: Create a .env file +# Option A: Create a .env file cat > .env << 'EOF' ANTHROPIC_API_KEY=your-api-key CLAUDE_CODE_MAX_OUTPUT_TOKENS=64000 EOF -# 3. Run a pentest -./shannon start URL=https://your-app.com REPO=your-repo +# 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. Build and run a pentest +./shannon build +./shannon start -u https://your-app.com -r your-repo ``` -Shannon will build the containers, start the workflow, and return a workflow ID. The pentest runs in the background. +Shannon will build the worker image locally, start the infrastructure, and launch an ephemeral worker container for the scan. ### Monitoring Progress ```bash -# View real-time worker logs -./shannon logs +# Tail workflow logs +./shannon logs -# Query a specific workflow's progress -./shannon query ID=shannon-1234567890 +# Show running workers +./shannon status # Open the Temporal Web UI for detailed monitoring open http://localhost:8233 @@ -196,27 +214,37 @@ open http://localhost:8233 # Stop all containers (preserves workflow data) ./shannon stop -# Full cleanup (removes all data) -./shannon stop CLEAN=true +# Full cleanup — removes Temporal volumes (confirms first) +./shannon stop --clean + +# npx mode: remove ~/.shannon/ and all data (confirms first) +npx @keygraph/shannon uninstall ``` ### Usage Examples ```bash # Basic pentest -./shannon start URL=https://example.com REPO=repo-name +./shannon start -u https://example.com -r repo-name # With a configuration file -./shannon start URL=https://example.com REPO=repo-name CONFIG=./configs/my-config.yaml +./shannon start -u https://example.com -r repo-name -c ./configs/my-config.yaml # Custom output directory -./shannon start URL=https://example.com REPO=repo-name OUTPUT=./my-reports +./shannon start -u https://example.com -r repo-name -o ./my-reports # Named workspace -./shannon start URL=https://example.com REPO=repo-name WORKSPACE=q1-audit +./shannon start -u https://example.com -r repo-name -w q1-audit + +# Any repo path (not just ./repos/) +./shannon start -u https://example.com -r /path/to/repo # List all workspaces ./shannon workspaces + +# Image management +./shannon build --no-cache # Local mode: rebuild worker image +npx @keygraph/shannon update # npx mode: pull latest image from Docker Hub ``` ### Workspaces and Resuming @@ -224,20 +252,21 @@ open http://localhost:8233 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 in `audit-logs/` (auto-named by default, e.g. `example-com_shannon-1771007534808`) -- Use `WORKSPACE=` to give your run a custom name for easier reference -- To resume any run, pass its workspace name via `WORKSPACE=` — Shannon detects which agents completed successfully and picks up where it left off +- Every run creates a workspace (auto-named by default, e.g. `example-com_shannon-1771007534808`) +- Workspaces are stored in `./workspaces/` (local mode) or `~/.shannon/workspaces/` (npx mode) +- Use `-w ` 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 -./shannon start URL=https://example.com REPO=repo-name WORKSPACE=my-audit +./shannon start -u https://example.com -r repo-name -w my-audit # Resume the same workspace (skips completed agents) -./shannon start URL=https://example.com REPO=repo-name WORKSPACE=my-audit +./shannon start -u https://example.com -r repo-name -w my-audit # Resume an auto-named workspace from a previous run -./shannon start URL=https://example.com REPO=repo-name WORKSPACE=example-com_shannon-1771007534808 +./shannon start -u https://example.com -r repo-name -w example-com_shannon-1771007534808 # List all workspaces and their status ./shannon workspaces @@ -248,7 +277,11 @@ Shannon supports **workspaces** that allow you to resume interrupted or failed r ### Prepare Your Repository -Shannon expects target repositories to be placed under the `./repos/` directory at the project root. The `REPO` flag refers to a folder name inside `./repos/`. Copy the repository you want to scan into `./repos/`, or clone it directly there: +Shannon can scan any repository on your machine. Pass a path with `-r`: +- **Any path**: `-r /path/to/repo` or `-r ./relative/path` — used as-is +- **Bare name** (local mode only): `-r my-repo` resolves to `./repos/my-repo` + +To use the `./repos/` convention, clone your repo there: ```bash git clone https://github.com/your-org/your-repo.git ./repos/your-repo @@ -308,7 +341,7 @@ See [WSL basic commands](https://learn.microsoft.com/en-us/windows/wsl/basic-com git clone https://github.com/KeygraphHQ/shannon.git cd shannon cp .env.example .env # Edit with your API key -./shannon start URL=https://your-app.com REPO=your-repo +./shannon start -u https://your-app.com -r your-repo ``` To access the Temporal Web UI, run `ip addr` inside WSL to find your WSL IP address, then navigate to `http://:8233` in your Windows browser. @@ -328,9 +361,23 @@ Works out of the box with Docker Desktop installed. Docker containers cannot reach `localhost` on your host machine. Use `host.docker.internal` in place of `localhost`: ```bash -./shannon start URL=http://host.docker.internal:3000 REPO=repo-name +./shannon start -u http://host.docker.internal:3000 -r repo-name ``` +### 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. In non-interactive environments (CI/CD), skip `setup` and export variables directly. + ### Configuration (Optional) While you can run without a config file, creating one enables authenticated testing and customized analysis. Place your configuration files inside the `./configs/` directory — this folder is mounted into the Docker container automatically. @@ -413,7 +460,7 @@ ANTHROPIC_LARGE_MODEL=us.anthropic.claude-opus-4-6 2. Run Shannon as usual: ```bash -./shannon start URL=https://example.com REPO=repo-name +./shannon start -u https://example.com -r repo-name ``` 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. @@ -422,7 +469,11 @@ Shannon uses three model tiers: **small** (`claude-haiku-4-5-20251001`) for summ Shannon also supports [Google Vertex AI](https://cloud.google.com/vertex-ai) instead of using an Anthropic API key. -#### Quick Setup +#### Quick Setup (npx — Interactive) + +Run `npx @keygraph/shannon setup` and select **Google Vertex AI**. The wizard will prompt for your region, project ID, and service account key file (with filesystem autocomplete), then securely copy the key to `~/.shannon/google-sa-key.json` and save the configuration. + +#### Quick Setup (Manual) 1. 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. @@ -430,7 +481,7 @@ Shannon also supports [Google Vertex AI](https://cloud.google.com/vertex-ai) ins ```bash mkdir -p ./credentials -cp /path/to/your-sa-key.json ./credentials/gcp-sa-key.json +cp /path/to/your-sa-key.json ./credentials/google-sa-key.json ``` 3. Add your GCP configuration to `.env`: @@ -439,7 +490,7 @@ cp /path/to/your-sa-key.json ./credentials/gcp-sa-key.json CLAUDE_CODE_USE_VERTEX=1 CLOUD_ML_REGION=us-east5 ANTHROPIC_VERTEX_PROJECT_ID=your-gcp-project-id -GOOGLE_APPLICATION_CREDENTIALS=./credentials/gcp-sa-key.json +GOOGLE_APPLICATION_CREDENTIALS=./credentials/google-sa-key.json # Set models with Vertex AI model IDs ANTHROPIC_SMALL_MODEL=claude-haiku-4-5@20251001 @@ -450,7 +501,7 @@ ANTHROPIC_LARGE_MODEL=claude-opus-4-6 4. Run Shannon as usual: ```bash -./shannon start URL=https://example.com REPO=repo-name +./shannon start -u https://example.com -r repo-name ``` 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. @@ -502,10 +553,10 @@ OPENROUTER_API_KEY=sk-or-... ROUTER_DEFAULT=openai,gpt-5.2 # provider,model format ``` -2. Run with `ROUTER=true`: +2. Run with `--router`: ```bash -./shannon start URL=https://example.com REPO=repo-name ROUTER=true +./shannon start -u https://example.com -r repo-name --router ``` #### Experimental Models @@ -521,12 +572,13 @@ This feature is experimental and unsupported. Output quality depends heavily on ### Output and Results -All results are saved to `./audit-logs/{hostname}_{sessionId}/` by default. Use `--output ` to specify a custom directory. +All results are saved to the workspaces directory: `./workspaces/` (local mode) or `~/.shannon/workspaces/` (npx mode). Use `-o ` to copy deliverables to a custom output directory after the run completes. Output structure: ``` -audit-logs/{hostname}_{sessionId}/ +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/ @@ -600,55 +652,70 @@ Shannon Lite scored **96.15% (100/104 exploits)** on a hint-free, source-aware v ## 🏗️ Architecture -Shannon uses a multi-agent architecture that combines white-box source code analysis with dynamic exploitation across four phases: +Shannon uses a multi-agent architecture that combines white-box source code analysis with dynamic exploitation across five phases: ``` - ┌──────────────────────┐ - │ Reconnaissance │ - └──────────┬───────────┘ - │ - ▼ - ┌──────────┴───────────┐ - │ │ │ - ▼ ▼ ▼ - ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ - │ Vuln Analysis │ │ Vuln Analysis │ │ ... │ - │ (Injection) │ │ (XSS) │ │ │ - └─────────┬───────┘ └─────────┬───────┘ └─────────┬───────┘ - │ │ │ - ▼ ▼ ▼ - ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ - │ Exploitation │ │ Exploitation │ │ ... │ - │ (Injection) │ │ (XSS) │ │ │ - └─────────┬───────┘ └─────────┬───────┘ └─────────┬───────┘ - │ │ │ - └─────────┬─────────┴───────────────────┘ - │ - ▼ - ┌──────────────────────┐ - │ Reporting │ - └──────────────────────┘ + ┌──────────────────────┐ + │ Pre-Reconnaissance │ + │ (nmap, subfinder, │ + │ whatweb, code scan) │ + └──────────┬───────────┘ + │ + ▼ + ┌──────────────────────┐ + │ Reconnaissance │ + │ (attack surface │ + │ mapping) │ + └──────────┬───────────┘ + │ + ▼ + ┌──────────┴───────────┐ + │ │ │ + ▼ ▼ ▼ + ┌───────────┐ ┌───────────┐ ┌───────────┐ + │ Vuln │ │ Vuln │ │ ... │ + │(Injection)│ │ (XSS) │ │ │ + └─────┬─────┘ └─────┬─────┘ └─────┬─────┘ + │ │ │ + ▼ ▼ ▼ + ┌───────────┐ ┌───────────┐ ┌───────────┐ + │ Exploit │ │ Exploit │ │ ... │ + │(Injection)│ │ (XSS) │ │ │ + └─────┬─────┘ └─────┬─────┘ └─────┬─────┘ + │ │ │ + └──────┬───────┴─────────────┘ + │ + ▼ + ┌──────────────────────┐ + │ Reporting │ + └──────────────────────┘ ``` ### Architectural Overview -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 four phases. The architecture is designed for minimal false positives through a "no exploit, no report" policy. +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. + +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. --- -#### **Phase 1: Reconnaissance** +#### **Phase 1: Pre-Reconnaissance** -The first phase builds a comprehensive map of the application's attack surface. Shannon analyzes the source code and integrates with tools like Nmap and Subfinder to understand the tech stack and infrastructure. Simultaneously, it 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 for the next phase. +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. -#### **Phase 2: Vulnerability Analysis** +#### **Phase 2: Reconnaissance** -To maximize efficiency, this phase operates in parallel. Using the reconnaissance data, specialized agents for each OWASP category 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. +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. -#### **Phase 3: Exploitation** +#### **Phase 3: Vulnerability Analysis** + +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. + +#### **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 4: Reporting** +#### **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. diff --git a/cli/.npmignore b/cli/.npmignore new file mode 100644 index 0000000..997effe --- /dev/null +++ b/cli/.npmignore @@ -0,0 +1,3 @@ +src/ +tsconfig.json +node_modules/ diff --git a/cli/infra/compose.yml b/cli/infra/compose.yml new file mode 100644 index 0000000..df8fbde --- /dev/null +++ b/cli/infra/compose.yml @@ -0,0 +1,50 @@ +networks: + default: + name: shannon-net + +services: + temporal: + image: temporalio/temporal:latest + container_name: shannon-temporal + command: ["server", "start-dev", "--db-filename", "/home/temporal/temporal.db", "--ip", "0.0.0.0"] + ports: + - "127.0.0.1:7233:7233" + - "127.0.0.1:8233:8233" + volumes: + - temporal-data:/home/temporal + healthcheck: + test: ["CMD", "temporal", "operator", "cluster", "health", "--address", "localhost:7233"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 30s + + router: + image: node:20-slim + container_name: shannon-router + profiles: ["router"] + command: > + sh -c "apt-get update && apt-get install -y gettext-base && + npm install -g @musistudio/claude-code-router && + mkdir -p /root/.claude-code-router && + envsubst < /config/router-config.json > /root/.claude-code-router/config.json && + ccr start" + ports: + - "127.0.0.1:3456:3456" + volumes: + - ./router-config.json:/config/router-config.json:ro + environment: + - HOST=0.0.0.0 + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-} + - OPENAI_API_KEY=${OPENAI_API_KEY:-} + - OPENROUTER_API_KEY=${OPENROUTER_API_KEY:-} + - ROUTER_DEFAULT=${ROUTER_DEFAULT:-openai,gpt-4o} + healthcheck: + test: ["CMD", "node", "-e", "require('http').get('http://localhost:3456/health', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + +volumes: + temporal-data: diff --git a/cli/infra/router-config.json b/cli/infra/router-config.json new file mode 100644 index 0000000..cf57b1e --- /dev/null +++ b/cli/infra/router-config.json @@ -0,0 +1,33 @@ +{ + "HOST": "0.0.0.0", + "APIKEY": "shannon-router-key", + "LOG": true, + "LOG_LEVEL": "info", + "NON_INTERACTIVE_MODE": true, + "API_TIMEOUT_MS": 600000, + "Providers": [ + { + "name": "openai", + "api_base_url": "https://api.openai.com/v1/chat/completions", + "api_key": "$OPENAI_API_KEY", + "models": ["gpt-5.2", "gpt-5-mini"], + "transformer": { + "use": [["maxcompletiontokens", { "max_completion_tokens": 16384 }]] + } + }, + { + "name": "openrouter", + "api_base_url": "https://openrouter.ai/api/v1/chat/completions", + "api_key": "$OPENROUTER_API_KEY", + "models": [ + "google/gemini-3-flash-preview" + ], + "transformer": { + "use": ["openrouter"] + } + } + ], + "Router": { + "default": "$ROUTER_DEFAULT" + } +} diff --git a/cli/package.json b/cli/package.json new file mode 100644 index 0000000..7583dca --- /dev/null +++ b/cli/package.json @@ -0,0 +1,45 @@ +{ + "name": "@keygraph/shannon", + "version": "0.0.0", + "description": "Shannon - Autonomous white-box AI pentester for web applications and APIs by Keygraph", + "type": "module", + "main": "dist/index.js", + "bin": { + "shannon": "dist/index.js" + }, + "files": [ + "dist", + "infra" + ], + "scripts": { + "build": "tsc", + "dev": "tsc --watch" + }, + "dependencies": { + "@clack/prompts": "^1.1.0", + "dotenv": "^17.3.1", + "smol-toml": "^1.6.0" + }, + "keywords": [ + "security", + "pentest", + "penetration-testing", + "vulnerability-assessment", + "ai", + "white-box", + "owasp", + "exploitation", + "appsec", + "keygraph" + ], + "author": "", + "license": "AGPL-3.0-only", + "repository": { + "type": "git", + "url": "git+https://github.com/KeygraphHQ/shannon.git", + "directory": "cli" + }, + "engines": { + "node": ">=18" + } +} diff --git a/cli/src/commands/build.ts b/cli/src/commands/build.ts new file mode 100644 index 0000000..dfbb8bd --- /dev/null +++ b/cli/src/commands/build.ts @@ -0,0 +1,19 @@ +/** + * `shannon build` command — build the worker Docker image locally. + * Only available in local mode (running from cloned repository). + */ + +import { isLocal } from '../mode.js'; +import { buildImage } from '../docker.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); + } + + buildImage(noCache); +} diff --git a/cli/src/commands/logs.ts b/cli/src/commands/logs.ts new file mode 100644 index 0000000..1e8bdd2 --- /dev/null +++ b/cli/src/commands/logs.ts @@ -0,0 +1,75 @@ +/** + * `shannon logs` command — tail a workspace's workflow log. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { getWorkspacesDir } from '../home.js'; + +export function logs(workspaceId: string): void { + const workspacesDir = getWorkspacesDir(); + let logFile = ''; + + // 1. Direct match + const directPath = path.join(workspacesDir, workspaceId, 'workflow.log'); + if (fs.existsSync(directPath)) { + logFile = directPath; + } + + // 2. Resume workflow ID (e.g. workspace_resume_123) + if (!logFile) { + const base = workspaceId.replace(/_resume_\d+$/, ''); + if (base !== workspaceId) { + const resumePath = path.join(workspacesDir, base, 'workflow.log'); + if (fs.existsSync(resumePath)) { + logFile = resumePath; + } + } + } + + // 3. Named workspace ID (e.g. workspace_shannon-123) + if (!logFile) { + const base = workspaceId.replace(/_shannon-\d+$/, ''); + if (base !== workspaceId) { + const namedPath = path.join(workspacesDir, base, 'workflow.log'); + if (fs.existsSync(namedPath)) { + logFile = namedPath; + } + } + } + + if (!logFile) { + 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); + } + + console.log(`Tailing workflow log: ${logFile}`); + + // Stream existing content, then watch for new lines + const stream = fs.createReadStream(logFile, { encoding: 'utf-8' }); + stream.pipe(process.stdout); + + stream.on('end', () => { + // Switch to watching for appended content + let position = fs.statSync(logFile).size; + const watcher = fs.watch(logFile, () => { + const stat = fs.statSync(logFile); + if (stat.size <= position) return; + + const chunk = fs.createReadStream(logFile, { start: position, encoding: 'utf-8' }); + chunk.pipe(process.stdout, { end: false }); + position = stat.size; + }); + + process.on('SIGINT', () => { + watcher.close(); + process.exit(0); + }); + }); +} diff --git a/cli/src/commands/setup.ts b/cli/src/commands/setup.ts new file mode 100644 index 0000000..da57fc7 --- /dev/null +++ b/cli/src/commands/setup.ts @@ -0,0 +1,249 @@ +/** + * `shn 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. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import * as p from '@clack/prompts'; +import { type ShannonConfig, saveConfig } from '../config/writer.js'; + +const SHANNON_HOME = path.join(os.homedir(), '.shannon'); + +type Provider = 'anthropic' | 'bedrock' | 'vertex' | 'router'; + +export async function setup(): Promise { + p.intro('Shannon Setup'); + + // 1. Select provider + const provider = await p.select({ + message: 'Select your AI provider', + options: [ + { value: 'anthropic' as const, label: 'Claude Direct', hint: 'recommended' }, + { value: 'bedrock' as const, label: 'Claude via AWS Bedrock' }, + { value: 'vertex' as const, label: 'Claude via Google Vertex AI' }, + { value: 'router' as const, label: 'Router', hint: 'experimental' }, + ], + }); + if (p.isCancel(provider)) return cancelAndExit(); + + const config = await setupProvider(provider as Provider); + + // 2. Save config + saveConfig(config); + + const configPath = path.join(SHANNON_HOME, 'config.toml'); + p.log.success(`Configuration saved to ${configPath}`); + p.outro('Run `npx @keygraph/shannon start` to begin a scan.'); +} + +async function setupProvider(provider: Provider): Promise { + switch (provider) { + case 'anthropic': return setupAnthropic(); + case 'bedrock': return setupBedrock(); + case 'vertex': return setupVertex(); + case 'router': return setupRouter(); + } +} + +// === Provider Setup Flows === + +async function setupAnthropic(): Promise { + const authMethod = await p.select({ + message: 'Authentication method', + options: [ + { value: 'api_key' as const, label: 'API Key' }, + { value: 'oauth' as const, label: 'OAuth Token' }, + ], + }); + if (p.isCancel(authMethod)) return cancelAndExit(); + + if (authMethod === 'oauth') { + const token = await promptSecret('Enter your OAuth token'); + return { anthropic: { oauth_token: token } }; + } + + const apiKey = await promptSecret('Enter your Anthropic API key'); + return { anthropic: { api_key: apiKey } }; +} + +async function setupBedrock(): Promise { + const results = await p.group({ + region: () => + p.text({ + message: 'AWS Region', + placeholder: 'us-east-1', + validate: required('AWS Region is required'), + }), + token: () => + p.password({ + message: 'AWS Bearer Token', + validate: required('Bearer token is required'), + }), + small: () => + p.text({ + message: 'Small model ID', + placeholder: 'us.anthropic.claude-haiku-4-5-20251001-v1:0', + validate: required('Small model ID is required'), + }), + medium: () => + p.text({ + message: 'Medium model ID', + placeholder: 'us.anthropic.claude-sonnet-4-6', + validate: required('Medium model ID is required'), + }), + large: () => + p.text({ + message: 'Large model ID', + placeholder: 'us.anthropic.claude-opus-4-6', + validate: required('Large model ID is required'), + }), + }); + if (p.isCancel(results)) return cancelAndExit(); + + return { + bedrock: { use: true, region: results.region, token: results.token }, + models: { small: results.small, medium: results.medium, large: results.large }, + }; +} + +async function setupVertex(): Promise { + // 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(); + + const projectId = await p.text({ + message: 'GCP Project ID', + validate: required('Project ID is required'), + }); + if (p.isCancel(projectId)) 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', + 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'; + return undefined; + }, + }); + if (p.isCancel(keySourcePath)) 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)`); + + // 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'), + }), + }); + if (p.isCancel(models)) return cancelAndExit(); + + return { + vertex: { + use: true, + region, + project_id: projectId, + key_path: destPath, + }, + models: { small: models.small, medium: models.medium, large: models.large }, + }; +} + +async function setupRouter(): Promise { + const routerProvider = await p.select({ + message: 'Router provider', + options: [ + { value: 'openai' as const, label: 'OpenAI' }, + { value: 'openrouter' as const, label: 'OpenRouter' }, + ], + }); + if (p.isCancel(routerProvider)) return cancelAndExit(); + + const apiKey = await promptSecret( + routerProvider === 'openai' ? 'Enter your OpenAI API key' : 'Enter your OpenRouter API key' + ); + + let defaultModel: string; + if (routerProvider === 'openai') { + const model = await p.select({ + message: 'Default model', + options: [ + { value: 'gpt-5.2' as const, label: 'GPT-5.2' }, + { value: 'gpt-5-mini' as const, label: 'GPT-5 Mini' }, + ], + }); + if (p.isCancel(model)) return cancelAndExit(); + defaultModel = `openai,${model}`; + } else { + const model = await p.select({ + message: 'Default model', + options: [ + { value: 'google/gemini-3-flash-preview' as const, label: 'Google Gemini 3 Flash Preview' }, + ], + }); + if (p.isCancel(model)) return cancelAndExit(); + defaultModel = `openrouter,${model}`; + } + + const router: ShannonConfig['router'] = { default: defaultModel }; + if (routerProvider === 'openai') { + router!.openai_key = apiKey; + } else { + router!.openrouter_key = apiKey; + } + + return { router }; +} + +// === Helpers === + +async function promptSecret(message: string): Promise { + const value = await p.password({ + message, + validate: required(`${message.replace(/^Enter /, '')} is required`), + }); + if (p.isCancel(value)) return cancelAndExit(); + return value; +} + +function required(errorMessage: string): (value: string | undefined) => string | undefined { + return (value) => { + if (!value) return errorMessage; + return undefined; + }; +} + +function cancelAndExit(): never { + p.cancel('Setup cancelled.'); + process.exit(0); +} diff --git a/cli/src/commands/start.ts b/cli/src/commands/start.ts new file mode 100644 index 0000000..85a1cf4 --- /dev/null +++ b/cli/src/commands/start.ts @@ -0,0 +1,208 @@ +/** + * `shannon start` command — launch a pentest scan. + * + * Handles both local mode (local build, ./workspaces/, mounted prompts) + * and npx mode (Docker Hub pull, ~/.shannon/). + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { isLocal } from '../mode.js'; +import { initHome, getWorkspacesDir, getCredentialsPath, getCredentialsDir } from '../home.js'; +import { loadEnv, buildEnvFlags, validateCredentials, isRouterConfigured } from '../env.js'; +import { resolveRepo, resolveConfig, ensureDeliverables } from '../paths.js'; +import { ensureInfra, ensureImage, spawnWorker, randomSuffix } from '../docker.js'; +import { displaySplash } from '../splash.js'; + +export interface StartArgs { + url: string; + repo: string; + config?: string; + workspace?: string; + output?: string; + pipelineTesting: boolean; + router: boolean; + version: string; +} + +export function start(args: StartArgs): void { + // 1. Initialize state directories and load env + initHome(); + loadEnv(); + + // 2. Validate credentials and auto-detect router mode + const creds = validateCredentials(); + if (!creds.valid) { + console.error(`ERROR: ${creds.error}`); + process.exit(1); + } + const useRouter = args.router || isRouterConfigured(); + + // 3. Resolve paths + const repo = resolveRepo(args.repo); + const config = args.config ? resolveConfig(args.config) : undefined; + ensureDeliverables(repo.hostPath); + + // 4. Ensure workspaces dir is writable by container user (UID 1001) + const workspacesDir = getWorkspacesDir(); + fs.mkdirSync(workspacesDir, { recursive: true }); + fs.chmodSync(workspacesDir, 0o777); + + // 5. Handle router env + if (useRouter) { + process.env.ANTHROPIC_BASE_URL = 'http://shannon-router:3456'; + process.env.ANTHROPIC_AUTH_TOKEN = 'shannon-router-key'; + } + + // 6. Ensure image (auto-build in dev, pull in npx) and start infra + ensureImage(args.version); + ensureInfra(useRouter); + + // 7. Generate unique task queue and container name + const suffix = randomSuffix(); + const taskQueue = `shannon-${suffix}`; + const containerName = `shannon-worker-${suffix}`; + + // 8. Generate workspace name if not provided + let workspace = args.workspace; + if (!workspace) { + const hostname = new URL(args.url).hostname.replace(/[^a-zA-Z0-9-]/g, '-'); + workspace = `${hostname}_shannon-${Date.now()}`; + } + + // 9. Resolve credentials + const credentialsDir = getCredentialsDir(); + const credentialsPath = getCredentialsPath(); + const hasCredentials = !credentialsDir && fs.existsSync(credentialsPath); + + // 10. Resolve output directory + const outputDir = args.output ? path.resolve(args.output) : undefined; + if (outputDir) { + fs.mkdirSync(outputDir, { recursive: true }); + } + + // 11. Resolve prompts directory (local mode only) + const promptsDir = isLocal() ? path.resolve('prompts') : undefined; + + // 12. Display splash screen + displaySplash(isLocal() ? undefined : args.version); + + // 13. Spawn worker container + const proc = spawnWorker({ + version: args.version, + url: args.url, + repo, + workspacesDir, + taskQueue, + containerName, + envFlags: buildEnvFlags(), + ...(config && { config }), + ...(credentialsDir && { credentialsDir }), + ...(hasCredentials && { credentials: credentialsPath }), + ...(promptsDir && { promptsDir }), + ...(outputDir && { outputDir }), + ...(workspace && { workspace }), + ...(args.pipelineTesting && { pipelineTesting: true }), + }); + + // 14. Wait for workflow.log to appear, then display info + const workflowLog = path.join(workspacesDir, workspace, 'workflow.log'); + + proc.on('error', (err) => { + console.error(`Failed to start worker: ${err.message}`); + process.exit(1); + }); + + // Poll for workflow.log header + 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 content = fs.readFileSync(workflowLog, 'utf-8'); + if (content.includes('====')) { + clearInterval(pollInterval); + started = true; + + // Extract workflow ID + const match = /^Workflow ID: (.+)$/m.exec(content); + if (match?.[1]) { + workflowId = match[1]; + } + + // Clear waiting line and show info + process.stdout.write('\r\x1b[K'); + printInfo(args, useRouter, 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 + let cleaned = false; + const cleanup = (): void => { + if (cleaned || started) return; + cleaned = true; + clearInterval(pollInterval); + console.log(`\nStopping worker ${containerName}...`); + try { + execFileSync('docker', ['stop', containerName], { stdio: 'pipe' }); + } catch { + // Container may have already exited + } + }; + + process.on('SIGINT', () => { cleanup(); process.exit(0); }); + process.on('SIGTERM', () => { cleanup(); process.exit(0); }); + process.on('exit', cleanup); +} + +function printInfo( + args: StartArgs, + routerActive: boolean, + 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); + + console.log(` Target: ${args.url}`); + console.log(` Repository: ${repoPath}`); + console.log(` Workspace: ${workspace}`); + if (args.config) { + console.log(` Config: ${path.resolve(args.config)}`); + } + if (args.pipelineTesting) { + console.log(' Mode: Pipeline Testing'); + } + if (routerActive) { + console.log(' Router: Enabled'); + } + 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'); + } + console.log(` Logs: ${logsCmd}`); + console.log(''); + console.log(' Output:'); + console.log(` Reports: ${reportsPath}/`); + console.log(''); +} diff --git a/cli/src/commands/status.ts b/cli/src/commands/status.ts new file mode 100644 index 0000000..724ae49 --- /dev/null +++ b/cli/src/commands/status.ts @@ -0,0 +1,24 @@ +/** + * `shannon status` command — show running workers and Temporal health. + */ + +import { isTemporalReady, listRunningWorkers } from '../docker.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'); + } + console.log(''); + + // 2. Running workers + const workers = listRunningWorkers(); + if (workers) { + console.log('Workers:'); + console.log(workers); + } else { + console.log('Workers: none running'); + } +} diff --git a/cli/src/commands/stop.ts b/cli/src/commands/stop.ts new file mode 100644 index 0000000..4460333 --- /dev/null +++ b/cli/src/commands/stop.ts @@ -0,0 +1,21 @@ +/** + * `shannon stop` command — stop workers and infrastructure. + */ + +import * as p from '@clack/prompts'; +import { stopWorkers, stopInfra } from '../docker.js'; + +export async function stop(clean: boolean): Promise { + 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); + } + } + + stopWorkers(); + stopInfra(clean); +} diff --git a/cli/src/commands/uninstall.ts b/cli/src/commands/uninstall.ts new file mode 100644 index 0000000..26aa9a2 --- /dev/null +++ b/cli/src/commands/uninstall.ts @@ -0,0 +1,37 @@ +/** + * `shn uninstall` command — remove ~/.shannon/ after confirmation (npx only). + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import * as p from '@clack/prompts'; +import { stopWorkers, stopInfra } from '../docker.js'; + +const SHANNON_HOME = path.join(os.homedir(), '.shannon'); + +export async function uninstall(): Promise { + 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.'); +} diff --git a/cli/src/commands/update.ts b/cli/src/commands/update.ts new file mode 100644 index 0000000..5a17010 --- /dev/null +++ b/cli/src/commands/update.ts @@ -0,0 +1,10 @@ +/** + * `shannon update` command — pull the worker image matching the current CLI version. + */ + +import { pullImage } from '../docker.js'; + +export function update(version: string): void { + pullImage(version); + console.log('Update complete.'); +} diff --git a/cli/src/commands/workspaces.ts b/cli/src/commands/workspaces.ts new file mode 100644 index 0000000..b5e5eb7 --- /dev/null +++ b/cli/src/commands/workspaces.ts @@ -0,0 +1,26 @@ +/** + * `shannon workspaces` command — list all workspaces. + */ + +import { execFileSync } from 'node:child_process'; +import { getWorkspacesDir } from '../home.js'; +import { getWorkerImage } from '../docker.js'; + +export function workspaces(version: string): void { + const workspacesDir = getWorkspacesDir(); + const image = getWorkerImage(version); + + try { + execFileSync('docker', [ + 'run', '--rm', + '-v', `${workspacesDir}:/app/workspaces`, + '-e', 'WORKSPACES_DIR=/app/workspaces', + image, + 'node', 'dist/temporal/workspaces.js', + ], { stdio: 'inherit' }); + } catch { + console.error('ERROR: Failed to list workspaces. Is the Docker image available?'); + console.error(` Run: docker pull ${image}`); + process.exit(1); + } +} diff --git a/cli/src/config/resolver.ts b/cli/src/config/resolver.ts new file mode 100644 index 0000000..e1a889a --- /dev/null +++ b/cli/src/config/resolver.ts @@ -0,0 +1,294 @@ +/** + * Configuration resolver with environment-first, TOML-fallback precedence. + * + * Priority: process.env > ~/.shannon/config.toml + * Env var names match .env.example exactly; TOML uses nested sections. + */ + +import fs from 'node:fs'; +import { parse as parseTOML } from 'smol-toml'; +import { getConfigFile } from '../home.js'; +import { getMode } from '../mode.js'; + +// === TOML ↔ Env Mapping === + +type TOMLType = 'string' | 'number' | 'boolean'; + +interface ConfigMapping { + readonly env: string; + readonly toml: string; + readonly type: TOMLType; +} + +/** 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' }, + + // Anthropic + { env: 'ANTHROPIC_API_KEY', toml: 'anthropic.api_key', type: 'string' }, + { env: 'CLAUDE_CODE_OAUTH_TOKEN', toml: 'anthropic.oauth_token', 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' }, + + // Router + { env: 'ROUTER_DEFAULT', toml: 'router.default', type: 'string' }, + { env: 'OPENAI_API_KEY', toml: 'router.openai_key', type: 'string' }, + { env: 'OPENROUTER_API_KEY', toml: 'router.openrouter_key', 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' }, +] as const; + +// === TOML Parsing === + +type TOMLValue = string | number | boolean; +type TOMLSection = Record; +type TOMLConfig = Record; + +/** 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('.'); + if (!section || !key) return undefined; + + const sectionObj = config[section]; + if (!sectionObj || typeof sectionObj !== 'object') return 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'; + + return String(value); +} + +/** Parse the global TOML config file, returning null if it doesn't exist. */ +function loadTOML(): TOMLConfig | null { + const configPath = getConfigFile(); + if (!fs.existsSync(configPath)) return null; + + // Config contains secrets — refuse to read if group or others have any access + 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); + } + + try { + const content = fs.readFileSync(configPath, 'utf-8'); + 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); + } +} + +// === Validation === + +/** Build a lookup of allowed keys per section from CONFIG_MAP. */ +function buildSchema(): Map> { + const schema = new Map>(); + for (const mapping of CONFIG_MAP) { + const [section, key] = mapping.toml.split('.'); + if (!section || !key) continue; + + let keys = schema.get(section); + if (!keys) { + keys = new Map(); + schema.set(section, keys); + } + keys.set(key, mapping.type); + } + 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 | 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 '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; + } + + case 'router': { + if (!keys.includes('default')) { + errors.push('[router] missing required key: default'); + } + if (!keys.includes('openai_key') && !keys.includes('openrouter_key')) { + errors.push('[router] requires either openai_key or openrouter_key'); + } + const models = config.models as Record | undefined; + if (models && typeof models === 'object' && Object.keys(models).length > 0) { + errors.push('[models] is not supported with [router]'); + } + 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 | 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(', ')}`); + } +} + +/** + * Validate a parsed TOML config against the known schema. + * Returns an array of human-readable error messages (empty = valid). + */ +function validateConfig(config: TOMLConfig): string[] { + const schema = buildSchema(); + const errors: string[] = []; + + for (const [section, sectionObj] of Object.entries(config)) { + // 1. Reject unknown sections + const allowedKeys = schema.get(section); + if (!allowedKeys) { + const known = [...schema.keys()].join(', '); + errors.push(`Unknown section [${section}]. Valid sections: ${known}`); + continue; + } + + // 2. Section value must be a table + if (!sectionObj || typeof sectionObj !== 'object') { + errors.push(`[${section}] must be a table, got ${typeof sectionObj}`); + continue; + } + + // 3. Validate each key in the section + for (const [key, value] of Object.entries(sectionObj as Record)) { + const expectedType = allowedKeys.get(key); + if (!expectedType) { + const known = [...allowedKeys.keys()].join(', '); + errors.push(`Unknown key "${key}" in [${section}]. Valid keys: ${known}`); + continue; + } + + if (typeof value !== expectedType) { + errors.push( + `[${section}].${key} must be ${expectedType}, got ${typeof value}` + ); + continue; + } + + // Reject empty strings — they pass type checks but are never useful + if (typeof value === 'string' && value.trim() === '') { + errors.push(`[${section}].${key} must not be empty`); + } + } + } + + // 4. Only one provider section allowed (ignore empty sections) + const PROVIDER_SECTIONS = ['anthropic', 'bedrock', 'vertex', 'router'] 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` + ); + } + + // 5. Required fields per provider + const singleProvider = present.length === 1 ? present[0] : undefined; + if (singleProvider) { + validateProviderFields(config, singleProvider, errors); + } + + return errors; +} + +// === Public API === + +/** + * Resolve all config values into process.env (npx mode only). + * + * For each mapped variable: if not already set in the environment, + * look it up in ~/.shannon/config.toml and inject it into process.env. + * Local mode uses .env exclusively — TOML is skipped. + * Exits with an error if the TOML contains unknown or invalid keys. + */ +export function resolveConfig(): void { + if (getMode() === 'local') return; + + const toml = loadTOML(); + if (!toml) return; + + // 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); + } + + for (const mapping of CONFIG_MAP) { + if (process.env[mapping.env]) continue; + + const value = getTomlValue(toml, mapping.toml); + if (value) { + process.env[mapping.env] = value; + } + } +} diff --git a/cli/src/config/writer.ts b/cli/src/config/writer.ts new file mode 100644 index 0000000..efabea4 --- /dev/null +++ b/cli/src/config/writer.ts @@ -0,0 +1,29 @@ +/** TOML config writer for ~/.shannon/config.toml. */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { stringify } from 'smol-toml'; +import { getConfigFile } from '../home.js'; + +// === Types === + +export interface ShannonConfig { + core?: { max_tokens?: number }; + anthropic?: { api_key?: string; oauth_token?: string }; + bedrock?: { use?: boolean; region?: string; token?: string }; + vertex?: { use?: boolean; region?: string; project_id?: string; key_path?: string }; + router?: { default?: string; openai_key?: string; openrouter_key?: string }; + models?: { small?: string; medium?: string; large?: string }; +} + +// === File Operations === + +/** Write the config to ~/.shannon/config.toml with 0o600 permissions. */ +export function saveConfig(config: ShannonConfig): void { + const configPath = getConfigFile(); + const dir = path.dirname(configPath); + fs.mkdirSync(dir, { recursive: true }); + + const content = stringify(config); + fs.writeFileSync(configPath, content, { mode: 0o600 }); +} diff --git a/cli/src/docker.ts b/cli/src/docker.ts new file mode 100644 index 0000000..4905431 --- /dev/null +++ b/cli/src/docker.ts @@ -0,0 +1,285 @@ +/** + * Docker orchestration — compose lifecycle, network, image pull/build, worker spawning. + * + * Local mode: builds locally, uses docker-compose.yml from repo root, mounts prompts. + * NPX mode: pulls from Docker Hub, uses bundled compose.yml. + */ + +import { execFileSync, spawn, type ChildProcess } from 'node:child_process'; +import path from 'node:path'; +import os from 'node:os'; +import crypto from 'node:crypto'; +import { fileURLToPath } from 'node:url'; +import { getMode } from './mode.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const NPX_IMAGE_REPO = 'keygraph/shannon'; +const DEV_IMAGE = 'shannon-worker'; + +export function getWorkerImage(version: string): string { + return getMode() === 'local' ? DEV_IMAGE : `${NPX_IMAGE_REPO}:${version}`; +} + +function getComposeFile(): string { + return getMode() === 'local' + ? path.resolve('docker-compose.yml') + : path.resolve(__dirname, '..', 'infra', 'compose.yml'); +} + +/** Generate an 8-char random hex suffix for container/queue names. */ +export function randomSuffix(): string { + return crypto.randomBytes(4).toString('hex'); +} + +/** Run a command silently, return true if it succeeds. */ +function runQuiet(cmd: string, args: string[]): boolean { + try { + execFileSync(cmd, args, { stdio: 'pipe' }); + return true; + } catch { + return false; + } +} + +/** Run a command and return stdout, or empty string on failure. */ +function runOutput(cmd: string, args: string[]): string { + try { + return execFileSync(cmd, args, { stdio: 'pipe', encoding: 'utf-8' }).trim(); + } catch { + return ''; + } +} + +/** + * 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', + ]); + return output.includes('SERVING'); +} + +/** + * Ensure Temporal (and optionally router) are running via compose. + */ +export function ensureInfra(useRouter: boolean): void { + if (isTemporalReady()) { + return; + } + + const composeFile = getComposeFile(); + console.log('Starting Shannon infrastructure...'); + const composeArgs = ['compose', '-f', composeFile]; + if (useRouter) composeArgs.push('--profile', 'router'); + composeArgs.push('up', '-d'); + execFileSync('docker', composeArgs, { stdio: 'inherit' }); + + console.log('Waiting for Temporal to be ready...'); + for (let i = 0; i < 30; i++) { + if (isTemporalReady()) { + console.log('Temporal is ready!'); + return; + } + execFileSync('sleep', ['2']); + } + console.error('Timeout waiting for Temporal'); + process.exit(1); +} + +/** + * Build the worker image locally (local mode only). + */ +export function buildImage(noCache: boolean): void { + console.log(`Building ${DEV_IMAGE}...`); + const args = ['build']; + if (noCache) args.push('--no-cache'); + args.push('-t', DEV_IMAGE, '.'); + execFileSync('docker', args, { stdio: 'inherit' }); + console.log(`Build complete: ${DEV_IMAGE}`); +} + +/** + * Ensure the worker image is available. + * Local mode: auto-builds if missing. NPX mode: 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); + } 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); + } + pruneOldImages(version); + } +} + +/** + * Detect if --add-host is needed (Linux without Podman). + * macOS has host.docker.internal built in. + */ +function addHostFlag(): string[] { + if (os.platform() === 'linux') { + const hasPodman = runQuiet('which', ['podman']); + if (!hasPodman) { + return ['--add-host', 'host.docker.internal:host-gateway']; + } + } + return []; +} + +export interface WorkerOptions { + version: string; + url: string; + repo: { hostPath: string; containerPath: string }; + workspacesDir: string; + taskQueue: string; + containerName: string; + envFlags: string[]; + config?: { hostPath: string; containerPath: string }; + credentials?: string; + credentialsDir?: string; + promptsDir?: string; + outputDir?: string; + workspace?: string; + pipelineTesting?: boolean; +} + +/** + * Spawn the worker container in detached mode and return the process. + */ +export function spawnWorker(opts: WorkerOptions): ChildProcess { + const args = ['run', '-d', '--rm', '--name', opts.containerName, '--network', 'shannon-net']; + + // Add host flag for Linux + args.push(...addHostFlag()); + + // Volume mounts + args.push('-v', `${opts.workspacesDir}:/app/workspaces`); + args.push('-v', `${opts.repo.hostPath}:${opts.repo.containerPath}`); + + // Local mode: mount prompts for live editing + if (opts.promptsDir) { + args.push('-v', `${opts.promptsDir}:/app/prompts:ro`); + } + + if (opts.config) { + args.push('-v', `${opts.config.hostPath}:${opts.config.containerPath}:ro`); + } + + // Output directory for deliverables copy + if (opts.outputDir) { + args.push('-v', `${opts.outputDir}:/app/output`); + } + + // Local mode: mount entire credentials directory. NPX mode: single file. + if (opts.credentialsDir) { + args.push('-v', `${opts.credentialsDir}:/app/credentials:ro`); + } else if (opts.credentials) { + args.push('-v', `${opts.credentials}:/app/credentials/google-sa-key.json:ro`); + } + + // Environment + args.push(...opts.envFlags); + + // Container settings + args.push('--shm-size', '2gb', '--ipc', 'host', '--security-opt', 'seccomp=unconfined'); + + // Image + args.push(getWorkerImage(opts.version)); + + // Worker command + args.push('node', 'dist/temporal/worker.js', opts.url, opts.repo.containerPath); + args.push('--task-queue', opts.taskQueue); + if (opts.config) { + args.push('--config', opts.config.containerPath); + } + if (opts.outputDir) { + args.push('--output', '/app/output'); + } + if (opts.workspace) { + args.push('--workspace', opts.workspace); + } + if (opts.pipelineTesting) { + args.push('--pipeline-testing'); + } + + return spawn('docker', args, { stdio: 'pipe' }); +} + +/** + * Stop all running shannon-worker-* containers. + */ +export function stopWorkers(): void { + const workers = runOutput('docker', ['ps', '-q', '--filter', 'name=shannon-worker-']); + if (!workers) return; + + const ids = workers.split('\n').filter(Boolean); + console.log('Stopping worker containers...'); + execFileSync('docker', ['stop', ...ids], { stdio: 'inherit' }); +} + +/** + * Tear down the compose stack. + */ +export function stopInfra(clean: boolean): void { + const composeFile = getComposeFile(); + const args = ['compose', '-f', composeFile, '--profile', 'router', 'down']; + if (clean) args.push('-v'); + execFileSync('docker', args, { stdio: 'inherit' }); +} + +/** + * Pull the worker image matching the current CLI version. + */ +export function pullImage(version: string): void { + const image = getWorkerImage(version); + console.log(`Pulling ${image}...`); + try { + execFileSync('docker', ['pull', image], { stdio: 'inherit' }); + } catch { + console.error(`\nERROR: Failed to pull ${image}`); + console.error('Check https://hub.docker.com/r/keygraph/shannon for available tags.'); + process.exit(1); + } + pruneOldImages(version); +} + +/** + * Remove old keygraph/shannon images that don't match the current version. + */ +function pruneOldImages(currentVersion: string): void { + const output = runOutput('docker', [ + 'images', NPX_IMAGE_REPO, '--format', '{{.Tag}}', + ]); + if (!output) return; + + const currentTag = currentVersion; + const stale = output.split('\n').filter((tag) => tag && tag !== currentTag); + for (const tag of stale) { + 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}}', + ]); +} diff --git a/cli/src/env.ts b/cli/src/env.ts new file mode 100644 index 0000000..c14d1b0 --- /dev/null +++ b/cli/src/env.ts @@ -0,0 +1,159 @@ +/** + * Environment variable loading and credential validation. + * + * Local mode: loads ./.env via dotenv. + * NPX mode: fills gaps from ~/.shannon/config.toml (no .env). + */ + +import dotenv from 'dotenv'; +import { getMode } from './mode.js'; +import { resolveConfig } from './config/resolver.js'; + +/** Environment variables forwarded to worker containers. */ +const FORWARD_VARS = [ + 'ANTHROPIC_API_KEY', + 'ANTHROPIC_BASE_URL', + 'ANTHROPIC_AUTH_TOKEN', + 'ROUTER_DEFAULT', + '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', + 'OPENAI_API_KEY', + 'OPENROUTER_API_KEY', +] as const; + +/** + * Load credentials into process.env. + * Local mode: loads ./.env via dotenv. + * NPX mode: fills gaps from ~/.shannon/config.toml. + * Exported env vars always take precedence in both modes. + */ +export function loadEnv(): void { + if (getMode() === 'local') { + dotenv.config({ path: '.env', quiet: true }); + } else { + resolveConfig(); + } +} + +/** + * Build `-e KEY=VALUE` flags for docker run, only for set variables. + */ +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}`); + } + } + + return flags; +} + +interface CredentialValidation { + valid: boolean; + error?: string; + mode: 'api-key' | 'oauth' | 'bedrock' | 'vertex' | 'router'; +} + +/** Check if router credentials are present in the environment. */ +export function isRouterConfigured(): boolean { + return !!(process.env.ROUTER_DEFAULT && (process.env.OPENAI_API_KEY || process.env.OPENROUTER_API_KEY)); +} + +/** 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 (process.env.CLAUDE_CODE_USE_BEDROCK === '1') providers.push('AWS Bedrock'); + if (process.env.CLAUDE_CODE_USE_VERTEX === '1') providers.push('Google Vertex'); + if (isRouterConfigured()) providers.push('Router'); + return providers; +} + +/** + * Validate that exactly one authentication method is configured. + */ +export function validateCredentials(): CredentialValidation { + // Reject multiple providers + const providers = detectProviders(); + if (providers.length > 1) { + return { + valid: false, + mode: 'api-key', + error: `Multiple providers detected: ${providers.join(', ')}. Only one provider can be active at a time.`, + }; + } + + 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 (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(', ')}`, + }; + } + 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' }; + } + if (isRouterConfigured()) { + // Set a placeholder so the worker doesn't reject the missing key + process.env.ANTHROPIC_API_KEY = 'router-mode'; + return { valid: true, mode: 'router' }; + } + + const hint = getMode() === 'local' + ? `No credentials found. Set ANTHROPIC_API_KEY in .env or export it.` + : `Authentication not configured. Export variables or run 'npx @keygraph/shannon setup'.`; + return { + valid: false, + mode: 'api-key', + error: hint, + }; +} diff --git a/cli/src/home.ts b/cli/src/home.ts new file mode 100644 index 0000000..3f0d172 --- /dev/null +++ b/cli/src/home.ts @@ -0,0 +1,68 @@ +/** + * Shannon state directory management. + * + * Local mode (cloned repo): uses ./workspaces/, ./credentials/ + * NPX mode: uses ~/.shannon/workspaces/, ~/.shannon/ + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { getMode } from './mode.js'; + +const SHANNON_HOME = path.join(os.homedir(), '.shannon'); + +export function getConfigFile(): string { + return path.join(SHANNON_HOME, 'config.toml'); +} + +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'); +} + +/** + * In dev mode, return the credentials directory if it exists and has files. + * In npx mode, there is no credentials directory (single file mount instead). + */ +export function getCredentialsDir(): string | undefined { + if (getMode() !== 'local') return undefined; + + const dir = path.resolve('credentials'); + if (!fs.existsSync(dir)) return undefined; + + const entries = fs.readdirSync(dir); + return entries.length > 0 ? dir : undefined; +} + +/** + * Initialize state directories. + * Local mode: creates ./workspaces/ and ./credentials/ + * 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 }); + } +} diff --git a/cli/src/index.ts b/cli/src/index.ts new file mode 100644 index 0000000..0074923 --- /dev/null +++ b/cli/src/index.ts @@ -0,0 +1,211 @@ +#!/usr/bin/env node + +/** + * Shannon CLI — AI Penetration Testing Framework + * + * Unified CLI supporting two modes: + * Local mode: Run from cloned repo — builds locally, mounts prompts, uses ./workspaces/ + * NPX mode: Run via npx — pulls from Docker Hub, uses ~/.shannon/ + * + * Mode is auto-detected based on presence of Dockerfile + docker-compose.yml + prompts/ + * in the current working directory. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { getMode } from './mode.js'; +import { start } from './commands/start.js'; +import { stop } from './commands/stop.js'; +import { logs } from './commands/logs.js'; +import { workspaces } from './commands/workspaces.js'; +import { status } from './commands/status.js'; +import { update } from './commands/update.js'; +import { build } from './commands/build.js'; +import { setup } from './commands/setup.js'; +import { uninstall } from './commands/uninstall.js'; +import { displaySplash } from './splash.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +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'; + } +} + +function showHelp(): void { + const mode = getMode(); + const prefix = mode === 'local' ? './shannon' : 'npx @keygraph/shannon'; + + console.log(` +Shannon - AI Penetration Testing Framework + +Usage:${mode === 'local' ? '' : ` + ${prefix} setup Configure credentials`} + ${prefix} start --url --repo [options] Start a pentest scan + ${prefix} stop [--clean] Stop all containers + ${prefix} workspaces List all workspaces + ${prefix} logs Tail workflow log + ${prefix} status Show running workers${mode === 'local' ? ` + ${prefix} build [--no-cache] Build worker image` : ` + ${prefix} update Pull latest image + ${prefix} uninstall Remove ~/.shannon/ and all data`} + ${prefix} info Show splash screen + ${prefix} help Show this help + +Options for 'start': + -u, --url Target URL (required) + -r, --repo Repository path${mode === 'local' ? ' or bare name' : ''} (required) + -c, --config Configuration file (YAML) + -o, --output Copy deliverables to this directory after run + -w, --workspace Named workspace (auto-resumes if exists) + --pipeline-testing Use minimal prompts for fast testing + --router Route requests through claude-code-router + +Examples: + ${prefix} start -u https://example.com -r ${mode === 'local' ? 'my-repo' : './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 +`); +} + +interface ParsedStartArgs { + url: string; + repo: string; + config?: string; + workspace?: string; + output?: string; + pipelineTesting: boolean; + router: 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 router = false; + + 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 '--router': + router = true; + break; + default: + console.error(`Unknown option: ${arg}`); + console.error(`Run "${getMode() === 'local' ? './shannon' : 'npx @keygraph/shannon'} help" for usage`); + process.exit(1); + } + } + + if (!url || !repo) { + console.error('ERROR: --url and --repo are required'); + console.error(`Usage: ${getMode() === 'local' ? './shannon' : 'npx @keygraph/shannon'} start -u -r `); + process.exit(1); + } + + return { url, repo, pipelineTesting, router, ...(config && { config }), ...(workspace && { workspace }), ...(output && { output }) }; +} + +// === Main Dispatch === + +const args = process.argv.slice(2); +const command = args[0]; + +switch (command) { + case 'start': { + const parsed = parseStartArgs(args.slice(1)); + start({ ...parsed, version: getVersion() }); + break; + } + 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 `); + process.exit(1); + } + logs(workspaceId); + break; + } + 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); + } + setup(); + break; + case 'build': + build(args.includes('--no-cache')); + break; + case 'update': + update(getVersion()); + break; + case 'uninstall': + if (getMode() === 'local') { + console.error('ERROR: uninstall is only available in npx mode.'); + process.exit(1); + } + 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); +} diff --git a/cli/src/mode.ts b/cli/src/mode.ts new file mode 100644 index 0000000..5a61e68 --- /dev/null +++ b/cli/src/mode.ts @@ -0,0 +1,25 @@ +/** + * Runtime mode detection — local (build from source) vs npx (Docker Hub). + * + * The root `./shannon` entry point sets SHANNON_LOCAL=1 before importing. + * When run via npx, `cli/dist/index.js` is executed directly without it. + */ + +export type Mode = 'local' | 'npx'; + +let cachedMode: Mode | undefined; + +export function getMode(): Mode { + if (cachedMode !== undefined) return cachedMode; + + cachedMode = process.env.SHANNON_LOCAL === '1' ? 'local' : 'npx'; + return cachedMode; +} + +export function setMode(mode: Mode): void { + cachedMode = mode; +} + +export function isLocal(): boolean { + return getMode() === 'local'; +} diff --git a/cli/src/paths.ts b/cli/src/paths.ts new file mode 100644 index 0000000..4386e1b --- /dev/null +++ b/cli/src/paths.ts @@ -0,0 +1,87 @@ +/** + * 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. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { isLocal } from './mode.js'; + +export interface MountPair { + hostPath: string; + containerPath: string; +} + +/** + * Resolve --repo to absolute path and container mount. + * Dev mode: bare names (no / or . prefix) check ./repos/ first. + */ +export function resolveRepo(repoArg: string): MountPair { + let hostPath: string; + + if (isLocal() && !repoArg.startsWith('/') && !repoArg.startsWith('.')) { + // Bare name — check ./repos/ 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); + } + + if (!fs.existsSync(hostPath)) { + console.error(`ERROR: Repository not found: ${hostPath}`); + process.exit(1); + } + + if (!fs.statSync(hostPath).isDirectory()) { + console.error(`ERROR: Not a directory: ${hostPath}`); + process.exit(1); + } + + const basename = path.basename(hostPath); + return { + hostPath, + containerPath: `/repos/${basename}`, + }; +} + +/** + * Resolve --config to absolute path and container mount. + */ +export function resolveConfig(configArg: string): MountPair { + const hostPath = path.resolve(configArg); + + if (!fs.existsSync(hostPath)) { + console.error(`ERROR: Config file not found: ${hostPath}`); + process.exit(1); + } + + if (!fs.statSync(hostPath).isFile()) { + console.error(`ERROR: Not a file: ${hostPath}`); + process.exit(1); + } + + const basename = path.basename(hostPath); + return { + hostPath, + containerPath: `/app/configs/${basename}`, + }; +} + +/** + * Ensure the deliverables directory exists and is writable by the container user. + */ +export function ensureDeliverables(repoHostPath: string): void { + const deliverables = path.join(repoHostPath, 'deliverables'); + fs.mkdirSync(deliverables, { recursive: true }); + fs.chmodSync(deliverables, 0o777); +} diff --git a/cli/src/splash.ts b/cli/src/splash.ts new file mode 100644 index 0000000..004421d --- /dev/null +++ b/cli/src/splash.ts @@ -0,0 +1,50 @@ +/** + * Splash screen display — pure terminal output, no npm dependencies. + */ + +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'; + + const B = `${CYAN}\u2551${RESET}`; + const S67 = ' '.repeat(67); + const HR = '\u2550'.repeat(67); + + 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}`, + '', + ); + + console.log(lines.join('\n')); +} diff --git a/cli/tsconfig.json b/cli/tsconfig.json new file mode 100644 index 0000000..68dd138 --- /dev/null +++ b/cli/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/docker-compose.docker.yml b/docker-compose.docker.yml deleted file mode 100644 index 65fd403..0000000 --- a/docker-compose.docker.yml +++ /dev/null @@ -1,6 +0,0 @@ -# Docker-specific overrides (not used with Podman) -# This file is automatically included by the shannon script when running Docker -services: - worker: - extra_hosts: - - "host.docker.internal:host-gateway" diff --git a/docker-compose.yml b/docker-compose.yml index eede388..951e05c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,11 @@ +networks: + default: + name: shannon-net + services: temporal: image: temporalio/temporal:latest + container_name: shannon-temporal command: ["server", "start-dev", "--db-filename", "/home/temporal/temporal.db", "--ip", "0.0.0.0"] ports: - "127.0.0.1:7233:7233" # gRPC @@ -14,46 +19,11 @@ services: retries: 10 start_period: 30s - worker: - build: . - entrypoint: ["node", "dist/temporal/worker.js"] - environment: - - TEMPORAL_ADDRESS=temporal:7233 - - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-} - - ANTHROPIC_BASE_URL=${ANTHROPIC_BASE_URL:-} # Optional: route through claude-code-router - - ANTHROPIC_AUTH_TOKEN=${ANTHROPIC_AUTH_TOKEN:-} # Auth token for router - - ROUTER_DEFAULT=${ROUTER_DEFAULT:-} # Model name when using router (e.g., "gemini,gemini-2.5-pro") - - CLAUDE_CODE_OAUTH_TOKEN=${CLAUDE_CODE_OAUTH_TOKEN:-} - - CLAUDE_CODE_USE_BEDROCK=${CLAUDE_CODE_USE_BEDROCK:-} - - AWS_REGION=${AWS_REGION:-} - - AWS_BEARER_TOKEN_BEDROCK=${AWS_BEARER_TOKEN_BEDROCK:-} - - CLAUDE_CODE_USE_VERTEX=${CLAUDE_CODE_USE_VERTEX:-} - - CLOUD_ML_REGION=${CLOUD_ML_REGION:-} - - ANTHROPIC_VERTEX_PROJECT_ID=${ANTHROPIC_VERTEX_PROJECT_ID:-} - - GOOGLE_APPLICATION_CREDENTIALS=${GOOGLE_APPLICATION_CREDENTIALS:-} - - ANTHROPIC_SMALL_MODEL=${ANTHROPIC_SMALL_MODEL:-} - - ANTHROPIC_MEDIUM_MODEL=${ANTHROPIC_MEDIUM_MODEL:-} - - ANTHROPIC_LARGE_MODEL=${ANTHROPIC_LARGE_MODEL:-} - - CLAUDE_CODE_MAX_OUTPUT_TOKENS=${CLAUDE_CODE_MAX_OUTPUT_TOKENS:-64000} - depends_on: - temporal: - condition: service_healthy - volumes: - - ./configs:/app/configs - - ./prompts:/app/prompts - - ./audit-logs:/app/audit-logs - - ${OUTPUT_DIR:-./audit-logs}:/app/output - - ./credentials:/app/credentials:ro - - ./repos:/repos - - ${BENCHMARKS_BASE:-.}:/benchmarks - shm_size: 2gb - security_opt: - - seccomp:unconfined - # Optional: claude-code-router for multi-model support # Start with: ROUTER=true ./shannon start ... router: image: node:20-slim + container_name: shannon-router profiles: ["router"] # Only starts when explicitly requested command: > sh -c "apt-get update && apt-get install -y gettext-base && diff --git a/mcp-server/package-lock.json b/mcp-server/package-lock.json deleted file mode 100644 index fcc81c7..0000000 --- a/mcp-server/package-lock.json +++ /dev/null @@ -1,368 +0,0 @@ -{ - "name": "@shannon/mcp-server", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "@shannon/mcp-server", - "version": "1.0.0", - "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.2.38", - "zod": "^4.3.6" - }, - "devDependencies": { - "@types/node": "^25.0.3", - "typescript": "^5.9.3" - } - }, - "node_modules/@anthropic-ai/claude-agent-sdk": { - "version": "0.2.38", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.38.tgz", - "integrity": "sha512-U1vpf3rlSkw1qUlzC6CBibBA30ouQnla9JnuqYFLQ2zBb1U2NUCXIElrnV7RwWrI5e9ZKCHgR+1uaCwROONo7w==", - "license": "SEE LICENSE IN README.md", - "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "@img/sharp-darwin-arm64": "^0.33.5", - "@img/sharp-darwin-x64": "^0.33.5", - "@img/sharp-linux-arm": "^0.33.5", - "@img/sharp-linux-arm64": "^0.33.5", - "@img/sharp-linux-x64": "^0.33.5", - "@img/sharp-linuxmusl-arm64": "^0.33.5", - "@img/sharp-linuxmusl-x64": "^0.33.5", - "@img/sharp-win32-x64": "^0.33.5" - }, - "peerDependencies": { - "zod": "^4.0.0" - } - }, - "node_modules/@img/sharp-darwin-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", - "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.0.4" - } - }, - "node_modules/@img/sharp-darwin-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", - "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.0.4" - } - }, - "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", - "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", - "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", - "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", - "cpu": [ - "arm" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", - "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", - "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", - "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", - "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-linux-arm": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", - "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", - "cpu": [ - "arm" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.0.5" - } - }, - "node_modules/@img/sharp-linux-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", - "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.0.4" - } - }, - "node_modules/@img/sharp-linux-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", - "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.0.4" - } - }, - "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", - "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" - } - }, - "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", - "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.0.4" - } - }, - "node_modules/@img/sharp-win32-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", - "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@types/node": { - "version": "25.0.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.3.tgz", - "integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~7.16.0" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "dev": true, - "license": "MIT" - }, - "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", - "license": "MIT", - "peer": true, - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - } - } -} diff --git a/mcp-server/package.json b/mcp-server/package.json index 9faa35a..eb832dd 100644 --- a/mcp-server/package.json +++ b/mcp-server/package.json @@ -10,9 +10,5 @@ "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.38", "zod": "^4.3.6" - }, - "devDependencies": { - "@types/node": "^25.0.3", - "typescript": "^5.9.3" } } diff --git a/mcp-server/tsconfig.json b/mcp-server/tsconfig.json index 5ef6c93..68dd138 100644 --- a/mcp-server/tsconfig.json +++ b/mcp-server/tsconfig.json @@ -1,49 +1,8 @@ { - // Visit https://aka.ms/tsconfig to read more about this file + "extends": "../tsconfig.base.json", "compilerOptions": { - // File Layout "rootDir": "./src", - "outDir": "./dist", - - // Environment Settings - // See also https://aka.ms/tsconfig/module - "module": "nodenext", - "moduleResolution": "nodenext", - - "target": "es2022", - "lib": ["es2022"], - - "types": ["node"], - // For nodejs: - // "lib": ["esnext"], - // "types": ["node"], - // and npm install -D @types/node - - "resolveJsonModule": true, - "forceConsistentCasingInFileNames": true, - "noEmitOnError": true, - - // Other Outputs - "sourceMap": true, - "declaration": true, - "declarationMap": true, - - // Stricter Typechecking Options - "noUncheckedIndexedAccess": true, - "exactOptionalPropertyTypes": true, - - // Style Options - // "noImplicitReturns": true, - // "noImplicitOverride": true, - // "noUnusedLocals": true, - // "noUnusedParameters": true, - // "noFallthroughCasesInSwitch": true, - // "noPropertyAccessFromIndexSignature": true, - - // Recommended Options - "strict": true, - "noUncheckedSideEffectImports": true, - "skipLibCheck": true, + "outDir": "./dist" }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"] diff --git a/package-lock.json b/package-lock.json index 9d2b980..645bf4d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,16 @@ { "name": "shannon", - "version": "1.0.0", + "version": "0.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "shannon", - "version": "1.0.0", + "version": "0.0.0", + "workspaces": [ + "cli", + "mcp-server" + ], "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.38", "@temporalio/activity": "^1.11.0", @@ -15,11 +19,7 @@ "@temporalio/workflow": "^1.11.0", "ajv": "^8.12.0", "ajv-formats": "^2.1.1", - "boxen": "^8.0.1", - "chalk": "^5.0.0", "dotenv": "^16.4.5", - "figlet": "^1.9.3", - "gradient-string": "^3.0.0", "js-yaml": "^4.1.0", "zx": "^8.0.0" }, @@ -29,6 +29,42 @@ "typescript": "^5.9.3" } }, + "cli": { + "name": "@keygraph/shannon", + "version": "0.0.0", + "license": "AGPL-3.0-only", + "dependencies": { + "@clack/prompts": "^1.1.0", + "dotenv": "^17.3.1", + "smol-toml": "^1.6.0" + }, + "bin": { + "shannon": "dist/index.js" + }, + "engines": { + "node": ">=18" + } + }, + "cli/node_modules/dotenv": { + "version": "17.3.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", + "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "mcp-server": { + "name": "@shannon/mcp-server", + "version": "1.0.0", + "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.2.38", + "zod": "^4.3.6" + } + }, "node_modules/@anthropic-ai/claude-agent-sdk": { "version": "0.2.38", "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.38.tgz", @@ -51,6 +87,25 @@ "zod": "^4.0.0" } }, + "node_modules/@clack/core": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.1.0.tgz", + "integrity": "sha512-SVcm4Dqm2ukn64/8Gub2wnlA5nS2iWJyCkdNHcvNHPIeBTGojpdJ+9cZKwLfmqy7irD4N5qLteSilJlE0WLAtA==", + "license": "MIT", + "dependencies": { + "sisteransi": "^1.0.5" + } + }, + "node_modules/@clack/prompts": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.1.0.tgz", + "integrity": "sha512-pkqbPGtohJAvm4Dphs2M8xE29ggupihHdy1x84HNojZuMtFsHiUlRvqD24tM2+XmI+61LlfNceM3Wr7U5QES5g==", + "license": "MIT", + "dependencies": { + "@clack/core": "1.1.0", + "sisteransi": "^1.0.5" + } + }, "node_modules/@grpc/grpc-js": { "version": "1.14.3", "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz", @@ -536,6 +591,10 @@ "tslib": "2" } }, + "node_modules/@keygraph/shannon": { + "resolved": "cli", + "link": true + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -600,13 +659,16 @@ "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", "license": "BSD-3-Clause" }, + "node_modules/@shannon/mcp-server": { + "resolved": "mcp-server", + "link": true + }, "node_modules/@swc/core": { "version": "1.15.8", "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.8.tgz", "integrity": "sha512-T8keoJjXaSUoVBCIjgL6wAnhADIb09GOELzKg10CjNg+vLX48P93SME6jTfte9MZIm5m+Il57H3rTSk/0kzDUw==", "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.25" @@ -998,12 +1060,6 @@ "undici-types": "~7.16.0" } }, - "node_modules/@types/tinycolor2": { - "version": "1.4.6", - "resolved": "https://registry.npmjs.org/@types/tinycolor2/-/tinycolor2-1.4.6.tgz", - "integrity": "sha512-iEN8J0BoMnsWBqjVbWH/c0G0Hh7O21lpR2/+PrvAVgWdzL7eexIFm4JN/Wn10PTcmNdtS6U67r499mlWMXOxNw==", - "license": "MIT" - }, "node_modules/@webassemblyjs/ast": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", @@ -1179,7 +1235,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1204,7 +1259,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -1245,75 +1299,16 @@ "ajv": "^8.8.2" } }, - "node_modules/ansi-align": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", - "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==", - "license": "ISC", - "dependencies": { - "string-width": "^4.1.0" - } - }, - "node_modules/ansi-align/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-align/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/ansi-align/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-align/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, "node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, "engines": { - "node": ">=12" + "node": ">=8" }, "funding": { "url": "https://github.com/chalk/ansi-styles?sponsor=1" @@ -1334,28 +1329,6 @@ "baseline-browser-mapping": "dist/cli.js" } }, - "node_modules/boxen": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/boxen/-/boxen-8.0.1.tgz", - "integrity": "sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw==", - "license": "MIT", - "dependencies": { - "ansi-align": "^3.0.1", - "camelcase": "^8.0.0", - "chalk": "^5.3.0", - "cli-boxes": "^3.0.0", - "string-width": "^7.2.0", - "type-fest": "^4.21.0", - "widest-line": "^5.0.0", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/browserslist": { "version": "4.28.1", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", @@ -1375,7 +1348,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -1396,18 +1368,6 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "license": "MIT" }, - "node_modules/camelcase": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-8.0.0.tgz", - "integrity": "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==", - "license": "MIT", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/caniuse-lite": { "version": "1.0.30001764", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001764.tgz", @@ -1428,18 +1388,6 @@ ], "license": "CC-BY-4.0" }, - "node_modules/chalk": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.0.tgz", - "integrity": "sha512-46QrSQFyVSEyYAgQ22hQ+zDa60YHA4fBstHmtSApj1Y5vKtG27fWowW03jCk5KcbXEWPZUIR894aARCA/G1kfQ==", - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/chrome-trace-event": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", @@ -1449,18 +1397,6 @@ "node": ">=6.0" } }, - "node_modules/cli-boxes": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", - "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -1475,79 +1411,6 @@ "node": ">=12" } }, - "node_modules/cliui/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/cliui/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/cliui/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -1566,15 +1429,6 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, - "node_modules/commander": { - "version": "14.0.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.1.tgz", - "integrity": "sha512-2JkV3gUZUVrbNA+1sjBOYLsMZ5cEEl8GTFP2a4AVz5hvasAMCQ1D2l2le/cX+pV4N6ZU17zjUahLpIXRrnWL8A==", - "license": "MIT", - "engines": { - "node": ">=20" - } - }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -1594,9 +1448,9 @@ "license": "ISC" }, "node_modules/emoji-regex": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.5.0.tgz", - "integrity": "sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, "node_modules/enhanced-resolve": { @@ -1710,21 +1564,6 @@ ], "license": "BSD-3-Clause" }, - "node_modules/figlet": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/figlet/-/figlet-1.9.3.tgz", - "integrity": "sha512-majPgOpVtrZN1iyNGbsUP6bOtZ6eaJgg5HHh0vFvm5DJhh8dc+FJpOC4GABvMZ/A7XHAJUuJujhgUY/2jPWgMA==", - "license": "MIT", - "dependencies": { - "commander": "^14.0.0" - }, - "bin": { - "figlet": "bin/index.js" - }, - "engines": { - "node": ">= 17.0.0" - } - }, "node_modules/fs-monkey": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.1.0.tgz", @@ -1740,18 +1579,6 @@ "node": "6.* || 8.* || >= 10.*" } }, - "node_modules/get-east-asian-width": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", - "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/glob-to-regex.js": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/glob-to-regex.js/-/glob-to-regex.js-1.2.0.tgz", @@ -1780,19 +1607,6 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, - "node_modules/gradient-string": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/gradient-string/-/gradient-string-3.0.0.tgz", - "integrity": "sha512-frdKI4Qi8Ihp4C6wZNB565de/THpIaw3DjP5ku87M+N9rNSGmPTjfkq61SdRXB7eCaL8O1hkKDvf6CDMtOzIAg==", - "license": "MIT", - "dependencies": { - "chalk": "^5.3.0", - "tinygradient": "^1.1.5" - }, - "engines": { - "node": ">=14" - } - }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -2111,6 +1925,24 @@ "randombytes": "^2.1.0" } }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, + "node_modules/smol-toml": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.0.tgz", + "integrity": "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, "node_modules/source-map": { "version": "0.7.6", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", @@ -2169,35 +2001,38 @@ } }, "node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "license": "MIT", "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" }, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=8" } }, "node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "license": "MIT", "dependencies": { - "ansi-regex": "^6.0.1" + "ansi-regex": "^5.0.1" }, "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" + "node": ">=8" + } + }, + "node_modules/strip-ansi/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" } }, "node_modules/supports-color": { @@ -2315,22 +2150,6 @@ "tslib": "^2" } }, - "node_modules/tinycolor2": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", - "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==", - "license": "MIT" - }, - "node_modules/tinygradient": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/tinygradient/-/tinygradient-1.1.5.tgz", - "integrity": "sha512-8nIfc2vgQ4TeLnk2lFj4tRLvvJwEfQuabdsmvDdQPT0xlk9TaNtpGd6nNRxXoK6vQhN6RSzj+Cnp5tTQmpxmbw==", - "license": "MIT", - "dependencies": { - "@types/tinycolor2": "^1.4.0", - "tinycolor2": "^1.0.0" - } - }, "node_modules/tree-dump": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/tree-dump/-/tree-dump-1.1.0.tgz", @@ -2351,20 +2170,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "peer": true - }, - "node_modules/type-fest": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } + "license": "0BSD" }, "node_modules/typescript": { "version": "5.9.3", @@ -2455,7 +2261,6 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.104.1.tgz", "integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==", "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -2508,33 +2313,18 @@ "node": ">=10.13.0" } }, - "node_modules/widest-line": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-5.0.0.tgz", - "integrity": "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==", - "license": "MIT", - "dependencies": { - "string-width": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "license": "MIT", "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" }, "engines": { - "node": ">=18" + "node": ">=10" }, "funding": { "url": "https://github.com/chalk/wrap-ansi?sponsor=1" @@ -2576,53 +2366,11 @@ "node": ">=12" } }, - "node_modules/yargs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/yargs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/yargs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/yargs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/zod": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 8758b3f..c2f3e00 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,21 @@ { "name": "shannon", - "version": "1.0.0", + "version": "0.0.0", + "private": true, "type": "module", + "workspaces": [ + "cli", + "mcp-server" + ], "scripts": { "build": "tsc", + "build:cli": "npm run build -w cli", + "build:mcp": "npm run build -w mcp-server", + "build:all": "npm run build:mcp && npm run build && npm run build:cli", "temporal:server": "docker compose -f docker/docker-compose.temporal.yml up temporal -d", "temporal:server:stop": "docker compose -f docker/docker-compose.temporal.yml down", "temporal:worker": "node dist/temporal/worker.js", - "temporal:start": "node dist/temporal/client.js" + "temporal:start": "node dist/temporal/worker.js" }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.38", @@ -17,11 +25,7 @@ "@temporalio/workflow": "^1.11.0", "ajv": "^8.12.0", "ajv-formats": "^2.1.1", - "boxen": "^8.0.1", - "chalk": "^5.0.0", "dotenv": "^16.4.5", - "figlet": "^1.9.3", - "gradient-string": "^3.0.0", "js-yaml": "^4.1.0", "zx": "^8.0.0" }, diff --git a/src/audit/audit-session.ts b/src/audit/audit-session.ts index 0a63d5e..b571e65 100644 --- a/src/audit/audit-session.ts +++ b/src/audit/audit-session.ts @@ -82,8 +82,8 @@ export class AuditSession { // Initialize metrics tracker (loads or creates session.json) await this.metricsTracker.initialize(workflowId); - // Initialize workflow logger - await this.workflowLogger.initialize(); + // Initialize workflow logger with actual Temporal workflow ID + await this.workflowLogger.initialize(workflowId); this.initialized = true; } diff --git a/src/audit/utils.ts b/src/audit/utils.ts index c4366ac..bd51c87 100644 --- a/src/audit/utils.ts +++ b/src/audit/utils.ts @@ -25,7 +25,7 @@ const __dirname = path.dirname(__filename); // Get Shannon repository root const SHANNON_ROOT = path.resolve(__dirname, '..', '..'); -const AUDIT_LOGS_DIR = path.join(SHANNON_ROOT, 'audit-logs'); +const WORKSPACES_DIR = path.join(SHANNON_ROOT, 'workspaces'); /** * Extract and sanitize hostname from URL for use in identifiers @@ -44,11 +44,11 @@ export function generateSessionIdentifier(sessionMetadata: SessionMetadata): str /** * Generate path to audit log directory for a session - * Uses custom outputPath if provided, otherwise defaults to AUDIT_LOGS_DIR + * Uses custom outputPath if provided, otherwise defaults to WORKSPACES_DIR */ export function generateAuditPath(sessionMetadata: SessionMetadata): string { const sessionIdentifier = generateSessionIdentifier(sessionMetadata); - const baseDir = sessionMetadata.outputPath || AUDIT_LOGS_DIR; + const baseDir = sessionMetadata.outputPath || WORKSPACES_DIR; return path.join(baseDir, sessionIdentifier); } @@ -92,7 +92,7 @@ export function generateWorkflowLogPath(sessionMetadata: SessionMetadata): strin /** * Initialize audit directory structure for a session - * Creates: audit-logs/{sessionId}/, agents/, prompts/, deliverables/ + * Creates: workspaces/{sessionId}/, agents/, prompts/, deliverables/ */ export async function initializeAuditStructure(sessionMetadata: SessionMetadata): Promise { const auditPath = generateAuditPath(sessionMetadata); @@ -107,7 +107,7 @@ export async function initializeAuditStructure(sessionMetadata: SessionMetadata) } /** - * Copy deliverable files from repo to audit-logs for self-contained audit trail. + * Copy deliverable files from repo to workspaces for self-contained audit trail. * No-ops if source directory doesn't exist. Idempotent and parallel-safe. */ export async function copyDeliverablesToAudit( diff --git a/src/audit/workflow-logger.ts b/src/audit/workflow-logger.ts index 67a604c..2790fe4 100644 --- a/src/audit/workflow-logger.ts +++ b/src/audit/workflow-logger.ts @@ -44,6 +44,7 @@ export interface WorkflowSummary { export class WorkflowLogger { private readonly sessionMetadata: SessionMetadata; private readonly logStream: LogStream; + private workflowId: string | undefined; constructor(sessionMetadata: SessionMetadata) { this.sessionMetadata = sessionMetadata; @@ -54,7 +55,11 @@ export class WorkflowLogger { /** * Initialize the log stream (creates file and writes header) */ - async initialize(): Promise { + async initialize(workflowId?: string): Promise { + if (workflowId) { + this.workflowId = workflowId; + } + if (this.logStream.isOpen) { return; } @@ -76,7 +81,7 @@ export class WorkflowLogger { `================================================================================`, `Shannon Pentest - Workflow Log`, `================================================================================`, - `Workflow ID: ${this.sessionMetadata.id}`, + `Workflow ID: ${this.workflowId ?? this.sessionMetadata.id}`, `Target URL: ${this.sessionMetadata.webUrl}`, `Started: ${formatTimestamp()}`, `================================================================================`, @@ -341,7 +346,7 @@ export class WorkflowLogger { await this.logStream.write(`================================================================================\n`); await this.logStream.write(`Workflow ${status}\n`); await this.logStream.write(`────────────────────────────────────────\n`); - await this.logStream.write(`Workflow ID: ${this.sessionMetadata.id}\n`); + await this.logStream.write(`Workflow ID: ${this.workflowId ?? this.sessionMetadata.id}\n`); await this.logStream.write(`Status: ${summary.status}\n`); await this.logStream.write(`Duration: ${formatDuration(summary.totalDurationMs)}\n`); await this.logStream.write(`Total Cost: $${summary.totalCostUsd.toFixed(4)}\n`); diff --git a/src/splash-screen.ts b/src/splash-screen.ts deleted file mode 100644 index 4fcca3e..0000000 --- a/src/splash-screen.ts +++ /dev/null @@ -1,86 +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 figlet from 'figlet'; -import gradient from 'gradient-string'; -import boxen from 'boxen'; -import chalk from 'chalk'; -import { fs, path } from 'zx'; - -export const displaySplashScreen = async (): Promise => { - try { - // Get version info from package.json - const packagePath = path.join(import.meta.dirname, '..', 'package.json'); - const packageJson = (await fs.readJSON(packagePath)) as { version?: string }; - const version = packageJson.version || '1.0.0'; - - // Create the main SHANNON ASCII art - const shannonText = figlet.textSync('SHANNON', { - font: 'ANSI Shadow', - horizontalLayout: 'default', - verticalLayout: 'default', - }); - - // Apply golden gradient to SHANNON - const gradientShannon = gradient(['#F4C542', '#FFD700'])(shannonText); - - // Create minimal tagline with styling - const tagline = chalk.bold.white('AI Penetration Testing Framework'); - const versionInfo = chalk.gray(`v${version}`); - - // Build the complete splash content - const content = [ - gradientShannon, - '', - chalk.bold.cyan(' ╔════════════════════════════════════╗'), - chalk.bold.cyan(' ║') + ' ' + tagline + ' ' + chalk.bold.cyan('║'), - chalk.bold.cyan(' ╚════════════════════════════════════╝'), - '', - ` ${versionInfo}`, - '', - chalk.bold.yellow(' 🔐 DEFENSIVE SECURITY ONLY 🔐'), - '', - ].join('\n'); - - // Create boxed output with minimal styling - const boxedContent = boxen(content, { - padding: 1, - margin: 1, - borderStyle: 'double', - borderColor: 'cyan', - dimBorder: false, - }); - - // Clear screen and display splash - console.clear(); - console.log(boxedContent); - - // Add loading animation - const loadingFrames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; - let frameIndex = 0; - - return new Promise((resolve) => { - const loadingInterval = setInterval(() => { - process.stdout.write( - `\r${chalk.cyan(loadingFrames[frameIndex])} ${chalk.dim('Initializing systems...')}` - ); - frameIndex = (frameIndex + 1) % loadingFrames.length; - }, 100); - - setTimeout(() => { - clearInterval(loadingInterval); - process.stdout.write(`\r${chalk.green('✓')} ${chalk.dim('Systems initialized. ')}\n\n`); - resolve(); - }, 2000); - }); - } catch (error) { - // Fallback to simple splash if anything fails - const errMsg = error instanceof Error ? error.message : String(error); - console.log(chalk.cyan.bold('\n🚀 SHANNON - AI Penetration Testing Framework\n')); - console.log(chalk.yellow('⚠️ Could not load full splash screen:', errMsg)); - console.log(''); - } -}; diff --git a/src/temporal/activities.ts b/src/temporal/activities.ts index 78780f0..1f6dc1a 100644 --- a/src/temporal/activities.ts +++ b/src/temporal/activities.ts @@ -337,7 +337,7 @@ export async function injectReportMetadataActivity(input: ActivityInput): Promis const logger = createActivityLogger(); const effectiveOutputPath = outputPath ? path.join(outputPath, sessionId) - : path.join('./audit-logs', sessionId); + : path.join('./workspaces', sessionId); try { await injectModelIntoReport(repoPath, effectiveOutputPath, logger); } catch (error) { @@ -394,7 +394,7 @@ export async function loadResumeState( expectedRepoPath: string ): Promise { // 1. Validate workspace exists - const sessionPath = path.join('./audit-logs', workspaceName, 'session.json'); + const sessionPath = path.join('./workspaces', workspaceName, 'session.json'); const exists = await fileExists(sessionPath); if (!exists) { @@ -646,12 +646,12 @@ export async function logWorkflowComplete( // 5. Write completion entry to workflow.log await auditSession.logWorkflowComplete(cumulativeSummary); - // 6. Copy deliverables to audit-logs + // 6. Copy deliverables to workspaces try { await copyDeliverablesToAudit(sessionMetadata, repoPath); } catch (copyErr) { const logger = createActivityLogger(); - logger.error('Failed to copy deliverables to audit-logs', { + logger.error('Failed to copy deliverables to workspaces', { error: copyErr instanceof Error ? copyErr.message : String(copyErr), }); } diff --git a/src/temporal/client.ts b/src/temporal/client.ts deleted file mode 100644 index 357cc8f..0000000 --- a/src/temporal/client.ts +++ /dev/null @@ -1,482 +0,0 @@ -#!/usr/bin/env node -// 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. - -/** - * Temporal client for starting Shannon pentest pipeline workflows. - * - * Starts a workflow and optionally waits for completion with progress polling. - * - * Usage: - * npm run temporal:start -- [options] - * # or - * node dist/temporal/client.js [options] - * - * Options: - * --config Configuration file path - * --output Output directory for audit logs - * --pipeline-testing Use minimal prompts for fast testing - * --workflow-id Custom workflow ID (default: shannon-) - * --wait Wait for workflow completion with progress polling - * - * Environment: - * TEMPORAL_ADDRESS - Temporal server address (default: localhost:7233) - */ - -import { Connection, Client, WorkflowNotFoundError, type WorkflowHandle } from '@temporalio/client'; -import dotenv from 'dotenv'; -import { displaySplashScreen } from '../splash-screen.js'; -import { sanitizeHostname } from '../audit/utils.js'; -import { readJson, fileExists } from '../utils/file-io.js'; -import path from 'path'; -import { parseConfig } from '../config-parser.js'; -import type { PipelineConfig } from '../types/config.js'; -// Import types only - these don't pull in workflow runtime code -import type { PipelineInput, PipelineState, PipelineProgress } from './shared.js'; - -/** - * Session.json structure for resume validation - */ -interface SessionJson { - session: { - id: string; - webUrl: string; - originalWorkflowId?: string; - resumeAttempts?: Array<{ workflowId: string }>; - }; - metrics: { - total_cost_usd: number; - }; -} - -dotenv.config(); - -// Query name must match the one defined in workflows.ts -const PROGRESS_QUERY = 'getProgress'; - -/** - * Terminate any running workflows associated with a workspace. - * Returns the list of terminated workflow IDs. - */ -async function terminateExistingWorkflows( - client: Client, - workspaceName: string -): Promise { - const sessionPath = path.join('./audit-logs', workspaceName, 'session.json'); - - if (!(await fileExists(sessionPath))) { - throw new Error( - `Workspace not found: ${workspaceName}\n` + - `Expected path: ${sessionPath}` - ); - } - - const session = await readJson(sessionPath); - - // Collect all workflow IDs associated with this workspace - const workflowIds = [ - session.session.originalWorkflowId || session.session.id, - ...(session.session.resumeAttempts?.map((r) => r.workflowId) || []), - ].filter((id): id is string => id != null); - - const terminated: string[] = []; - - for (const wfId of workflowIds) { - try { - const handle = client.workflow.getHandle(wfId); - const description = await handle.describe(); - - if (description.status.name === 'RUNNING') { - console.log(`Terminating running workflow: ${wfId}`); - await handle.terminate('Superseded by resume workflow'); - terminated.push(wfId); - console.log(`Terminated: ${wfId}`); - } else { - console.log(`Workflow already ${description.status.name}: ${wfId}`); - } - } catch (error) { - if (error instanceof WorkflowNotFoundError) { - console.log(`Workflow not found (already cleaned up): ${wfId}`); - } else { - console.log(`Failed to terminate ${wfId}: ${error}`); - // Continue anyway - don't block resume on termination failure - } - } - } - - return terminated; -} - -/** - * Validate workspace name: alphanumeric, hyphens, underscores, 1-128 chars, - * must start with alphanumeric. - */ -function isValidWorkspaceName(name: string): boolean { - return /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/.test(name); -} - -function showUsage(): void { - console.log('\nShannon Temporal Client'); - console.log('Start a pentest pipeline workflow\n'); - console.log('Usage:'); - console.log( - ' node dist/temporal/client.js [options]\n' - ); - console.log('Options:'); - console.log(' --config Configuration file path'); - console.log(' --output Output directory for audit logs'); - console.log(' --pipeline-testing Use minimal prompts for fast testing'); - console.log(' --workspace Resume from existing workspace'); - console.log( - ' --workflow-id Custom workflow ID (default: shannon-)' - ); - console.log(' --wait Wait for workflow completion with progress polling\n'); - console.log('Examples:'); - console.log(' node dist/temporal/client.js https://example.com /path/to/repo'); - console.log( - ' node dist/temporal/client.js https://example.com /path/to/repo --config config.yaml\n' - ); -} - -// === CLI Argument Parsing === - -interface CliArgs { - webUrl: string; - repoPath: string; - configPath?: string; - outputPath?: string; - displayOutputPath?: string; - pipelineTestingMode: boolean; - customWorkflowId?: string; - waitForCompletion: boolean; - resumeFromWorkspace?: string; -} - -function parseCliArgs(argv: string[]): CliArgs { - if (argv.includes('--help') || argv.includes('-h') || argv.length === 0) { - showUsage(); - process.exit(0); - } - - let webUrl: string | undefined; - let repoPath: string | undefined; - let configPath: string | undefined; - let outputPath: string | undefined; - let displayOutputPath: string | undefined; - let pipelineTestingMode = false; - let customWorkflowId: string | undefined; - let waitForCompletion = false; - let resumeFromWorkspace: string | undefined; - - for (let i = 0; i < argv.length; i++) { - const arg = argv[i]; - if (arg === '--config') { - const nextArg = argv[i + 1]; - if (nextArg && !nextArg.startsWith('-')) { - configPath = nextArg; - i++; - } - } else if (arg === '--output') { - const nextArg = argv[i + 1]; - if (nextArg && !nextArg.startsWith('-')) { - outputPath = nextArg; - i++; - } - } else if (arg === '--display-output') { - const nextArg = argv[i + 1]; - if (nextArg && !nextArg.startsWith('-')) { - displayOutputPath = nextArg; - i++; - } - } else if (arg === '--workflow-id') { - const nextArg = argv[i + 1]; - if (nextArg && !nextArg.startsWith('-')) { - customWorkflowId = nextArg; - i++; - } - } else if (arg === '--pipeline-testing') { - pipelineTestingMode = true; - } else if (arg === '--workspace') { - const nextArg = argv[i + 1]; - if (nextArg && !nextArg.startsWith('-')) { - resumeFromWorkspace = nextArg; - i++; - } - } else if (arg === '--wait') { - waitForCompletion = true; - } else if (arg && !arg.startsWith('-')) { - if (!webUrl) { - webUrl = arg; - } else if (!repoPath) { - repoPath = arg; - } - } - } - - if (!webUrl || !repoPath) { - console.log('Error: webUrl and repoPath are required'); - showUsage(); - process.exit(1); - } - - return { - webUrl, repoPath, pipelineTestingMode, waitForCompletion, - ...(configPath && { configPath }), - ...(outputPath && { outputPath }), - ...(displayOutputPath && { displayOutputPath }), - ...(customWorkflowId && { customWorkflowId }), - ...(resumeFromWorkspace && { resumeFromWorkspace }), - }; -} - -// === Workspace Resolution === - -interface WorkspaceResolution { - workflowId: string; - sessionId: string; - isResume: boolean; - terminatedWorkflows: string[]; -} - -async function resolveWorkspace( - client: Client, - args: CliArgs -): Promise { - if (!args.resumeFromWorkspace) { - const hostname = sanitizeHostname(args.webUrl); - const workflowId = args.customWorkflowId || `${hostname}_shannon-${Date.now()}`; - return { - workflowId, - sessionId: workflowId, - isResume: false, - terminatedWorkflows: [], - }; - } - - const workspace = args.resumeFromWorkspace; - const sessionPath = path.join('./audit-logs', workspace, 'session.json'); - const workspaceExists = await fileExists(sessionPath); - - if (workspaceExists) { - console.log('=== RESUME MODE ==='); - console.log(`Workspace: ${workspace}\n`); - - // 1. Terminate any running workflows from previous attempts - const terminatedWorkflows = await terminateExistingWorkflows(client, workspace); - if (terminatedWorkflows.length > 0) { - console.log(`Terminated ${terminatedWorkflows.length} previous workflow(s)\n`); - } - - // 2. Validate URL matches the workspace - const session = await readJson(sessionPath); - if (session.session.webUrl !== args.webUrl) { - console.error('ERROR: URL mismatch with workspace'); - console.error(` Workspace URL: ${session.session.webUrl}`); - console.error(` Provided URL: ${args.webUrl}`); - process.exit(1); - } - - // 3. Generate a new workflow ID scoped to this resume attempt - // 4. Return resolution with isResume=true so downstream uses resume logic - return { - workflowId: `${workspace}_resume_${Date.now()}`, - sessionId: workspace, - isResume: true, - terminatedWorkflows, - }; - } - - if (!isValidWorkspaceName(workspace)) { - console.error(`ERROR: Invalid workspace name: "${workspace}"`); - console.error(' Must be 1-128 characters, alphanumeric/hyphens/underscores, starting with alphanumeric'); - process.exit(1); - } - - console.log('=== NEW NAMED WORKSPACE ==='); - console.log(`Workspace: ${workspace}\n`); - - return { - workflowId: `${workspace}_shannon-${Date.now()}`, - sessionId: workspace, - isResume: false, - terminatedWorkflows: [], - }; -} - -// === Pipeline Input Construction === - -async function loadPipelineConfig(configPath: string | undefined): Promise { - if (!configPath) return {}; - try { - const config = await parseConfig(configPath); - const raw = config.pipeline; - if (!raw) return {}; - - // FAILSAFE_SCHEMA parses all YAML values as strings — coerce to number - const result: PipelineConfig = {}; - if (raw.retry_preset !== undefined) { - result.retry_preset = raw.retry_preset; - } - if (raw.max_concurrent_pipelines !== undefined) { - result.max_concurrent_pipelines = Number(raw.max_concurrent_pipelines); - } - return result; - } catch { - // Config errors surface later in preflight. Don't block workflow start. - return {}; - } -} - -function buildPipelineInput( - args: CliArgs, workspace: WorkspaceResolution, pipelineConfig: PipelineConfig -): PipelineInput { - return { - webUrl: args.webUrl, - repoPath: args.repoPath, - workflowId: workspace.workflowId, - sessionId: workspace.sessionId, - ...(args.configPath && { configPath: args.configPath }), - ...(args.outputPath && { outputPath: args.outputPath }), - ...(args.pipelineTestingMode && { pipelineTestingMode: args.pipelineTestingMode }), - ...(workspace.isResume && args.resumeFromWorkspace && { resumeFromWorkspace: args.resumeFromWorkspace }), - ...(workspace.terminatedWorkflows.length > 0 && { terminatedWorkflows: workspace.terminatedWorkflows }), - ...(Object.keys(pipelineConfig).length > 0 && { pipelineConfig }), - }; -} - -// === Display Helpers === - -function displayWorkflowInfo(args: CliArgs, workspace: WorkspaceResolution): void { - console.log(`✓ Workflow started: ${workspace.workflowId}`); - if (workspace.isResume) { - console.log(` (Resuming workspace: ${workspace.sessionId})`); - } - console.log(); - console.log(` Target: ${args.webUrl}`); - console.log(` Repository: ${args.repoPath}`); - console.log(` Workspace: ${workspace.sessionId}`); - if (args.configPath) { - console.log(` Config: ${args.configPath}`); - } - if (args.displayOutputPath) { - console.log(` Output: ${args.displayOutputPath}`); - } - if (args.pipelineTestingMode) { - console.log(` Mode: Pipeline Testing`); - } - console.log(); -} - -function displayMonitoringInfo(args: CliArgs, workspace: WorkspaceResolution): void { - const effectiveDisplayPath = args.displayOutputPath || args.outputPath || './audit-logs'; - const outputDir = `${effectiveDisplayPath}/${workspace.sessionId}`; - - console.log('Monitor progress:'); - console.log(` Web UI: http://localhost:8233/namespaces/default/workflows/${workspace.workflowId}`); - console.log(` Logs: ./shannon logs ID=${workspace.workflowId}`); - console.log(); - console.log('Output:'); - console.log(` Reports: ${outputDir}`); - console.log(); -} - -// === Workflow Result Handling === - -async function waitForWorkflowResult( - handle: WorkflowHandle<(input: PipelineInput) => Promise>, - workspace: WorkspaceResolution -): Promise { - const progressInterval = setInterval(async () => { - try { - const progress = await handle.query(PROGRESS_QUERY); - const elapsed = Math.floor(progress.elapsedMs / 1000); - console.log( - `[${elapsed}s] Phase: ${progress.currentPhase || 'unknown'} | Agent: ${progress.currentAgent || 'none'} | Completed: ${progress.completedAgents.length}/13` - ); - } catch { - // Workflow may have completed - } - }, 30000); - - try { - // 1. Block until workflow completes - const result = await handle.result(); - clearInterval(progressInterval); - - // 2. Display run metrics - console.log('\nPipeline completed successfully!'); - if (result.summary) { - console.log(`Duration: ${Math.floor(result.summary.totalDurationMs / 1000)}s`); - console.log(`Agents completed: ${result.summary.agentCount}`); - console.log(`Total turns: ${result.summary.totalTurns}`); - console.log(`Run cost: $${result.summary.totalCostUsd.toFixed(4)}`); - - // 3. Show cumulative cost across all resume attempts - if (workspace.isResume) { - try { - const session = await readJson( - path.join('./audit-logs', workspace.sessionId, 'session.json') - ); - console.log(`Cumulative cost: $${session.metrics.total_cost_usd.toFixed(4)}`); - } catch { - // Non-fatal, skip cumulative cost display - } - } - } - } catch (error) { - clearInterval(progressInterval); - console.error('\nPipeline failed:', error); - process.exit(1); - } -} - -// === Main Entry Point === - -async function startPipeline(): Promise { - // 1. Parse CLI args and display splash - const args = parseCliArgs(process.argv.slice(2)); - await displaySplashScreen(); - - // 2. Connect to Temporal server - const address = process.env.TEMPORAL_ADDRESS || 'localhost:7233'; - console.log(`Connecting to Temporal at ${address}...`); - - const connection = await Connection.connect({ address }); - const client = new Client({ connection }); - - try { - // 3. Resolve workspace (new or resume) and build pipeline input - const workspace = await resolveWorkspace(client, args); - const pipelineConfig = await loadPipelineConfig(args.configPath); - const input = buildPipelineInput(args, workspace, pipelineConfig); - - // 4. Start the Temporal workflow - const handle = await client.workflow.start<(input: PipelineInput) => Promise>( - 'pentestPipelineWorkflow', - { - taskQueue: 'shannon-pipeline', - workflowId: workspace.workflowId, - args: [input], - } - ); - - // 5. Display info and optionally wait for completion - displayWorkflowInfo(args, workspace); - - if (args.waitForCompletion) { - await waitForWorkflowResult(handle, workspace); - } else { - displayMonitoringInfo(args, workspace); - } - } finally { - await connection.close(); - } -} - -startPipeline().catch((err) => { - console.error('Client error:', err); - process.exit(1); -}); diff --git a/src/temporal/worker.ts b/src/temporal/worker.ts index b0f2f9b..d33f558 100644 --- a/src/temporal/worker.ts +++ b/src/temporal/worker.ts @@ -6,73 +6,458 @@ // as published by the Free Software Foundation. /** - * Temporal worker for Shannon pentest pipeline. + * Combined Temporal worker + client for Shannon pentest pipeline. * - * Polls the 'shannon-pipeline' task queue and executes activities. - * Handles up to 25 concurrent activities to support multiple parallel workflows. + * Starts a worker on a per-invocation task queue, submits a workflow, + * waits for the result, and exits. Designed to run as a single ephemeral + * container per scan. * * Usage: - * npm run temporal:worker - * # or - * node dist/temporal/worker.js + * node dist/temporal/worker.js [options] + * + * Options: + * --task-queue Task queue name (required, unique per scan) + * --config Configuration file path + * --output Output directory for workspaces + * --workspace Resume from existing workspace + * --pipeline-testing Use minimal prompts for fast testing * * Environment: * TEMPORAL_ADDRESS - Temporal server address (default: localhost:7233) */ import { NativeConnection, Worker, bundleWorkflowCode } from '@temporalio/worker'; +import { Connection, Client, WorkflowNotFoundError, type WorkflowHandle } from '@temporalio/client'; import { fileURLToPath } from 'node:url'; import path from 'node:path'; +import fs from 'node:fs'; import dotenv from 'dotenv'; import * as activities from './activities.js'; +import { sanitizeHostname } from '../audit/utils.js'; +import { readJson, fileExists } from '../utils/file-io.js'; +import { parseConfig } from '../config-parser.js'; +import type { PipelineConfig } from '../types/config.js'; +import type { PipelineInput, PipelineState, PipelineProgress } from './shared.js'; dotenv.config(); const __dirname = path.dirname(fileURLToPath(import.meta.url)); -async function runWorker(): Promise { +const PROGRESS_QUERY = 'getProgress'; + +// === CLI Argument Parsing === + +interface CliArgs { + webUrl: string; + repoPath: string; + taskQueue: string; + configPath?: string; + outputPath?: string; + pipelineTestingMode: boolean; + resumeFromWorkspace?: string; +} + +function showUsage(): void { + console.log('\nShannon Worker'); + console.log('Combined worker + client for pentest pipeline\n'); + console.log('Usage:'); + console.log( + ' node dist/temporal/worker.js --task-queue [options]\n' + ); + console.log('Options:'); + console.log(' --task-queue Task queue name (required)'); + console.log(' --config Configuration file path'); + console.log(' --workspace Resume from existing workspace'); + console.log(' --pipeline-testing Use minimal prompts for fast testing\n'); +} + +function parseCliArgs(argv: string[]): CliArgs { + if (argv.includes('--help') || argv.includes('-h') || argv.length === 0) { + showUsage(); + process.exit(0); + } + + let webUrl: string | undefined; + let repoPath: string | undefined; + let taskQueue: string | undefined; + let configPath: string | undefined; + let outputPath: string | undefined; + let pipelineTestingMode = false; + let resumeFromWorkspace: string | undefined; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === '--task-queue') { + const nextArg = argv[i + 1]; + if (nextArg && !nextArg.startsWith('-')) { + taskQueue = nextArg; + i++; + } + } else if (arg === '--config') { + const nextArg = argv[i + 1]; + if (nextArg && !nextArg.startsWith('-')) { + configPath = nextArg; + i++; + } + } else if (arg === '--output') { + const nextArg = argv[i + 1]; + if (nextArg && !nextArg.startsWith('-')) { + outputPath = nextArg; + i++; + } + } else if (arg === '--workspace') { + const nextArg = argv[i + 1]; + if (nextArg && !nextArg.startsWith('-')) { + resumeFromWorkspace = nextArg; + i++; + } + } else if (arg === '--pipeline-testing') { + pipelineTestingMode = true; + } else if (arg && !arg.startsWith('-')) { + if (!webUrl) { + webUrl = arg; + } else if (!repoPath) { + repoPath = arg; + } + } + } + + if (!webUrl || !repoPath) { + console.error('Error: webUrl and repoPath are required'); + showUsage(); + process.exit(1); + } + + if (!taskQueue) { + console.error('Error: --task-queue is required'); + showUsage(); + process.exit(1); + } + + return { + webUrl, repoPath, taskQueue, pipelineTestingMode, + ...(configPath && { configPath }), + ...(outputPath && { outputPath }), + ...(resumeFromWorkspace && { resumeFromWorkspace }), + }; +} + +// === Workspace Resolution === + +interface SessionJson { + session: { + id: string; + webUrl: string; + originalWorkflowId?: string; + resumeAttempts?: Array<{ workflowId: string }>; + }; + metrics: { + total_cost_usd: number; + }; +} + +function isValidWorkspaceName(name: string): boolean { + return /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/.test(name); +} + +interface WorkspaceResolution { + workflowId: string; + sessionId: string; + isResume: boolean; + terminatedWorkflows: string[]; +} + +async function terminateExistingWorkflows( + client: Client, + workspaceName: string +): Promise { + const sessionPath = path.join('./workspaces', workspaceName, 'session.json'); + + if (!(await fileExists(sessionPath))) { + throw new Error( + `Workspace not found: ${workspaceName}\n` + + `Expected path: ${sessionPath}` + ); + } + + const session = await readJson(sessionPath); + + const workflowIds = [ + session.session.originalWorkflowId || session.session.id, + ...(session.session.resumeAttempts?.map((r) => r.workflowId) || []), + ].filter((id): id is string => id != null); + + const terminated: string[] = []; + + for (const wfId of workflowIds) { + try { + const handle = client.workflow.getHandle(wfId); + const description = await handle.describe(); + + if (description.status.name === 'RUNNING') { + console.log(`Terminating running workflow: ${wfId}`); + await handle.terminate('Superseded by resume workflow'); + terminated.push(wfId); + console.log(`Terminated: ${wfId}`); + } else { + console.log(`Workflow already ${description.status.name}: ${wfId}`); + } + } catch (error) { + if (error instanceof WorkflowNotFoundError) { + console.log(`Workflow not found (already cleaned up): ${wfId}`); + } else { + console.log(`Failed to terminate ${wfId}: ${error}`); + } + } + } + + return terminated; +} + +async function resolveWorkspace( + client: Client, + args: CliArgs +): Promise { + if (!args.resumeFromWorkspace) { + const hostname = sanitizeHostname(args.webUrl); + const workflowId = `${hostname}_shannon-${Date.now()}`; + return { + workflowId, + sessionId: workflowId, + isResume: false, + terminatedWorkflows: [], + }; + } + + const workspace = args.resumeFromWorkspace; + const sessionPath = path.join('./workspaces', workspace, 'session.json'); + const workspaceExists = await fileExists(sessionPath); + + if (workspaceExists) { + console.log('=== RESUME MODE ==='); + console.log(`Workspace: ${workspace}\n`); + + const terminatedWorkflows = await terminateExistingWorkflows(client, workspace); + if (terminatedWorkflows.length > 0) { + console.log(`Terminated ${terminatedWorkflows.length} previous workflow(s)\n`); + } + + const session = await readJson(sessionPath); + if (session.session.webUrl !== args.webUrl) { + console.error('ERROR: URL mismatch with workspace'); + console.error(` Workspace URL: ${session.session.webUrl}`); + console.error(` Provided URL: ${args.webUrl}`); + process.exit(1); + } + + return { + workflowId: `${workspace}_resume_${Date.now()}`, + sessionId: workspace, + isResume: true, + terminatedWorkflows, + }; + } + + if (!isValidWorkspaceName(workspace)) { + console.error(`ERROR: Invalid workspace name: "${workspace}"`); + console.error(' Must be 1-128 characters, alphanumeric/hyphens/underscores, starting with alphanumeric'); + process.exit(1); + } + + console.log('=== NEW NAMED WORKSPACE ==='); + console.log(`Workspace: ${workspace}\n`); + + // If the workspace name already looks like a CLI-generated ID + // (ends with _shannon-), use it directly to avoid double _shannon- suffixes + const workflowId = /_shannon-\d+$/.test(workspace) + ? workspace + : `${workspace}_shannon-${Date.now()}`; + + return { + workflowId, + sessionId: workspace, + isResume: false, + terminatedWorkflows: [], + }; +} + +// === Pipeline Input Construction === + +async function loadPipelineConfig(configPath: string | undefined): Promise { + if (!configPath) return {}; + try { + const config = await parseConfig(configPath); + const raw = config.pipeline; + if (!raw) return {}; + + const result: PipelineConfig = {}; + if (raw.retry_preset !== undefined) { + result.retry_preset = raw.retry_preset; + } + if (raw.max_concurrent_pipelines !== undefined) { + result.max_concurrent_pipelines = Number(raw.max_concurrent_pipelines); + } + return result; + } catch { + return {}; + } +} + +function buildPipelineInput( + args: CliArgs, workspace: WorkspaceResolution, pipelineConfig: PipelineConfig +): PipelineInput { + return { + webUrl: args.webUrl, + repoPath: args.repoPath, + workflowId: workspace.workflowId, + sessionId: workspace.sessionId, + ...(args.configPath && { configPath: args.configPath }), + ...(args.pipelineTestingMode && { pipelineTestingMode: args.pipelineTestingMode }), + ...(workspace.isResume && args.resumeFromWorkspace && { resumeFromWorkspace: args.resumeFromWorkspace }), + ...(workspace.terminatedWorkflows.length > 0 && { terminatedWorkflows: workspace.terminatedWorkflows }), + ...(Object.keys(pipelineConfig).length > 0 && { pipelineConfig }), + }; +} + +// === Workflow Result Handling === + +async function waitForWorkflowResult( + handle: WorkflowHandle<(input: PipelineInput) => Promise>, + workspace: WorkspaceResolution +): Promise { + const progressInterval = setInterval(async () => { + try { + const progress = await handle.query(PROGRESS_QUERY); + const elapsed = Math.floor(progress.elapsedMs / 1000); + console.log( + `[${elapsed}s] Phase: ${progress.currentPhase || 'unknown'} | Agent: ${progress.currentAgent || 'none'} | Completed: ${progress.completedAgents.length}/13` + ); + } catch { + // Workflow may have completed + } + }, 30000); + + try { + const result = await handle.result(); + clearInterval(progressInterval); + + console.log('\nPipeline completed successfully!'); + if (result.summary) { + console.log(`Duration: ${Math.floor(result.summary.totalDurationMs / 1000)}s`); + console.log(`Agents completed: ${result.summary.agentCount}`); + console.log(`Total turns: ${result.summary.totalTurns}`); + console.log(`Run cost: $${result.summary.totalCostUsd.toFixed(4)}`); + + if (workspace.isResume) { + try { + const session = await readJson( + path.join('./workspaces', workspace.sessionId, 'session.json') + ); + console.log(`Cumulative cost: $${session.metrics.total_cost_usd.toFixed(4)}`); + } catch { + // Non-fatal + } + } + } + } catch (error) { + clearInterval(progressInterval); + console.error('\nPipeline failed:', error); + process.exit(1); + } +} + +// === Deliverables Copy === + +function copyDeliverables(repoPath: string, outputPath: string): void { + const deliverablesDir = path.join(repoPath, 'deliverables'); + if (!fs.existsSync(deliverablesDir)) { + console.log('No deliverables directory found, skipping copy'); + return; + } + + const files = fs.readdirSync(deliverablesDir); + if (files.length === 0) { + console.log('No deliverables to copy'); + return; + } + + fs.mkdirSync(outputPath, { recursive: true }); + + for (const file of files) { + const src = path.join(deliverablesDir, file); + const dest = path.join(outputPath, file); + fs.cpSync(src, dest, { recursive: true }); + } + + console.log(`Copied ${files.length} deliverable(s) to ${outputPath}`); +} + +// === Main Entry Point === + +async function run(): Promise { + // 1. Parse CLI args + const args = parseCliArgs(process.argv.slice(2)); + + // 2. Connect to Temporal server const address = process.env.TEMPORAL_ADDRESS || 'localhost:7233'; console.log(`Connecting to Temporal at ${address}...`); const connection = await NativeConnection.connect({ address }); - - // Bundle workflows for Temporal's V8 isolate - console.log('Bundling workflows...'); - const workflowBundle = await bundleWorkflowCode({ - workflowsPath: path.join(__dirname, 'workflows.js'), - }); - - const worker = await Worker.create({ - connection, - namespace: 'default', - workflowBundle, - activities, - taskQueue: 'shannon-pipeline', - maxConcurrentActivityTaskExecutions: 25, // Support multiple parallel workflows (5 agents × ~5 workflows) - }); - - // Graceful shutdown handling - const shutdown = async (): Promise => { - console.log('\nShutting down worker...'); - worker.shutdown(); - }; - - process.on('SIGINT', shutdown); - process.on('SIGTERM', shutdown); - - console.log('Shannon worker started'); - console.log('Task queue: shannon-pipeline'); - console.log('Press Ctrl+C to stop\n'); + const clientConnection = await Connection.connect({ address }); + const client = new Client({ connection: clientConnection }); try { - await worker.run(); + // 3. Bundle workflows and create worker on per-invocation task queue + console.log('Bundling workflows...'); + const workflowBundle = await bundleWorkflowCode({ + workflowsPath: path.join(__dirname, 'workflows.js'), + }); + + const worker = await Worker.create({ + connection, + namespace: 'default', + workflowBundle, + activities, + taskQueue: args.taskQueue, + maxConcurrentActivityTaskExecutions: 25, + }); + + // 4. Resolve workspace and build pipeline input + const workspace = await resolveWorkspace(client, args); + const pipelineConfig = await loadPipelineConfig(args.configPath); + const input = buildPipelineInput(args, workspace, pipelineConfig); + + // 5. Start worker polling in the background + const workerDone = worker.run(); + + // 6. Submit workflow to the same task queue + const handle = await client.workflow.start<(input: PipelineInput) => Promise>( + 'pentestPipelineWorkflow', + { + taskQueue: args.taskQueue, + workflowId: workspace.workflowId, + args: [input], + } + ); + + // 7. Wait for workflow result + await waitForWorkflowResult(handle, workspace); + + // 8. Copy deliverables to output directory + if (args.outputPath) { + copyDeliverables(args.repoPath, args.outputPath); + } + + // 9. Shut down worker gracefully + worker.shutdown(); + await workerDone; } finally { await connection.close(); - console.log('Worker stopped'); + await clientConnection.close(); } } -runWorker().catch((err) => { +run().catch((err) => { console.error('Worker failed:', err); process.exit(1); }); diff --git a/src/temporal/workspaces.ts b/src/temporal/workspaces.ts index 62d6b29..28f6e37 100644 --- a/src/temporal/workspaces.ts +++ b/src/temporal/workspaces.ts @@ -8,14 +8,14 @@ /** * Workspace listing tool for Shannon. * - * Reads audit-logs/ directories, parses session.json files, and displays + * Reads workspaces/ directories, parses session.json files, and displays * a formatted table of all workspaces with status, duration, and cost. * * Usage: * node dist/temporal/workspaces.js * * Environment: - * AUDIT_LOGS_DIR - Override audit-logs directory (default: ./audit-logs) + * WORKSPACES_DIR - Override workspaces directory (default: ./workspaces) */ import fs from 'fs/promises'; @@ -67,21 +67,21 @@ function truncate(str: string, maxLen: number): string { } async function listWorkspaces(): Promise { - const auditDir = process.env.AUDIT_LOGS_DIR || './audit-logs'; + const workspacesDir = process.env.WORKSPACES_DIR || './workspaces'; let entries: string[]; try { - entries = await fs.readdir(auditDir); + entries = await fs.readdir(workspacesDir); } catch { - console.log('No audit-logs directory found.'); - console.log(`Expected: ${auditDir}`); + console.log('No workspaces directory found.'); + console.log(`Expected: ${workspacesDir}`); return; } const workspaces: WorkspaceInfo[] = []; for (const entry of entries) { - const sessionPath = path.join(auditDir, entry, 'session.json'); + const sessionPath = path.join(workspacesDir, entry, 'session.json'); try { const content = await fs.readFile(sessionPath, 'utf8'); const data = JSON.parse(content) as SessionJson; @@ -101,7 +101,7 @@ async function listWorkspaces(): Promise { if (workspaces.length === 0) { console.log('\nNo workspaces found.'); - console.log('Run a pipeline first: ./shannon start URL= REPO='); + console.log('Run a pipeline first: ./shannon start -u -r '); return; } @@ -161,7 +161,7 @@ async function listWorkspaces(): Promise { console.log(`${summary}${resumeSummary}`); if (resumableCount > 0) { - console.log('\nResume with: ./shannon start URL= REPO= WORKSPACE='); + console.log('\nResume with: ./shannon start -u -r -w '); } console.log(); diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..3cec30d --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "module": "nodenext", + "moduleResolution": "nodenext", + "target": "es2022", + "lib": ["es2022"], + "types": ["node"], + + "resolveJsonModule": true, + "forceConsistentCasingInFileNames": true, + "noEmitOnError": true, + + "sourceMap": true, + "declaration": true, + "declarationMap": true, + + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + + "noImplicitReturns": true, + "noImplicitOverride": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + + "strict": true, + "noUncheckedSideEffectImports": true, + "skipLibCheck": true + } +} diff --git a/tsconfig.json b/tsconfig.json index 1222629..5bf6a7e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,56 +1,9 @@ { - // Visit https://aka.ms/tsconfig to read more about this file + "extends": "./tsconfig.base.json", "compilerOptions": { - // File Layout "rootDir": "./src", - "outDir": "./dist", - - // Environment Settings - // See also https://aka.ms/tsconfig/module - "module": "nodenext", - "moduleResolution": "nodenext", - - "target": "es2022", - "lib": ["es2022"], - - "types": ["node"], - // For nodejs: - // "lib": ["esnext"], - // "types": ["node"], - // and npm install -D @types/node - - "resolveJsonModule": true, - "forceConsistentCasingInFileNames": true, - "noEmitOnError": true, - - // Other Outputs - "sourceMap": true, - "declaration": true, - "declarationMap": true, - - // Stricter Typechecking Options - "noUncheckedIndexedAccess": true, - "exactOptionalPropertyTypes": true, - - // Style Options - "noImplicitReturns": true, - "noImplicitOverride": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true, - // "noPropertyAccessFromIndexSignature": true, - - // Recommended Options - "strict": true, - "noUncheckedSideEffectImports": true, - "skipLibCheck": true, + "outDir": "./dist" }, - "include": [ - "src/**/*" - ], - "exclude": [ - "node_modules", - "dist", - "mcp-server" - ] + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "mcp-server"] } diff --git a/audit-logs/.gitkeep b/workspaces/.gitkeep similarity index 100% rename from audit-logs/.gitkeep rename to workspaces/.gitkeep