From 8989f209d9ce4f4751ee92380847af5261d3afed Mon Sep 17 00:00:00 2001 From: Klaus Agnoletti Date: Fri, 6 Mar 2026 17:13:03 +0100 Subject: [PATCH 1/8] Add TabletopExercise MCP server (Issue #212) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Converts the TabletopExercise skill into an MCP server so AI coding agents can enrich M&M scenario cards in a schema-validated, additive-only way. New files: - generators/schema.ts — Zod v3 schemas as single source of truth for types, MCP validation, and the tabletop://schema resource - generators/mcp-server.ts — MCP server (StdioServerTransport) with 6 tools and 3 resources - generators/test-mcp.ts — integration tests via InMemoryTransport (39/39 passing) - CLAUDE.md — project coding standards (security, TypeScript, MCP tool design, code quality) - .gitignore — excludes node_modules and generated test HTML Modified files: - generators/generate-pdf.ts — exports generateTabletopHTML(data, mode) wrapper; existing generatePDF and CLI entry point untouched - generators/package.json — adds @modelcontextprotocol/sdk, zod, zod-to-json-schema; adds mcp and test scripts Tools: check_scenario_completeness, validate_exercise_data, generate_exercise, merge_exercise_data, validate_m_and_m_formatting, list_scenario_cards Resources: tabletop://schema, tabletop://atomics, tabletop://template Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 3 + CLAUDE.md | 115 ++++ TabletopExercise/generators/bun.lock | 213 +++++++ TabletopExercise/generators/generate-pdf.ts | 31 + TabletopExercise/generators/mcp-server.ts | 599 ++++++++++++++++++++ TabletopExercise/generators/package.json | 9 +- TabletopExercise/generators/schema.ts | 208 +++++++ TabletopExercise/generators/test-mcp.ts | 386 +++++++++++++ 8 files changed, 1562 insertions(+), 2 deletions(-) create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 TabletopExercise/generators/bun.lock create mode 100644 TabletopExercise/generators/mcp-server.ts create mode 100644 TabletopExercise/generators/schema.ts create mode 100644 TabletopExercise/generators/test-mcp.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6bdda49 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +TabletopExercise/generators/node_modules/ +TabletopExercise/generators/facilitator.html +TabletopExercise/generators/participant.html diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c3d9526 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,115 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +This is the **TabletopExercise PAI Skill** — a cybersecurity tabletop exercise design and facilitation framework. It is a Claude skill (not a standalone app) that generates professional exercise materials from structured JSON data. + +The skill produces three output formats for each exercise: +- **Facilitator HTML** — full content with facilitator notes, expected answers, timing guidance +- **Participant HTML** — clean version with spoilers hidden +- **PDF** — professional client-ready document (via Playwright/Chromium) + +## Directory Structure + +``` +TabletopExercise/ +├── SKILL.md # Main skill definition loaded by PAI +├── ATOMICS-LIBRARY.md # Pre-built atomic inject sequences +├── README.md # Usage documentation +├── generators/ # TypeScript generators (run with bun) +│ ├── generate-html.ts # Standalone HTML generator +│ ├── generate-html-new.ts # Updated standalone HTML generator +│ ├── generate-html-standalone.ts +│ ├── generate-both.ts # Generates facilitator + participant versions +│ ├── generate-pdf.ts # PDF generator (core logic + HTML renderer) +│ └── package.json +├── templates/ +│ └── tabletop-exercise.html +└── examples/ + ├── ssrf-aws-compromise/ # SSRF → AWS credential theft scenario + └── rainbow-six-ddos-attack/ # DDoS scenario +``` + +## Generator Commands + +All generators use **Bun** (not Node/npm). + +```bash +cd TabletopExercise/generators + +# Install dependencies (first time only) +bun add playwright && bunx playwright install chromium + +# Generate both facilitator and participant HTML +bun run generate-both.ts ../examples/[slug]/exercise-data.json + +# Generate standalone HTML +bun run generate-html.ts --input ../examples/[slug]/exercise-data.json --output ../examples/[slug]/ + +# Generate PDF +bun run generate-pdf.ts +``` + +## Exercise Data Format + +Each exercise lives in `examples/[slug]/exercise-data.json`. Key top-level fields: + +```json +{ + "title": "...", + "severity": "CRITICAL|HIGH|MEDIUM|LOW", + "targetAudience": "...", + "facilitatorGuide": { ... }, + "timelineEvents": [ { "time": "T+0", "title": "...", "facilitatorNotes": {...} } ], + "gapAnalysis": [ ... ], + "atomics": [ ... ] +} +``` + +The `generate-pdf.ts` file contains the `generateTabletopHTML(data, mode)` function where `mode` is `'facilitator'` or `'participant'`. Participant mode hides facilitator-only fields. + +## Architecture + +- **SKILL.md** is the PAI skill definition — it defines how Claude should behave when invoked as the `TabletopExercise` skill. It is not a script to run. +- **ATOMICS-LIBRARY.md** provides reusable inject sequences that can be referenced when building `timelineEvents` in exercise JSON. +- **generators/** are TypeScript scripts consumed directly by Bun (no build step needed). +- HTML output is fully self-contained (CSS/JS inlined, no external dependencies). + +## Deployment Context + +This skill is used within the PAI (Personal AI) framework. The generators run locally or on the production server. Do not run Docker containers locally — testing happens in production. + +# Coding Standards + +## Security + +- **Input validation first**: All tool inputs are validated against the Zod schema before any file I/O or generator calls. Never process unvalidated data. + +- **Path traversal prevention (OWASP A01)**: When accepting file paths as tool input (e.g. qmd_path in check_scenario_completeness), resolve and verify the path stays within the expected base directory before reading. Reject paths containing `..`. + +- **No injection (OWASP A03)**: Never pass tool input directly to shell commands, template strings, or eval. All generator calls use direct function imports — no child_process or exec. + +- **Dependency hygiene (OWASP A06)**: Minimize dependencies. This project uses @modelcontextprotocol/sdk, zod, and zod-to-json-schema only. Add nothing else without a strong reason. + +- **No secrets in code**: API keys, tokens, or credentials must come from environment variables. Never hardcode or log them. + +## TypeScript + +- Strict mode always. No `any` — use `z.infer` for Zod-derived types. +- Explicit return types on all exported functions. +- Prefer `unknown` over `any` when the type is genuinely unknown, then narrow it. + +## MCP Tool Design + +- Tools NEVER throw. Return structured error objects instead so the agent can read, fix, and retry. Only `validate_exercise_data` and `check_scenario_completeness` return errors as data — all others call validate_exercise_data internally first. +- Resources are read-only. No resource handler modifies state. +- The Zod schema in schema.ts is the single source of truth — TypeScript types, MCP validation, and the tabletop://schema resource all derive from it. Never duplicate type definitions. + +## Code Quality + +- Small, focused functions. If a function needs a comment to explain what it does, it should probably be split or renamed. +- DRY: if the same logic appears twice, extract it. +- Parse atomics at startup once, index by ID and category — don't re-parse on every tool call. +- Explicit error handling — no silent catch blocks. diff --git a/TabletopExercise/generators/bun.lock b/TabletopExercise/generators/bun.lock new file mode 100644 index 0000000..59301e0 --- /dev/null +++ b/TabletopExercise/generators/bun.lock @@ -0,0 +1,213 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "@tabletop-exercise/pdf-generator", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.27.1", + "playwright": "^1.40.0", + "zod": "^3.22.0", + "zod-to-json-schema": "^3.25.1", + }, + "devDependencies": { + "@types/node": "^20.10.0", + }, + }, + }, + "packages": { + "@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="], + + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.27.1", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA=="], + + "@types/node": ["@types/node@20.19.37", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw=="], + + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + + "ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], + + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + + "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], + + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + + "content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="], + + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + + "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + + "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], + + "eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="], + + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], + + "express-rate-limit": ["express-rate-limit@8.3.0", "", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-KJzBawY6fB9FiZGdE/0aftepZ91YlaGIrV8vgblRM3J8X+dHx/aiowJWwkx6LIGyuqGiANsjSwwrbb8mifOJ4Q=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], + + "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], + + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + + "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + + "hono": ["hono@4.12.5", "", {}, "sha512-3qq+FUBtlTHhtYxbxheZgY8NIFnkkC/MR8u5TTsr7YZ3wixryQ3cCwn3iZbg8p8B88iDBBAYSfZDS75t8MN7Vg=="], + + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + + "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], + + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "jose": ["jose@6.2.0", "", {}, "sha512-xsfE1TcSCbUdo6U07tR0mvhg0flGxU8tPLbF03mirl2ukGQENhUg4ubGYQnhVH0b5stLlPM+WOqDkEl1R1y5sQ=="], + + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], + + "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="], + + "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + + "playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="], + + "playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="], + + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + + "qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="], + + "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], + + "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], + + "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], + + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], + + "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + + "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], + + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="], + + "@modelcontextprotocol/sdk/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + } +} diff --git a/TabletopExercise/generators/generate-pdf.ts b/TabletopExercise/generators/generate-pdf.ts index e4f45b8..2651f7b 100644 --- a/TabletopExercise/generators/generate-pdf.ts +++ b/TabletopExercise/generators/generate-pdf.ts @@ -1791,4 +1791,35 @@ if (import.meta.main) { }); } +/** + * Generate a complete HTML document from exercise data. + * + * mode='facilitator' — full document including facilitator notes, + * expected responses, conditional responses, and gap analysis. + * mode='participant' — spoilers stripped: no expectedResponse, + * no conditionalResponses, no discussionQuestions, no gap analysis, + * no facilitatorGuide. + */ +export function generateTabletopHTML( + data: TabletopExerciseData, + mode: 'facilitator' | 'participant' +): string { + if (mode === 'facilitator') { + return buildHtml(data); + } + const participantData: TabletopExerciseData = { + ...data, + facilitatorGuide: undefined as unknown as TabletopExerciseData['facilitatorGuide'], + injects: data.injects.map(inject => ({ + ...inject, + expectedResponse: '', + conditionalResponses: undefined, + discussionQuestions: undefined, + })), + gaps: [], + gapStats: { critical: 0, high: 0, medium: 0, low: 0 }, + }; + return buildHtml(participantData); +} + export { generatePDF, type TabletopExerciseData }; diff --git a/TabletopExercise/generators/mcp-server.ts b/TabletopExercise/generators/mcp-server.ts new file mode 100644 index 0000000..732627b --- /dev/null +++ b/TabletopExercise/generators/mcp-server.ts @@ -0,0 +1,599 @@ +#!/usr/bin/env bun + +/** + * TabletopExercise MCP Server + * + * Exposes 6 tools and 3 resources so AI coding agents can enrich + * M&M scenario cards in a schema-validated, additive-only way. + * + * Start: + * bun run mcp-server.ts + * + * Register with Claude: + * claude mcp add tabletop-exercise -- bun run generators/mcp-server.ts + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { z } from 'zod'; +import { zodToJsonSchema } from 'zod-to-json-schema'; +import { readFile, writeFile, readdir } from 'fs/promises'; +import { resolve, join, extname, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +import { + TabletopExerciseDataSchema, + ScenarioTypeSchema, + type ScenarioType, + type SectionPresence, +} from './schema.ts'; +import { generateTabletopHTML } from './generate-pdf.ts'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// --------------------------------------------------------------------------- +// Security helpers +// --------------------------------------------------------------------------- + +/** Reject any path containing ".." to prevent path traversal (OWASP A01). */ +function rejectTraversal(p: string): string | null { + if (p.includes('..')) return null; + return p; +} + +// --------------------------------------------------------------------------- +// Atomics content — parsed once at startup, never re-read per call +// --------------------------------------------------------------------------- + +const ATOMICS_PATH = resolve(__dirname, '../ATOMICS-LIBRARY.md'); +let atomicsContent = '(Atomics library not found)'; +try { + atomicsContent = await readFile(ATOMICS_PATH, 'utf-8'); +} catch (err) { + process.stderr.write(`Warning: could not load ATOMICS-LIBRARY.md: ${String(err)}\n`); +} + +// --------------------------------------------------------------------------- +// Known M&M malmon family names (update with actual names from the M&M project) +// --------------------------------------------------------------------------- + +const MALMON_FAMILIES: readonly string[] = [ + // Placeholder — replace with real family names from klausagnoletti/malware-and-monsters + // e.g. 'Crypton', 'Vipera', 'Rottenberg' +]; + +// --------------------------------------------------------------------------- +// Section detection — shared between check_scenario_completeness and list_scenario_cards +// --------------------------------------------------------------------------- + +const SECTION_PATTERNS: Array<{ name: string; patterns: RegExp[] }> = [ + { name: 'npc_dialogue', patterns: [/npc.{0,20}dialogue/i, /## NPC/i, /scripted.*dialogue/i] }, + { name: 'inject_timeline', patterns: [/## Injects/i, /## Timeline/i, /T\+\d+/i, /inject.*sequence/i] }, + { name: 'gap_analysis', patterns: [/## Gap Analysis/i, /gap analysis/i, /## Gaps/i] }, + { name: 'artifacts', patterns: [/## Artifacts/i, /artifact.*handout/i, /\[handout\]/i] }, + { name: 'branching', patterns: [/## Branch/i, /conditional.*response/i, /if.*then.*inject/i, /decision.*tree/i] }, + { name: 'facilitator_notes', patterns: [/facilitator.{0,10}note/i, /## Facilitator/i] }, + { name: 'objectives', patterns: [/## Objectives/i, /learning objectives/i] }, + { name: 'atomics', patterns: [/## Atomics/i, /atomic.*sequence/i, /ATOMIC-ID/i] }, +]; + +const ALL_SECTION_NAMES = SECTION_PATTERNS.map(s => s.name); + +function detectSections(content: string, filePath: string): SectionPresence { + let scenario_type: ScenarioType = 'contemporary'; + + // Check frontmatter 'type' field + const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/); + if (frontmatterMatch) { + const typeMatch = frontmatterMatch[1].match(/^type:\s*(.+)$/m); + if (typeMatch) { + const val = typeMatch[1].trim().replace(/['"]/g, ''); + if (val === 'historical') scenario_type = 'historical'; + } + } + + // Directory name heuristic + if (filePath.includes('historical-foundation')) { + scenario_type = 'historical'; + } + + const present: string[] = []; + const missing: string[] = []; + + for (const section of SECTION_PATTERNS) { + if (section.patterns.some(p => p.test(content))) { + present.push(section.name); + } else { + missing.push(section.name); + } + } + + return { present, missing, scenario_type }; +} + +// --------------------------------------------------------------------------- +// MCP Server factory — exported so tests can connect via InMemoryTransport +// --------------------------------------------------------------------------- + +export function createServer(): McpServer { +const server = new McpServer({ + name: 'tabletop-exercise', + version: '1.0.0', +}); + +// --------------------------------------------------------------------------- +// Resources (read-only) +// --------------------------------------------------------------------------- + +server.resource( + 'tabletop-schema', + 'tabletop://schema', + async (_uri) => ({ + contents: [{ + uri: 'tabletop://schema', + mimeType: 'application/json', + text: JSON.stringify( + zodToJsonSchema(TabletopExerciseDataSchema, { name: 'TabletopExerciseData' }), + null, + 2 + ), + }], + }) +); + +server.resource( + 'tabletop-atomics', + 'tabletop://atomics', + async (_uri) => ({ + contents: [{ + uri: 'tabletop://atomics', + mimeType: 'text/markdown', + text: atomicsContent, + }], + }) +); + +server.resource( + 'tabletop-template', + 'tabletop://template', + async (_uri) => ({ + contents: [{ + uri: 'tabletop://template', + mimeType: 'text/markdown', + text: `# TabletopExercise HTML Template Reference + +This document maps each schema field to its rendered position in the HTML output. + +## Cover Page +- \`title\` → H1 heading on cover page +- \`subtitle\` → Subtitle line beneath title +- \`severity\` → Color-coded badge: CRITICAL=red, HIGH=orange, MEDIUM=yellow, LOW=green +- \`scenarioType\` → Scenario type label +- \`targetAudience\` → Audience specification +- \`duration\` → Estimated exercise duration +- \`difficulty\` → Difficulty level +- \`preparedBy\` → Author attribution +- \`date\` → Exercise date +- \`version\` → Document version number + +## Executive Summary Section +- \`executiveSummary\` → Full narrative paragraph +- \`attackVector\` → Attack vector description box +- \`potentialImpact\` → Impact assessment box +- \`testingGoals\` → Goals paragraph +- \`criticalGaps\` → Pre-identified gaps summary + +## Scenario Overview +- \`scenarioOverview\` → Narrative paragraph +- \`timelineEvents[]\` → Visual timeline: time marker + title + description + severity badge + +## Learning Objectives +- \`objectives[].number\` → Objective number +- \`objectives[].title\` → Bold objective title +- \`objectives[].description\` → Detail text +- \`objectives[].successCriteria[]\` → Bulleted success criteria + +## Injects (Core Exercise Content) +- \`injects[].id\` → Inject identifier (e.g. "INJ-001") +- \`injects[].time\` → Time marker (e.g. "T+15") +- \`injects[].title\` → Inject title shown to all participants +- \`injects[].severity\` → Severity badge +- \`injects[].scenario\` → READ-ALOUD narrative (shown to both facilitator and participant) + - Contemporary rule: symptom-only language, no malmon family names + - Historical rule: may name the malmon +- \`injects[].artifact\` → Reference to an artifact handout +- \`injects[].expectedResponse\` → FACILITATOR-ONLY: what participants should do +- \`injects[].discussionQuestions[]\` → FACILITATOR-ONLY: discussion prompts +- \`injects[].conditionalResponses[]\` → FACILITATOR-ONLY: if-then response trees + +## Atomics (Technical Exercise Execution) +- \`atomics[].id\` → Atomic identifier (e.g. "PHISH-001") +- \`atomics[].time\` → Execution time offset +- \`atomics[].title\` → Atomic title +- \`atomics[].action\` → Runner action to perform +- \`atomics[].commands\` → Code/command block +- \`atomics[].commandLanguage\` → Syntax highlighting hint +- \`atomics[].expectedResponse\` → Expected participant reaction +- \`atomics[].fallback\` → What to do if participants don't respond +- \`atomics[].verification[]\` → Pre-exercise verification checklist items + +## Gap Analysis +- \`gapStats.critical/high/medium/low\` → Statistics dashboard counters +- \`gaps[].priority\` → Priority badge +- \`gaps[].title\` → Gap title +- \`gaps[].status\` → Current remediation status +- \`gaps[].trigger\` → Which inject/moment revealed this gap +- \`gaps[].requiredProcedures[]\` → Procedures needed to close the gap +- \`gaps[].impact\` → Business impact if gap is not closed +- \`gaps[].recommendation\` → Actionable remediation guidance + +## M&M Enrichment Fields (stored in JSON, not yet rendered by base HTML renderer) +- \`npcDialogue[]\` → Scripted NPC interactions for facilitator reference during exercise +- \`artifacts[]\` → Full handout content linked to specific injects +- \`scenario_type\` → "contemporary" | "historical" — controls M&M naming and debrief rules + +## M&M Naming Rules by Scenario Type +| Field | Contemporary | Historical | +|---|---|---| +| inject.scenario | Symptom-only, no malmon names | May name the malmon | +| artifacts | Fictional organizations only | Real historical events/dates OK | +| Debrief framing | "your organization" | "lessons from history" | +| scenario_type field | Optional (defaults to contemporary) | Must be set to "historical" | +`, + }], + }) +); + +// --------------------------------------------------------------------------- +// Tool 1: check_scenario_completeness +// --------------------------------------------------------------------------- + +server.tool( + 'check_scenario_completeness', + 'Parse a M&M scenario card (.qmd) and detect which enrichment sections are present vs missing. Returns scenario_type inferred from frontmatter or path.', + { + qmd_path: z.string().describe('Path to a .qmd scenario card file'), + }, + async ({ qmd_path }) => { + if (!rejectTraversal(qmd_path)) { + return { content: [{ type: 'text' as const, text: JSON.stringify({ error: 'Path traversal rejected: path must not contain ".."' }) }] }; + } + + let content: string; + try { + content = await readFile(qmd_path, 'utf-8'); + } catch (err) { + return { content: [{ type: 'text' as const, text: JSON.stringify({ error: `Cannot read file: ${String(err)}` }) }] }; + } + + const result = detectSections(content, qmd_path); + return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] }; + } +); + +// --------------------------------------------------------------------------- +// Tool 2: validate_exercise_data +// --------------------------------------------------------------------------- + +server.tool( + 'validate_exercise_data', + 'Validate a raw JSON object against TabletopExerciseDataSchema. Returns structured errors — never throws.', + { + data: z.record(z.unknown()).describe('Raw exercise data object to validate'), + }, + async ({ data }) => { + const result = TabletopExerciseDataSchema.safeParse(data); + + if (result.success) { + return { content: [{ type: 'text' as const, text: JSON.stringify({ valid: true, errors: [] }) }] }; + } + + const errors = result.error.issues.map(issue => ({ + path: issue.path.join('.') || '(root)', + message: issue.message, + })); + + return { content: [{ type: 'text' as const, text: JSON.stringify({ valid: false, errors }) }] }; + } +); + +// --------------------------------------------------------------------------- +// Tool 3: generate_exercise +// --------------------------------------------------------------------------- + +server.tool( + 'generate_exercise', + 'Validate exercise JSON then generate facilitator.html and participant.html into output_dir.', + { + exercise_data_path: z.string().describe('Path to exercise-data.json'), + output_dir: z.string().describe('Directory to write HTML output files'), + }, + async ({ exercise_data_path, output_dir }) => { + if (!rejectTraversal(exercise_data_path) || !rejectTraversal(output_dir)) { + return { content: [{ type: 'text' as const, text: JSON.stringify({ error: 'Path traversal rejected' }) }] }; + } + + let raw: unknown; + try { + raw = JSON.parse(await readFile(exercise_data_path, 'utf-8')); + } catch (err) { + return { content: [{ type: 'text' as const, text: JSON.stringify({ error: `Cannot read exercise data: ${String(err)}` }) }] }; + } + + const parsed = TabletopExerciseDataSchema.safeParse(raw); + if (!parsed.success) { + const errors = parsed.error.issues.map(i => ({ path: i.path.join('.'), message: i.message })); + return { content: [{ type: 'text' as const, text: JSON.stringify({ error: 'Validation failed before generation', errors }) }] }; + } + + try { + const facilitatorHtml = generateTabletopHTML(parsed.data as Parameters[0], 'facilitator'); + const participantHtml = generateTabletopHTML(parsed.data as Parameters[0], 'participant'); + + const facilPath = join(output_dir, 'facilitator.html'); + const partPath = join(output_dir, 'participant.html'); + + await writeFile(facilPath, facilitatorHtml, 'utf-8'); + await writeFile(partPath, participantHtml, 'utf-8'); + + return { + content: [{ + type: 'text' as const, + text: JSON.stringify({ facilitator_html: facilPath, participant_html: partPath }), + }], + }; + } catch (err) { + return { content: [{ type: 'text' as const, text: JSON.stringify({ error: `HTML generation failed: ${String(err)}` }) }] }; + } + } +); + +// --------------------------------------------------------------------------- +// Tool 4: merge_exercise_data +// --------------------------------------------------------------------------- + +server.tool( + 'merge_exercise_data', + 'Additive-only deep merge: write only top-level keys that are absent from the existing JSON. Validates the merged result, then writes it back.', + { + base_path: z.string().describe('Path to existing exercise-data.json'), + additions: z.record(z.unknown()).describe('Top-level keys to add (existing keys are never overwritten)'), + }, + async ({ base_path, additions }) => { + if (!rejectTraversal(base_path)) { + return { content: [{ type: 'text' as const, text: JSON.stringify({ error: 'Path traversal rejected' }) }] }; + } + + let base: Record; + try { + base = JSON.parse(await readFile(base_path, 'utf-8')); + } catch (err) { + return { content: [{ type: 'text' as const, text: JSON.stringify({ error: `Cannot read base file: ${String(err)}` }) }] }; + } + + const sectionsAdded: string[] = []; + const merged: Record = { ...base }; + + for (const [key, value] of Object.entries(additions)) { + if (!(key in merged)) { + merged[key] = value; + sectionsAdded.push(key); + } + } + + const validation = TabletopExerciseDataSchema.safeParse(merged); + if (!validation.success) { + const errors = validation.error.issues.map(i => ({ path: i.path.join('.'), message: i.message })); + return { content: [{ type: 'text' as const, text: JSON.stringify({ error: 'Merged data failed schema validation', errors }) }] }; + } + + try { + await writeFile(base_path, JSON.stringify(merged, null, 2), 'utf-8'); + } catch (err) { + return { content: [{ type: 'text' as const, text: JSON.stringify({ error: `Cannot write merged file: ${String(err)}` }) }] }; + } + + return { + content: [{ + type: 'text' as const, + text: JSON.stringify({ merged_path: base_path, sections_added: sectionsAdded }), + }], + }; + } +); + +// --------------------------------------------------------------------------- +// Tool 5: validate_m_and_m_formatting +// --------------------------------------------------------------------------- + +server.tool( + 'validate_m_and_m_formatting', + 'Apply M&M-specific formatting rules beyond Zod schema. Checks contemporary/historical naming rules, debrief framing, and required fields.', + { + data: z.record(z.unknown()).describe('Exercise data object to validate'), + scenario_type: ScenarioTypeSchema.describe('Scenario type determines which rule-set applies'), + }, + async ({ data, scenario_type }) => { + // Validate against full schema before applying M&M rules + const schemaCheck = TabletopExerciseDataSchema.safeParse(data); + if (!schemaCheck.success) { + const errors = schemaCheck.error.issues.map(i => ({ path: i.path.join('.'), message: i.message })); + return { content: [{ type: 'text' as const, text: JSON.stringify({ error: 'Schema validation failed before M&M checks', errors }) }] }; + } + + try { + const violations: Array<{ rule: string; location: string; details: string }> = []; + const dataText = JSON.stringify(data).toLowerCase(); + + if (scenario_type === 'contemporary') { + // Rule: inject narrative must not contain malmon family names + const injects = (data as Record).injects; + if (Array.isArray(injects)) { + for (const inject of injects) { + const injectRecord = inject as Record; + const narrativeText = [injectRecord.scenario, injectRecord.title] + .filter((v): v is string => typeof v === 'string') + .join(' '); + + for (const family of MALMON_FAMILIES) { + if (new RegExp(`\\b${family}\\b`, 'i').test(narrativeText)) { + violations.push({ + rule: 'contemporary_no_malmon_name', + location: `inject[${String(injectRecord.id ?? '?')}].scenario`, + details: `Contemporary inject narrative must not name malmon family "${family}". Use symptom-only descriptions.`, + }); + } + } + } + } + + // Rule: artifacts must not reference real-world organizations + const artifacts = (data as Record).artifacts; + if (Array.isArray(artifacts)) { + const realOrgPatterns: Array<[RegExp, string]> = [ + [/\bmicrosoft\b/i, 'Microsoft'], + [/\bgoogle\b/i, 'Google'], + [/\bamazon\b/i, 'Amazon'], + [/\bapple\b/i, 'Apple'], + [/\bcisco\b/i, 'Cisco'], + ]; + for (const artifact of artifacts) { + const artifactRecord = artifact as Record; + const content = String(artifactRecord.content ?? ''); + for (const [pattern, orgName] of realOrgPatterns) { + if (pattern.test(content)) { + violations.push({ + rule: 'contemporary_fictional_orgs_only', + location: `artifact[${String(artifactRecord.id ?? '?')}].content`, + details: `Contemporary artifacts must use fictional organization names. Found real-world reference: "${orgName}".`, + }); + } + } + } + } + + // Rule: debrief framing must be "your org" / "your organization" + if ( + dataText.includes('debrief') && + !dataText.includes('your org') && + !dataText.includes('your organization') + ) { + violations.push({ + rule: 'contemporary_debrief_framing', + location: 'debrief section', + details: 'Contemporary debrief must use "your organization"/"your org" framing, not historical framing.', + }); + } + } + + if (scenario_type === 'historical') { + // Rule: scenario_type field must be set in data + if ((data as Record).scenario_type !== 'historical') { + violations.push({ + rule: 'historical_type_field_required', + location: 'data.scenario_type', + details: 'Historical scenarios must set scenario_type: "historical" in the exercise data JSON.', + }); + } + + // Rule: debrief should use "lessons from history" framing + if ( + dataText.includes('debrief') && + !dataText.includes('lessons from') + ) { + violations.push({ + rule: 'historical_debrief_framing', + location: 'debrief section', + details: 'Historical scenario debrief should use "lessons from history" framing.', + }); + } + } + + return { + content: [{ + type: 'text' as const, + text: JSON.stringify({ valid: violations.length === 0, violations }), + }], + }; + } catch (err) { + return { content: [{ type: 'text' as const, text: JSON.stringify({ error: `M&M validation error: ${String(err)}` }) }] }; + } + } +); + +// --------------------------------------------------------------------------- +// Tool 6: list_scenario_cards +// --------------------------------------------------------------------------- + +server.tool( + 'list_scenario_cards', + 'Walk a directory tree, find all .qmd scenario cards, and return a completeness summary for each.', + { + scenarios_dir: z.string().describe('Root directory to walk for .qmd files'), + }, + async ({ scenarios_dir }) => { + if (!rejectTraversal(scenarios_dir)) { + return { content: [{ type: 'text' as const, text: JSON.stringify({ error: 'Path traversal rejected' }) }] }; + } + + const qmdFiles: string[] = []; + + async function walkDir(dir: string): Promise { + let entries; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch (err) { + process.stderr.write(`Warning: cannot read directory ${dir}: ${String(err)}\n`); + return; + } + for (const entry of entries) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + await walkDir(full); + } else if (entry.isFile() && extname(entry.name) === '.qmd') { + qmdFiles.push(full); + } + } + } + + try { + await walkDir(resolve(scenarios_dir)); + } catch (err) { + return { content: [{ type: 'text' as const, text: JSON.stringify({ error: `Cannot walk directory: ${String(err)}` }) }] }; + } + + const results: Array< + | { path: string; scenario_type: string; present: string[]; missing: string[]; completeness_pct: number } + | { path: string; error: string } + > = []; + + for (const qmdPath of qmdFiles) { + let content: string; + try { + content = await readFile(qmdPath, 'utf-8'); + } catch { + results.push({ path: qmdPath, error: 'Cannot read file' }); + continue; + } + + const { present, missing, scenario_type } = detectSections(content, qmdPath); + const completeness_pct = Math.round((present.length / ALL_SECTION_NAMES.length) * 100); + results.push({ path: qmdPath, scenario_type, present, missing, completeness_pct }); + } + + return { content: [{ type: 'text' as const, text: JSON.stringify(results, null, 2) }] }; + } +); + + return server; +} + +// --------------------------------------------------------------------------- +// Entrypoint — connect to stdio when run directly +// --------------------------------------------------------------------------- + +if (import.meta.main) { + const transport = new StdioServerTransport(); + await createServer().connect(transport); +} diff --git a/TabletopExercise/generators/package.json b/TabletopExercise/generators/package.json index ae11e2e..c1c14de 100644 --- a/TabletopExercise/generators/package.json +++ b/TabletopExercise/generators/package.json @@ -7,10 +7,15 @@ "scripts": { "generate": "bun run generate-pdf.ts", "example": "bun run generate-pdf.ts --example --output example-tabletop.pdf", - "install-deps": "bun add playwright && bunx playwright install chromium" + "install-deps": "bun add playwright && bunx playwright install chromium", + "mcp": "bun run mcp-server.ts", + "test": "bun run test-mcp.ts" }, "dependencies": { - "playwright": "^1.40.0" + "@modelcontextprotocol/sdk": "^1.27.1", + "playwright": "^1.40.0", + "zod": "^3.22.0", + "zod-to-json-schema": "^3.25.1" }, "devDependencies": { "@types/node": "^20.10.0" diff --git a/TabletopExercise/generators/schema.ts b/TabletopExercise/generators/schema.ts new file mode 100644 index 0000000..ca2fc17 --- /dev/null +++ b/TabletopExercise/generators/schema.ts @@ -0,0 +1,208 @@ +/** + * Zod schemas for TabletopExercise data. + * + * Single source of truth for: + * - TypeScript types (via z.infer) + * - MCP tool validation + * - tabletop://schema resource (via zod-to-json-schema) + * + * Derived from the TabletopExerciseData interface in generate-pdf.ts, + * extended with M&M-specific enrichment fields. + */ + +import { z } from 'zod'; + +// --------------------------------------------------------------------------- +// Primitive enums +// --------------------------------------------------------------------------- + +export const ScenarioTypeSchema = z.enum(['contemporary', 'historical']); + +export const SeverityUpperSchema = z.enum(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']); +export const SeverityLowerSchema = z.enum(['critical', 'high', 'medium', 'low']); + +// --------------------------------------------------------------------------- +// Sub-schemas mirroring generate-pdf.ts interfaces +// --------------------------------------------------------------------------- + +export const ConditionalResponseSchema = z.object({ + trigger: z.string().min(1), + response: z.string().min(1), +}); + +export const TimelineEventSchema = z.object({ + time: z.string().min(1), + title: z.string().min(1), + description: z.string(), + impact: z.string().optional(), + severity: SeverityLowerSchema, +}); + +export const ObjectiveSchema = z.object({ + number: z.number().int().positive(), + title: z.string().min(1), + description: z.string(), + successCriteria: z.array(z.string()), +}); + +export const InjectSchema = z.object({ + id: z.string().min(1), + time: z.string().min(1), + title: z.string().min(1), + severity: SeverityLowerSchema, + /** READ-ALOUD narrative shown to participants. Contemporary: symptom-only, no malmon family names. */ + scenario: z.string().min(1), + artifact: z.string().optional(), + expectedResponse: z.string(), + discussionQuestions: z.array(z.string()).optional(), + conditionalResponses: z.array(ConditionalResponseSchema).optional(), +}); + +export const AtomicSchema = z.object({ + id: z.string().min(1), + time: z.string().min(1), + title: z.string().min(1), + action: z.string().min(1), + commands: z.string().optional(), + commandLanguage: z.string().optional(), + expectedResponse: z.string(), + fallback: z.string().optional(), + verification: z.array(z.string()).optional(), +}); + +export const GapSchema = z.object({ + priority: SeverityLowerSchema, + title: z.string().min(1), + status: z.string(), + trigger: z.string(), + requiredProcedures: z.array(z.string()), + impact: z.string(), + recommendation: z.string(), +}); + +export const GapStatsSchema = z.object({ + critical: z.number().int().min(0), + high: z.number().int().min(0), + medium: z.number().int().min(0), + low: z.number().int().min(0), +}); + +// --------------------------------------------------------------------------- +// M&M-specific enrichment schemas +// --------------------------------------------------------------------------- + +export const NPCDialogueLineSchema = z.object({ + prompt: z.string().min(1), + response: z.string().min(1), +}); + +export const NPCDialogueSchema = z.object({ + npcName: z.string().min(1), + role: z.string().min(1), + triggerContext: z.string(), + lines: z.array(NPCDialogueLineSchema).min(1), +}); + +export const ArtifactSchema = z.object({ + id: z.string().min(1), + type: z.enum(['screenshot', 'log', 'email', 'document', 'alert', 'other']), + title: z.string().min(1), + content: z.string(), + linkedInjectId: z.string().optional(), +}); + +// --------------------------------------------------------------------------- +// Facilitator guide sub-schema (passthrough for extra fields) +// --------------------------------------------------------------------------- + +export const FacilitatorGuidePreparationSchema = z + .object({ + timeline: z.string().optional(), + tasks: z.array(z.string()).optional(), + materialsNeeded: z.array(z.string()).optional(), + roomSetup: z.array(z.string()).optional(), + }) + .passthrough(); + +export const FacilitatorGuideSchema = z + .object({ + preparation: FacilitatorGuidePreparationSchema.optional(), + openingScript: z.string().optional(), + groundRules: z.array(z.string()).optional(), + }) + .passthrough(); + +// --------------------------------------------------------------------------- +// Full exercise data schema — the quality standard +// --------------------------------------------------------------------------- + +export const TabletopExerciseDataSchema = z.object({ + // M&M scenario type — unlocks historical mode rules + scenario_type: ScenarioTypeSchema.optional(), + + // Cover page + title: z.string().min(1), + subtitle: z.string().optional(), + scenarioType: z.string().optional(), + targetAudience: z.string(), + duration: z.string().optional(), + difficulty: z.string().optional(), + severity: SeverityUpperSchema, + preparedBy: z.string().optional(), + date: z.string().optional(), + version: z.string().optional(), + + // Executive summary + executiveSummary: z.string().optional(), + attackVector: z.string().optional(), + potentialImpact: z.string().optional(), + testingGoals: z.string().optional(), + criticalGaps: z.string().optional(), + + // Scenario narrative + scenarioOverview: z.string().optional(), + timelineEvents: z.array(TimelineEventSchema).optional(), + + // Learning objectives + objectives: z.array(ObjectiveSchema).optional(), + + // Facilitator guide + facilitatorGuide: FacilitatorGuideSchema.optional(), + + // Core exercise content + injects: z.array(InjectSchema), + + // Technical atomics + atomics: z.array(AtomicSchema).optional(), + + // Gap analysis + gapStats: GapStatsSchema.optional(), + gaps: z.array(GapSchema), + + // M&M enrichment sections + npcDialogue: z.array(NPCDialogueSchema).optional(), + artifacts: z.array(ArtifactSchema).optional(), +}); + +// --------------------------------------------------------------------------- +// Section presence schema (returned by check_scenario_completeness) +// --------------------------------------------------------------------------- + +export const SectionPresenceSchema = z.object({ + present: z.array(z.string()), + missing: z.array(z.string()), + scenario_type: ScenarioTypeSchema, +}); + +// --------------------------------------------------------------------------- +// Exported TypeScript types +// --------------------------------------------------------------------------- + +export type TabletopExerciseData = z.infer; +export type ScenarioType = z.infer; +export type SectionPresence = z.infer; +export type Inject = z.infer; +export type Gap = z.infer; +export type Atomic = z.infer; +export type Artifact = z.infer; +export type NPCDialogue = z.infer; diff --git a/TabletopExercise/generators/test-mcp.ts b/TabletopExercise/generators/test-mcp.ts new file mode 100644 index 0000000..5b44f4c --- /dev/null +++ b/TabletopExercise/generators/test-mcp.ts @@ -0,0 +1,386 @@ +#!/usr/bin/env bun +/** + * Integration tests for the TabletopExercise MCP server. + * + * Uses InMemoryTransport so tests run in-process without spawning a subprocess. + * + * Run: + * bun run test-mcp.ts + */ + +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import { writeFile, unlink, mkdir } from 'fs/promises'; +import { join, resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +import { createServer } from './mcp-server.ts'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +// --------------------------------------------------------------------------- +// Test harness +// --------------------------------------------------------------------------- + +let passed = 0; +let failed = 0; + +function assert(condition: boolean, label: string): void { + if (condition) { + console.log(` ✓ ${label}`); + passed++; + } else { + console.error(` ✗ ${label}`); + failed++; + } +} + +function parseToolResult(result: Awaited>): unknown { + const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? '{}'; + return JSON.parse(text); +} + +// --------------------------------------------------------------------------- +// Start server + client via InMemoryTransport +// --------------------------------------------------------------------------- + +const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + +const server = createServer(); +const client = new Client({ name: 'test-client', version: '1.0.0' }); + +await server.connect(serverTransport); +await client.connect(clientTransport); + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const SSRF_DATA_PATH = resolve(__dirname, 'ssrf-exercise-data.json'); +const TMP_DIR = resolve(__dirname); + +// Minimal QMD with inject timeline and gap analysis sections +const QMD_WITH_SECTIONS = `--- +title: Test Scenario +type: contemporary +--- + +## Injects + +**T+0**: Initial alert fires. + +## Gap Analysis + +| Gap | Severity | +|-----|----------| +| No runbook | High | + +## Facilitator Notes + +Keep participants on track. +`; + +// Minimal QMD missing most sections +const QMD_SPARSE = `--- +title: Sparse Scenario +--- + +# Overview +Short scenario with no enrichment. +`; + +// Historical QMD +const QMD_HISTORICAL = `--- +title: Historical Scenario +type: historical +--- + +## Injects + +T+0 something happens. +`; + +const QMD_CONTEMPORARY_PATH = resolve(__dirname, '/tmp/test-contemporary.qmd'); +const QMD_SPARSE_PATH = resolve(__dirname, '/tmp/test-sparse.qmd'); +const QMD_HISTORICAL_PATH = resolve(__dirname, '/tmp/test-historical.qmd'); + +await writeFile(QMD_CONTEMPORARY_PATH, QMD_WITH_SECTIONS, 'utf-8'); +await writeFile(QMD_SPARSE_PATH, QMD_SPARSE, 'utf-8'); +await writeFile(QMD_HISTORICAL_PATH, QMD_HISTORICAL, 'utf-8'); + +// --------------------------------------------------------------------------- +// Test 1: tools/list — all 6 tools registered +// --------------------------------------------------------------------------- + +console.log('\nTest 1: tools/list'); +{ + const toolList = await client.listTools(); + const names = toolList.tools.map(t => t.name).sort(); + const expected = [ + 'check_scenario_completeness', + 'generate_exercise', + 'list_scenario_cards', + 'merge_exercise_data', + 'validate_exercise_data', + 'validate_m_and_m_formatting', + ]; + assert(JSON.stringify(names) === JSON.stringify(expected), `6 tools registered: ${names.join(', ')}`); +} + +// --------------------------------------------------------------------------- +// Test 2: resources/list — 3 resources registered +// --------------------------------------------------------------------------- + +console.log('\nTest 2: resources/list'); +{ + const resList = await client.listResources(); + const uris = resList.resources.map(r => r.uri).sort(); + assert(uris.includes('tabletop://schema'), 'tabletop://schema registered'); + assert(uris.includes('tabletop://atomics'), 'tabletop://atomics registered'); + assert(uris.includes('tabletop://template'), 'tabletop://template registered'); +} + +// --------------------------------------------------------------------------- +// Test 3: tabletop://schema resource — valid JSON Schema with properties +// --------------------------------------------------------------------------- + +console.log('\nTest 3: tabletop://schema resource'); +{ + const res = await client.readResource({ uri: 'tabletop://schema' }); + const text = (res.contents[0] as { text: string }).text; + const schema = JSON.parse(text); + const props = schema.definitions?.TabletopExerciseData?.properties ?? schema.properties ?? {}; + assert('title' in props, 'schema has "title" property'); + assert('injects' in props, 'schema has "injects" property'); + assert('scenario_type' in props, 'schema has M&M "scenario_type" property'); + assert('npcDialogue' in props, 'schema has M&M "npcDialogue" property'); +} + +// --------------------------------------------------------------------------- +// Test 4: validate_exercise_data — SSRF example returns valid: true +// --------------------------------------------------------------------------- + +console.log('\nTest 4: validate_exercise_data (valid data)'); +{ + const ssrfData = JSON.parse(await Bun.file(SSRF_DATA_PATH).text()); + const result = parseToolResult( + await client.callTool({ name: 'validate_exercise_data', arguments: { data: ssrfData } }) + ) as { valid: boolean; errors: unknown[] }; + assert(result.valid === true, 'SSRF exercise-data.json passes schema validation'); + assert(result.errors.length === 0, 'No validation errors'); +} + +// --------------------------------------------------------------------------- +// Test 5: validate_exercise_data — invalid data returns structured errors +// --------------------------------------------------------------------------- + +console.log('\nTest 5: validate_exercise_data (invalid data)'); +{ + const result = parseToolResult( + await client.callTool({ + name: 'validate_exercise_data', + arguments: { data: { title: 'Missing required fields' } }, + }) + ) as { valid: boolean; errors: Array<{ path: string; message: string }> }; + assert(result.valid === false, 'Invalid data returns valid: false'); + assert(result.errors.length > 0, 'Returns at least one error'); + const paths = result.errors.map(e => e.path); + assert(paths.includes('severity'), 'Reports missing "severity"'); + assert(paths.includes('injects'), 'Reports missing "injects"'); +} + +// --------------------------------------------------------------------------- +// Test 6: check_scenario_completeness — rich QMD detects present sections +// --------------------------------------------------------------------------- + +console.log('\nTest 6: check_scenario_completeness (rich QMD)'); +{ + const result = parseToolResult( + await client.callTool({ + name: 'check_scenario_completeness', + arguments: { qmd_path: QMD_CONTEMPORARY_PATH }, + }) + ) as { present: string[]; missing: string[]; scenario_type: string }; + assert(result.scenario_type === 'contemporary', 'Detects contemporary from frontmatter'); + assert(result.present.includes('inject_timeline'), 'Detects inject_timeline section'); + assert(result.present.includes('gap_analysis'), 'Detects gap_analysis section'); + assert(result.present.includes('facilitator_notes'), 'Detects facilitator_notes section'); + assert(result.missing.includes('npc_dialogue'), 'Reports missing npc_dialogue'); + assert(result.missing.includes('artifacts'), 'Reports missing artifacts'); +} + +// --------------------------------------------------------------------------- +// Test 7: check_scenario_completeness — historical QMD +// --------------------------------------------------------------------------- + +console.log('\nTest 7: check_scenario_completeness (historical QMD)'); +{ + const result = parseToolResult( + await client.callTool({ + name: 'check_scenario_completeness', + arguments: { qmd_path: QMD_HISTORICAL_PATH }, + }) + ) as { present: string[]; missing: string[]; scenario_type: string }; + assert(result.scenario_type === 'historical', 'Detects historical from frontmatter type field'); +} + +// --------------------------------------------------------------------------- +// Test 8: check_scenario_completeness — path traversal rejected +// --------------------------------------------------------------------------- + +console.log('\nTest 8: check_scenario_completeness (path traversal)'); +{ + const result = parseToolResult( + await client.callTool({ + name: 'check_scenario_completeness', + arguments: { qmd_path: '/tmp/../../etc/passwd' }, + }) + ) as { error?: string }; + assert(typeof result.error === 'string' && result.error.includes('traversal'), 'Rejects path with ".."'); +} + +// --------------------------------------------------------------------------- +// Test 9: merge_exercise_data — additive-only merge +// --------------------------------------------------------------------------- + +console.log('\nTest 9: merge_exercise_data (additive-only)'); +{ + // Write a minimal valid base JSON + const basePath = '/tmp/test-exercise-base.json'; + const base = { + title: 'Merge Test', + targetAudience: 'SOC', + severity: 'HIGH', + injects: [{ id: 'INJ-001', time: 'T+0', title: 'Alert fires', severity: 'high', scenario: 'An alert fires.', expectedResponse: 'Investigate' }], + gaps: [], + }; + await writeFile(basePath, JSON.stringify(base), 'utf-8'); + + const result = parseToolResult( + await client.callTool({ + name: 'merge_exercise_data', + arguments: { + base_path: basePath, + additions: { + title: 'SHOULD NOT OVERWRITE', // existing key — must be ignored + scenario_type: 'contemporary', // new key — must be added + npcDialogue: [], // new key — must be added + }, + }, + }) + ) as { merged_path?: string; sections_added?: string[]; error?: string }; + + assert(!result.error, `No merge error: ${result.error ?? ''}`); + assert(Array.isArray(result.sections_added), 'Returns sections_added array'); + assert(result.sections_added!.includes('scenario_type'), 'Added scenario_type'); + assert(result.sections_added!.includes('npcDialogue'), 'Added npcDialogue'); + assert(!result.sections_added!.includes('title'), 'Did NOT overwrite existing title'); + + // Verify the file on disk + const merged = JSON.parse(await Bun.file(basePath).text()); + assert(merged.title === 'Merge Test', 'Original title preserved in file'); + assert(merged.scenario_type === 'contemporary', 'New field written to file'); + + await unlink(basePath).catch(() => {}); +} + +// --------------------------------------------------------------------------- +// Test 10: validate_m_and_m_formatting — contemporary violation (real org name) +// --------------------------------------------------------------------------- + +console.log('\nTest 10: validate_m_and_m_formatting (contemporary violation)'); +{ + const data = { + title: 'Test', + targetAudience: 'SOC', + severity: 'HIGH', + injects: [{ id: 'INJ-001', time: 'T+0', title: 'Alert', severity: 'high', scenario: 'Alert fires.', expectedResponse: '' }], + gaps: [], + artifacts: [{ + id: 'ART-001', + type: 'document', + title: 'Vendor invoice', + content: 'Invoice from Microsoft Corporation for Azure services.', + }], + }; + const result = parseToolResult( + await client.callTool({ + name: 'validate_m_and_m_formatting', + arguments: { data, scenario_type: 'contemporary' }, + }) + ) as { valid: boolean; violations: Array<{ rule: string }> }; + assert(result.valid === false, 'Detects real-org violation in contemporary artifact'); + assert( + result.violations.some(v => v.rule === 'contemporary_fictional_orgs_only'), + 'Reports contemporary_fictional_orgs_only rule violation' + ); +} + +// --------------------------------------------------------------------------- +// Test 11: validate_m_and_m_formatting — historical missing scenario_type field +// --------------------------------------------------------------------------- + +console.log('\nTest 11: validate_m_and_m_formatting (historical missing field)'); +{ + const data = { + title: 'Historical exercise', + targetAudience: 'SOC', + severity: 'HIGH', + // scenario_type intentionally omitted + injects: [], + gaps: [], + }; + const result = parseToolResult( + await client.callTool({ + name: 'validate_m_and_m_formatting', + arguments: { data, scenario_type: 'historical' }, + }) + ) as { valid: boolean; violations: Array<{ rule: string }> }; + assert(result.valid === false, 'Detects missing scenario_type field for historical'); + assert( + result.violations.some(v => v.rule === 'historical_type_field_required'), + 'Reports historical_type_field_required violation' + ); +} + +// --------------------------------------------------------------------------- +// Test 12: generate_exercise — produces facilitator.html and participant.html +// --------------------------------------------------------------------------- + +console.log('\nTest 12: generate_exercise'); +{ + const result = parseToolResult( + await client.callTool({ + name: 'generate_exercise', + arguments: { + exercise_data_path: SSRF_DATA_PATH, + output_dir: TMP_DIR, + }, + }) + ) as { facilitator_html?: string; participant_html?: string; error?: string }; + + assert(!result.error, `No generation error: ${result.error ?? ''}`); + assert(typeof result.facilitator_html === 'string', 'Returns facilitator_html path'); + assert(typeof result.participant_html === 'string', 'Returns participant_html path'); + + if (result.facilitator_html && result.participant_html) { + const facilContent = await Bun.file(result.facilitator_html).text().catch(() => ''); + const partContent = await Bun.file(result.participant_html).text().catch(() => ''); + assert(facilContent.includes(' partContent.length, 'facilitator.html is larger (has facilitator-only content)'); + } +} + +// --------------------------------------------------------------------------- +// Summary +// --------------------------------------------------------------------------- + +await client.close(); + +console.log(`\n${'─'.repeat(50)}`); +console.log(`Results: ${passed} passed, ${failed} failed`); + +if (failed > 0) { + process.exit(1); +} From 6b97edd1ce6aecf5c113b4ea96b7d0eb8afb90ff Mon Sep 17 00:00:00 2001 From: Klaus Agnoletti Date: Fri, 6 Mar 2026 22:33:37 +0100 Subject: [PATCH 2/8] Add generate_exercise_qmd tool (Issue #216 in klausagnoletti/malware-and-monsters) Implements native Quarto markdown output for the M&M handbook: - generate-qmd.ts: formatting helpers, guards (safeQmd, validateTestNetIPs, validateContemporaryReadAloud), variation block wrapping, section renderers for Inject Sequence / NPC Dialogue / Red Herrings / Gap Analysis, handout renderer with verified CSS block - mcp-server.ts: registers generate_exercise_qmd as Tool 6; populates MALMON_FAMILIES with all 13 canonical names from M&M scenario-cards - schema.ts: extends all sub-schemas with QMD-specific fields (trigger, read_aloud, artifact_inline, hint_if_stuck, NPCDialogueLinesQMD union, RedHerringSchema, handout artifact fields, etc.) - test-mcp.ts: adds 6 new integration tests (Tests 13-18) covering basic generation, handout output, em dash guard, contemporary read_aloud violation, TEST-NET IP validation, path traversal; 63/63 pass Verified: quarto render on lockbit/hospital-emergency fixture produces clean HTML with no warnings. Co-Authored-By: Claude Sonnet 4.6 --- TabletopExercise/generators/generate-qmd.ts | 493 ++++++++++++++++++++ TabletopExercise/generators/mcp-server.ts | 54 ++- TabletopExercise/generators/schema.ts | 87 +++- TabletopExercise/generators/test-mcp.ts | 230 ++++++++- 4 files changed, 852 insertions(+), 12 deletions(-) create mode 100644 TabletopExercise/generators/generate-qmd.ts diff --git a/TabletopExercise/generators/generate-qmd.ts b/TabletopExercise/generators/generate-qmd.ts new file mode 100644 index 0000000..8a96839 --- /dev/null +++ b/TabletopExercise/generators/generate-qmd.ts @@ -0,0 +1,493 @@ +/** + * QMD generator for the M&M handbook. + * + * Produces Quarto markdown output directly — no HTML-to-QMD conversion needed. + * + * Key rules enforced here (not left to the caller): + * - Blank lines before every list (Quarto/Pandoc requirement) + * - No em dash characters — use -- instead + * - Contemporary/community scenarios: read_aloud must not name the malmon family + * - artifact_content: TEST-NET IPs only (192.0.2.x, 198.51.100.x, 203.0.113.x) + * - Variation blocks auto-wrapped around fields containing {{...}} placeholders + */ + +import { readFile, writeFile } from 'fs/promises'; +import { join } from 'path'; + +import type { + TabletopExerciseData, + Inject, + Gap, + Artifact, + NPCDialogue, + RedHerring, + NPCDialogueLinesQMD, + ScenarioType, +} from './schema.ts'; + +// --------------------------------------------------------------------------- +// Formatting helpers — the ONLY way to produce lists in this generator. +// The leading \n is non-negotiable: Quarto requires a blank line before lists. +// --------------------------------------------------------------------------- + +export function bulletList(items: string[]): string { + if (items.length === 0) return ''; + return '\n' + items.map(i => `- ${i}`).join('\n') + '\n'; +} + +export function numberedList(items: string[]): string { + if (items.length === 0) return ''; + return '\n' + items.map((item, i) => `${i + 1}. ${item}`).join('\n') + '\n'; +} + +export function paragraph(text: string): string { + return '\n' + text + '\n'; +} + +/** Headings do NOT get a leading blank line — Quarto does not require one. */ +export function heading(level: number, text: string): string { + return '\n' + '#'.repeat(level) + ' ' + text + '\n'; +} + +/** Two trailing spaces = line break within a paragraph (Quarto rule). */ +export function boldKV(label: string, value: string): string { + return `\n**${label}:** ${value} `; +} + +// --------------------------------------------------------------------------- +// Guards +// --------------------------------------------------------------------------- + +const EM_DASH = '\u2014'; + +/** Throw if the generated string contains an em dash character. */ +export function safeQmd(s: string): string { + if (s.includes(EM_DASH)) { + throw new Error( + 'Em dash (\u2014) found in generated QMD — use -- (two hyphens) instead.' + ); + } + return s; +} + +/** TEST-NET IP ranges that are safe to use in exercise artifacts. */ +const TEST_NET_PREFIXES = ['192.0.2.', '198.51.100.', '203.0.113.']; + +const IPV4_RE = /\b(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})\b/g; + +function isTestNetIp(ip: string): boolean { + return TEST_NET_PREFIXES.some(prefix => ip.startsWith(prefix)); +} + +/** Throw if artifact_content contains IP addresses outside TEST-NET ranges. */ +export function validateTestNetIPs(content: string): void { + const matches = [...content.matchAll(IPV4_RE)]; + for (const match of matches) { + const ip = match[0]; + if (!isTestNetIp(ip)) { + throw new Error( + `Real routable IP address "${ip}" found in artifact_content. ` + + 'Use TEST-NET ranges only: 192.0.2.x, 198.51.100.x, 203.0.113.x.' + ); + } + } +} + +/** + * Throw if any inject read_aloud field names the malmon family in a + * contemporary or community scenario. + */ +export function validateContemporaryReadAloud( + injects: Inject[], + malmonFamily: string, + scenarioType: ScenarioType +): void { + if (scenarioType === 'historical') return; + const lowerFamily = malmonFamily.toLowerCase(); + for (const inject of injects) { + const readAloud = inject.read_aloud ?? inject.scenario ?? ''; + if (readAloud.toLowerCase().includes(lowerFamily)) { + throw new Error( + `Inject "${inject.id}" read_aloud names the malmon family "${malmonFamily}" ` + + `in a ${scenarioType} scenario. Use symptom-only descriptions.` + ); + } + } +} + +// --------------------------------------------------------------------------- +// Variation block wrapping +// --------------------------------------------------------------------------- + +const VARIATION_RE = /\{\{[^}]+\}\}/; + +/** + * Wrap text in a variation block if it contains {{...}} placeholders. + * If no region is specified in the data, uses a single default="true" block. + */ +export function wrapVariation(text: string, group = 'region', value = 'default'): string { + if (!VARIATION_RE.test(text)) return text; + return ( + `::: {.variation group="${group}" value="${value}" default="true"}\n` + + text + '\n' + + ':::\n' + ); +} + +// --------------------------------------------------------------------------- +// Slug derivation +// --------------------------------------------------------------------------- + +export function slugFromFilename(filename: string): string { + return filename + .replace(/\.qmd$/i, '') + .replace(/[^a-z0-9]+/gi, '-') + .replace(/^-+|-+$/g, '') + .toLowerCase(); +} + +// --------------------------------------------------------------------------- +// Section renderers +// --------------------------------------------------------------------------- + +function renderInjectSequence(injects: Inject[]): string { + let out = heading(2, 'Inject Sequence'); + out += paragraph( + '*The following injects are delivered by the IM at the trigger points described. ' + + 'Read aloud text verbatim. Adjust timing to group pace -- a fast-moving group ' + + 'may skip injects; a stuck group may need them early.*' + ); + + injects.forEach((inject, idx) => { + const n = idx + 1; + out += heading(3, `Inject ${n}: ${inject.title}`); + + if (inject.trigger) { + out += boldKV('Trigger', inject.trigger); + } + + const readAloud = inject.read_aloud ?? inject.scenario; + out += '\n\n**Read Aloud:**\n'; + out += paragraph(wrapVariation(`*"${readAloud}"*`)); + + if (inject.artifact_inline) { + out += '\n**Inline Artifact:**\n'; + // Indent 4 spaces → renders as code block in Quarto + out += '\n' + inject.artifact_inline.split('\n').map(l => ' ' + l).join('\n') + '\n'; + } + + const questions = inject.discussionQuestions ?? []; + if (questions.length > 0) { + out += '\n**Discussion Questions:**'; + out += bulletList(questions); + } + + const branches = inject.conditional_branches ?? inject.conditionalResponses?.map(cr => ({ + condition: cr.trigger, + im_response: cr.response, + })) ?? []; + if (branches.length > 0) { + out += '\n**Conditional Branches:**'; + out += bulletList(branches.map(b => `**If team ${b.condition}:** ${b.im_response}`)); + } + + const notes: string[] = []; + if (inject.hint_if_stuck) notes.push(`*Hint if stuck:* *"${inject.hint_if_stuck}"*`); + if (inject.red_flag) notes.push(`*Red flag:* ${inject.red_flag}`); + if (inject.success_indicator) notes.push(`*Success indicator:* ${inject.success_indicator}`); + if (inject.expectedResponse && !inject.hint_if_stuck) notes.push(`*Expected response:* ${inject.expectedResponse}`); + + if (notes.length > 0) { + out += '\n**IM Notes:**'; + out += bulletList(notes); + } + }); + + return out; +} + +function isQMDLines(lines: NPCDialogue['lines']): lines is NPCDialogueLinesQMD { + return ( + lines !== undefined && + !Array.isArray(lines) && + 'under_pressure' in lines + ); +} + +function renderNPCDialogue(npcDialogue: NPCDialogue[]): string { + let out = heading(2, 'NPC Dialogue Scripts'); + out += paragraph( + '*Verbatim lines for key NPCs at critical decision moments. Deliver in character ' + + 'when players interact with the NPC or when the scene naturally calls for it. ' + + 'Adapt phrasing naturally but preserve the core message.*' + ); + + for (const npc of npcDialogue) { + const nameLabel = npc.npcName ? `${npc.role}: ${npc.npcName}` : npc.role; + out += heading(3, wrapVariation(nameLabel)); + + if (npc.triggerContext) { + out += paragraph(npc.triggerContext); + } + + if (isQMDLines(npc.lines)) { + out += '\n**Under pressure** (when team delays or debates):\n'; + out += paragraph(wrapVariation(`*"${npc.lines.under_pressure}"*`)); + + out += '\n**Escalating** (when situation worsens or deadline approaches):\n'; + out += paragraph(wrapVariation(`*"${npc.lines.escalating}"*`)); + + out += '\n**Conceding** (when team presents a strong plan):\n'; + out += paragraph(wrapVariation(`*"${npc.lines.conceding}"*`)); + } else if (Array.isArray(npc.lines) && npc.lines.length > 0) { + for (const line of npc.lines) { + out += boldKV(line.prompt, ''); + out += paragraph(wrapVariation(`*"${line.response}"*`)); + } + } + } + + return out; +} + +function renderRedHerrings(redHerrings: RedHerring[]): string { + let out = heading(2, 'Red Herrings'); + out += paragraph( + '*These false leads are built into the scenario. Do not shut down player investigation -- ' + + 'let them work through the evidence to the correct conclusion. The goal is productive ' + + 'confusion, not frustration.*' + ); + + redHerrings.forEach((rh, idx) => { + out += heading(3, `Red Herring ${idx + 1}: ${rh.title}`); + out += '\n**What points to it:**'; + out += bulletList(rh.what_points_to_it); + out += boldKV('Why it\'s wrong', rh.why_its_wrong); + out += '\n\n**IM resolution script:** '; + out += paragraph(wrapVariation(`*"${rh.im_resolution_script}"*`)); + }); + + return out; +} + +function renderGapAnalysis(gaps: Gap[]): string { + let out = heading(2, 'Post-Session Gap Analysis'); + out += paragraph( + '*Use this section during the debrief. Each gap is a real security control weakness ' + + 'this scenario is designed to surface. Help participants connect scenario events to ' + + 'their own organization\'s readiness.*' + ); + + gaps.forEach((gap, idx) => { + const n = idx + 1; + out += heading(3, `Gap ${n}: ${gap.title} *(Priority: ${gap.priority})*`); + + const revealed = gap.what_the_scenario_revealed ?? gap.trigger ?? gap.impact ?? ''; + const matters = gap.why_it_matters ?? gap.recommendation ?? ''; + const remediation = gap.suggested_remediation ?? gap.requiredProcedures ?? []; + const debriefQ = gap.debrief_question ?? ''; + + out += boldKV('What the scenario revealed', revealed); + out += boldKV('Why it matters', matters); + + if (remediation.length > 0) { + out += '\n\n**Suggested remediation:**'; + out += bulletList(remediation); + } + + if (debriefQ) { + out += boldKV('Debrief question', `*"${debriefQ}"*`); + } + }); + + return out; +} + +// --------------------------------------------------------------------------- +// Handout renderer +// --------------------------------------------------------------------------- + +/** + * CSS block for handout QMD files. + * + * Verified against the canonical M&M source file: + * im-handbook/resources/scenario-cards/stuxnet/historical-foundation/handout-a-scada-diagnostics.qmd + */ +const HANDOUT_CSS = `\`\`\`{=html} + +\`\`\``; + +function renderHandout(artifact: Artifact, malmonFamily: string): string { + const letter = artifact.handout_letter ?? 'A'; + const artifactContent = artifact.artifact_content ?? artifact.content ?? ''; + + if (artifactContent) { + validateTestNetIPs(artifactContent); + } + + let out = `---\npagetitle: "Handout ${letter}: ${artifact.title} | ${malmonFamily}"\n---\n\n`; + out += HANDOUT_CSS + '\n'; + out += heading(1, `Handout ${letter}: ${artifact.title}`); + + if (artifact.scene_context) { + out += paragraph(`*${artifact.scene_context}*`); + } + + out += '\n---\n'; + + if (artifact.section_heading) { + out += heading(2, artifact.section_heading); + } + + if (artifactContent) { + out += '\n```\n' + artifactContent + '\n```\n'; + } + + const imNotes = artifact.im_notes_bullets ?? []; + if (imNotes.length > 0) { + out += '\n::: {.im-notes}\n**IM NOTES (Do Not Show to Players):**'; + out += bulletList(imNotes); + out += ':::\n'; + } + + out += '\n---\n'; + out += heading(2, 'Key Discovery Questions'); + + const questions = artifact.key_discovery_questions ?? []; + for (const q of questions) { + out += '\n- **' + q.question + '**\n'; + if (q.answer_and_facilitation) { + out += '\n::: {.im-notes}\n' + q.answer_and_facilitation + '\n:::\n'; + } + } + + const facilNotes = artifact.facilitation_notes ?? []; + if (facilNotes.length > 0) { + out += '\n::: {.im-notes}\n'; + out += heading(2, 'IM Facilitation Notes'); + out += bulletList(facilNotes); + out += ':::\n'; + } + + return out; +} + +// --------------------------------------------------------------------------- +// Main export +// --------------------------------------------------------------------------- + +export interface QMDGenerateResult { + appended_to: string; + sections_written: string[]; + handout_a_path?: string; + handout_b_path?: string; +} + +export async function generateExerciseQmd( + data: TabletopExerciseData, + outputDir: string, + appendTo: string +): Promise { + // Resolve scenario type (top-level or nested metadata) + const scenarioType: ScenarioType = + data.scenario_type ?? + data.metadata?.scenario_type ?? + 'contemporary'; + + const malmonFamily = data.scenario?.malmon_family ?? ''; + + // Contemporary/community read_aloud validation + if (malmonFamily && data.injects.length > 0) { + validateContemporaryReadAloud(data.injects, malmonFamily, scenarioType); + } + + // Build the four sections + const sectionsWritten: string[] = []; + let appendContent = ''; + + if (data.injects.length > 0) { + appendContent += safeQmd(renderInjectSequence(data.injects)); + sectionsWritten.push('Inject Sequence'); + } + + if ((data.npcDialogue ?? []).length > 0) { + appendContent += safeQmd(renderNPCDialogue(data.npcDialogue!)); + sectionsWritten.push('NPC Dialogue Scripts'); + } + + if ((data.red_herrings ?? []).length > 0) { + appendContent += safeQmd(renderRedHerrings(data.red_herrings!)); + sectionsWritten.push('Red Herrings'); + } + + if (data.gaps.length > 0) { + appendContent += safeQmd(renderGapAnalysis(data.gaps)); + sectionsWritten.push('Post-Session Gap Analysis'); + } + + // Append sections to index.qmd + let existing = ''; + try { + existing = await readFile(appendTo, 'utf-8'); + } catch { + // File doesn't exist yet — start fresh + } + await writeFile(appendTo, existing + '\n' + appendContent, 'utf-8'); + + // Write exercise-data.json to output_dir + await writeFile( + join(outputDir, 'exercise-data.json'), + JSON.stringify(data, null, 2), + 'utf-8' + ); + + const result: QMDGenerateResult = { + appended_to: appendTo, + sections_written: sectionsWritten, + }; + + // Write handout files (up to 2) + const artifacts = data.artifacts ?? []; + const handoutA = artifacts.find(a => a.handout_letter === 'A') ?? artifacts[0]; + const handoutB = artifacts.find(a => a.handout_letter === 'B') ?? artifacts[1]; + + if (handoutA) { + const slug = slugFromFilename(handoutA.filename ?? handoutA.id); + const handoutPath = join(outputDir, `handout-a-${slug}.qmd`); + await writeFile(handoutPath, safeQmd(renderHandout({ ...handoutA, handout_letter: 'A' }, malmonFamily)), 'utf-8'); + result.handout_a_path = handoutPath; + } + + if (handoutB) { + const slug = slugFromFilename(handoutB.filename ?? handoutB.id); + const handoutPath = join(outputDir, `handout-b-${slug}.qmd`); + await writeFile(handoutPath, safeQmd(renderHandout({ ...handoutB, handout_letter: 'B' }, malmonFamily)), 'utf-8'); + result.handout_b_path = handoutPath; + } + + return result; +} diff --git a/TabletopExercise/generators/mcp-server.ts b/TabletopExercise/generators/mcp-server.ts index 732627b..2cf392e 100644 --- a/TabletopExercise/generators/mcp-server.ts +++ b/TabletopExercise/generators/mcp-server.ts @@ -28,6 +28,7 @@ import { type SectionPresence, } from './schema.ts'; import { generateTabletopHTML } from './generate-pdf.ts'; +import { generateExerciseQmd } from './generate-qmd.ts'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -59,8 +60,19 @@ try { // --------------------------------------------------------------------------- const MALMON_FAMILIES: readonly string[] = [ - // Placeholder — replace with real family names from klausagnoletti/malware-and-monsters - // e.g. 'Crypton', 'Vipera', 'Rottenberg' + 'Code Red', + 'FakeBat', + 'GaboonGrabber', + 'Gh0st RAT', + 'LitterDrifter', + 'LockBit', + 'Noodle RAT', + 'Poison Ivy', + 'Raspberry Robin', + 'Stuxnet', + 'The Inquisitor', + 'WannaCry', + 'WireLurker', ]; // --------------------------------------------------------------------------- @@ -523,7 +535,43 @@ server.tool( ); // --------------------------------------------------------------------------- -// Tool 6: list_scenario_cards +// Tool 6: generate_exercise_qmd +// --------------------------------------------------------------------------- + +server.tool( + 'generate_exercise_qmd', + 'Generate native Quarto markdown for the M&M handbook: appends 4 sections to index.qmd and writes handout-a/b QMD files. Use instead of generate_exercise when the target is a Quarto book.', + { + exercise_data: z.record(z.unknown()).describe('Validated exercise data object'), + output_dir: z.string().describe('Scenario directory for handout files and exercise-data.json'), + append_to: z.string().describe('Path to index.qmd — the 4 sections are appended here'), + }, + async ({ exercise_data, output_dir, append_to }) => { + if (!rejectTraversal(output_dir) || !rejectTraversal(append_to)) { + return { content: [{ type: 'text' as const, text: JSON.stringify({ error: 'Path traversal rejected' }) }] }; + } + + const parsed = TabletopExerciseDataSchema.safeParse(exercise_data); + if (!parsed.success) { + const errors = parsed.error.issues.map(i => ({ path: i.path.join('.'), message: i.message })); + return { content: [{ type: 'text' as const, text: JSON.stringify({ error: 'Schema validation failed', errors }) }] }; + } + + try { + const result = await generateExerciseQmd( + parsed.data as Parameters[0], + output_dir, + append_to + ); + return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] }; + } catch (err) { + return { content: [{ type: 'text' as const, text: JSON.stringify({ error: String(err) }) }] }; + } + } +); + +// --------------------------------------------------------------------------- +// Tool 7: list_scenario_cards // --------------------------------------------------------------------------- server.tool( diff --git a/TabletopExercise/generators/schema.ts b/TabletopExercise/generators/schema.ts index ca2fc17..e7089ac 100644 --- a/TabletopExercise/generators/schema.ts +++ b/TabletopExercise/generators/schema.ts @@ -45,6 +45,12 @@ export const ObjectiveSchema = z.object({ successCriteria: z.array(z.string()), }); +/** QMD-format conditional branch (if team [condition] → IM response) */ +export const QMDConditionalBranchSchema = z.object({ + condition: z.string().min(1), + im_response: z.string().min(1), +}); + export const InjectSchema = z.object({ id: z.string().min(1), time: z.string().min(1), @@ -56,6 +62,15 @@ export const InjectSchema = z.object({ expectedResponse: z.string(), discussionQuestions: z.array(z.string()).optional(), conditionalResponses: z.array(ConditionalResponseSchema).optional(), + + // QMD-specific fields (M&M handbook format) + trigger: z.string().optional(), + read_aloud: z.string().optional(), + artifact_inline: z.string().optional(), + hint_if_stuck: z.string().optional(), + red_flag: z.string().optional(), + success_indicator: z.string().optional(), + conditional_branches: z.array(QMDConditionalBranchSchema).optional(), }); export const AtomicSchema = z.object({ @@ -73,11 +88,17 @@ export const AtomicSchema = z.object({ export const GapSchema = z.object({ priority: SeverityLowerSchema, title: z.string().min(1), - status: z.string(), - trigger: z.string(), - requiredProcedures: z.array(z.string()), - impact: z.string(), - recommendation: z.string(), + // HTML-generator fields + status: z.string().optional(), + trigger: z.string().optional(), + requiredProcedures: z.array(z.string()).optional(), + impact: z.string().optional(), + recommendation: z.string().optional(), + // QMD-specific fields (M&M debrief format) + what_the_scenario_revealed: z.string().optional(), + why_it_matters: z.string().optional(), + suggested_remediation: z.array(z.string()).optional(), + debrief_question: z.string().optional(), }); export const GapStatsSchema = z.object({ @@ -96,19 +117,55 @@ export const NPCDialogueLineSchema = z.object({ response: z.string().min(1), }); +/** QMD-format NPC dialogue lines with three named emotional beats */ +export const NPCDialogueLinesQMDSchema = z.object({ + under_pressure: z.string().min(1), + escalating: z.string().min(1), + conceding: z.string().min(1), +}); + export const NPCDialogueSchema = z.object({ npcName: z.string().min(1), role: z.string().min(1), - triggerContext: z.string(), - lines: z.array(NPCDialogueLineSchema).min(1), + triggerContext: z.string().optional(), + // Accepts either the original prompt/response array or the QMD under_pressure/escalating/conceding object + lines: z.union([ + z.array(NPCDialogueLineSchema).min(1), + NPCDialogueLinesQMDSchema, + ]).optional(), +}); + +export const KeyDiscoveryQuestionSchema = z.object({ + question: z.string().min(1), + answer_and_facilitation: z.string(), }); export const ArtifactSchema = z.object({ id: z.string().min(1), type: z.enum(['screenshot', 'log', 'email', 'document', 'alert', 'other']), title: z.string().min(1), - content: z.string(), + content: z.string().optional(), linkedInjectId: z.string().optional(), + // QMD handout fields + filename: z.string().optional(), // used to derive handout slug + handout_letter: z.enum(['A', 'B']).optional(), + scene_context: z.string().optional(), + section_heading: z.string().optional(), + artifact_content: z.string().optional(), // must use TEST-NET IPs + .example TLD + im_notes_bullets: z.array(z.string()).optional(), + key_discovery_questions: z.array(KeyDiscoveryQuestionSchema).optional(), + facilitation_notes: z.array(z.string()).optional(), +}); + +// --------------------------------------------------------------------------- +// M&M QMD-specific schemas +// --------------------------------------------------------------------------- + +export const RedHerringSchema = z.object({ + title: z.string().min(1), + what_points_to_it: z.array(z.string()).min(1), + why_its_wrong: z.string().min(1), + im_resolution_script: z.string().min(1), }); // --------------------------------------------------------------------------- @@ -182,6 +239,17 @@ export const TabletopExerciseDataSchema = z.object({ // M&M enrichment sections npcDialogue: z.array(NPCDialogueSchema).optional(), artifacts: z.array(ArtifactSchema).optional(), + red_herrings: z.array(RedHerringSchema).optional(), + + // M&M nested metadata (scenario_type also available at top level for backwards compat) + metadata: z.object({ + scenario_type: ScenarioTypeSchema.optional(), + }).passthrough().optional(), + + // M&M scenario info (malmon_family used for contemporary read_aloud validation) + scenario: z.object({ + malmon_family: z.string().optional(), + }).passthrough().optional(), }); // --------------------------------------------------------------------------- @@ -206,3 +274,6 @@ export type Gap = z.infer; export type Atomic = z.infer; export type Artifact = z.infer; export type NPCDialogue = z.infer; +export type RedHerring = z.infer; +export type NPCDialogueLinesQMD = z.infer; +export type KeyDiscoveryQuestion = z.infer; diff --git a/TabletopExercise/generators/test-mcp.ts b/TabletopExercise/generators/test-mcp.ts index 5b44f4c..6487b6a 100644 --- a/TabletopExercise/generators/test-mcp.ts +++ b/TabletopExercise/generators/test-mcp.ts @@ -119,12 +119,13 @@ console.log('\nTest 1: tools/list'); const expected = [ 'check_scenario_completeness', 'generate_exercise', + 'generate_exercise_qmd', 'list_scenario_cards', 'merge_exercise_data', 'validate_exercise_data', 'validate_m_and_m_formatting', ]; - assert(JSON.stringify(names) === JSON.stringify(expected), `6 tools registered: ${names.join(', ')}`); + assert(JSON.stringify(names) === JSON.stringify(expected), `7 tools registered: ${names.join(', ')}`); } // --------------------------------------------------------------------------- @@ -372,6 +373,233 @@ console.log('\nTest 12: generate_exercise'); } } +// --------------------------------------------------------------------------- +// Test 13: generate_exercise_qmd — basic QMD generation +// --------------------------------------------------------------------------- + +console.log('\nTest 13: generate_exercise_qmd (basic generation)'); +{ + const tmpIndexPath = '/tmp/test-index-basic.qmd'; + const tmpQmdDir = '/tmp/test-qmd-basic'; + await mkdir(tmpQmdDir, { recursive: true }); + + const exerciseData = { + title: 'QMD Test Exercise', + targetAudience: 'SOC', + severity: 'HIGH', + injects: [{ + id: 'INJ-001', time: 'T+0', title: 'Alert fires', + severity: 'high', scenario: 'An alert fires on the monitoring dashboard.', + expectedResponse: 'Investigate the alert.', + }], + gaps: [{ + priority: 'high', title: 'No incident runbook', + what_the_scenario_revealed: 'Responders had no documented runbook.', + why_it_matters: 'Increases mean time to respond.', + }], + }; + + const result = parseToolResult( + await client.callTool({ + name: 'generate_exercise_qmd', + arguments: { exercise_data: exerciseData, output_dir: tmpQmdDir, append_to: tmpIndexPath }, + }) + ) as { appended_to?: string; sections_written?: string[]; error?: string }; + + assert(!result.error, `No QMD generation error: ${result.error ?? ''}`); + assert(result.appended_to === tmpIndexPath, 'Returns correct appended_to path'); + assert(Array.isArray(result.sections_written), 'Returns sections_written array'); + assert(result.sections_written!.includes('Inject Sequence'), 'Wrote Inject Sequence section'); + assert(result.sections_written!.includes('Post-Session Gap Analysis'), 'Wrote Gap Analysis section'); + + const indexContent = await Bun.file(tmpIndexPath).text().catch(() => ''); + assert(indexContent.includes('## Inject Sequence'), 'index.qmd contains Inject Sequence heading'); + assert(indexContent.includes('## Post-Session Gap Analysis'), 'index.qmd contains Gap Analysis heading'); + assert(indexContent.includes('Alert fires'), 'index.qmd contains inject title'); + + const dataJson = await Bun.file(join(tmpQmdDir, 'exercise-data.json')).text().catch(() => ''); + assert(dataJson.includes('QMD Test Exercise'), 'exercise-data.json written to output_dir'); + + await unlink(tmpIndexPath).catch(() => {}); +} + +// --------------------------------------------------------------------------- +// Test 14: generate_exercise_qmd — handout generation +// --------------------------------------------------------------------------- + +console.log('\nTest 14: generate_exercise_qmd (handout generation)'); +{ + const tmpIndexPath = '/tmp/test-index-handout.qmd'; + const tmpQmdDir = '/tmp/test-qmd-handout'; + await mkdir(tmpQmdDir, { recursive: true }); + + const exerciseData = { + title: 'Handout Test', + targetAudience: 'SOC', + severity: 'HIGH', + injects: [{ + id: 'INJ-001', time: 'T+0', title: 'Alert', severity: 'high', + scenario: 'Alert fires.', expectedResponse: 'Investigate.', + }], + gaps: [], + artifacts: [{ + id: 'ART-001', type: 'log', title: 'SCADA Diagnostics', + handout_letter: 'A', filename: 'scada-diagnostics.qmd', + artifact_content: 'Device 192.0.2.10 reported error code 0x4F.', + im_notes_bullets: ['This log is intentionally truncated.'], + }], + }; + + const result = parseToolResult( + await client.callTool({ + name: 'generate_exercise_qmd', + arguments: { exercise_data: exerciseData, output_dir: tmpQmdDir, append_to: tmpIndexPath }, + }) + ) as { handout_a_path?: string; handout_b_path?: string; error?: string }; + + assert(!result.error, `No handout generation error: ${result.error ?? ''}`); + assert(typeof result.handout_a_path === 'string', 'Returns handout_a_path'); + assert(!result.handout_b_path, 'No handout_b_path when only one artifact'); + + if (result.handout_a_path) { + const handoutContent = await Bun.file(result.handout_a_path).text().catch(() => ''); + assert(handoutContent.includes('Handout A'), 'handout-a contains "Handout A"'); + assert(handoutContent.includes('SCADA Diagnostics'), 'handout-a contains artifact title'); + assert(handoutContent.includes('192.0.2.10'), 'handout-a contains TEST-NET IP (allowed)'); + assert(handoutContent.includes('im-notes'), 'handout-a contains IM notes div'); + } + + await unlink(tmpIndexPath).catch(() => {}); +} + +// --------------------------------------------------------------------------- +// Test 15: generate_exercise_qmd — em dash guard +// --------------------------------------------------------------------------- + +console.log('\nTest 15: generate_exercise_qmd (em dash guard)'); +{ + const exerciseData = { + title: 'Em Dash Test', + targetAudience: 'SOC', + severity: 'HIGH', + injects: [{ + id: 'INJ-001', time: 'T+0', title: 'Alert', severity: 'high', + scenario: 'A critical alert\u2014investigate immediately.', // em dash + expectedResponse: 'Investigate.', + }], + gaps: [], + }; + + const result = parseToolResult( + await client.callTool({ + name: 'generate_exercise_qmd', + arguments: { exercise_data: exerciseData, output_dir: '/tmp', append_to: '/tmp/test-em-dash.qmd' }, + }) + ) as { error?: string }; + + assert(typeof result.error === 'string', 'Returns error for em dash in content'); + assert( + result.error!.toLowerCase().includes('em dash') || result.error!.includes('\u2014'), + 'Error mentions em dash' + ); +} + +// --------------------------------------------------------------------------- +// Test 16: generate_exercise_qmd — contemporary read_aloud names malmon family +// --------------------------------------------------------------------------- + +console.log('\nTest 16: generate_exercise_qmd (contemporary read_aloud violation)'); +{ + const exerciseData = { + title: 'Contemporary Test', + targetAudience: 'SOC', + severity: 'HIGH', + scenario_type: 'contemporary', + scenario: { malmon_family: 'LockBit' }, + injects: [{ + id: 'INJ-001', time: 'T+0', title: 'Alert', severity: 'high', + scenario: 'LockBit ransomware has encrypted your files.', // names malmon family + expectedResponse: 'Isolate affected systems.', + }], + gaps: [], + }; + + const result = parseToolResult( + await client.callTool({ + name: 'generate_exercise_qmd', + arguments: { exercise_data: exerciseData, output_dir: '/tmp', append_to: '/tmp/test-malmon.qmd' }, + }) + ) as { error?: string }; + + assert(typeof result.error === 'string', 'Returns error when malmon named in contemporary read_aloud'); + assert(result.error!.includes('LockBit'), 'Error mentions the malmon family name'); +} + +// --------------------------------------------------------------------------- +// Test 17: generate_exercise_qmd — real IP in artifact_content rejected +// --------------------------------------------------------------------------- + +console.log('\nTest 17: generate_exercise_qmd (real IP in artifact_content)'); +{ + const exerciseData = { + title: 'IP Test', + targetAudience: 'SOC', + severity: 'HIGH', + injects: [{ + id: 'INJ-001', time: 'T+0', title: 'Alert', severity: 'high', + scenario: 'Alert fires.', expectedResponse: 'Investigate.', + }], + gaps: [], + artifacts: [{ + id: 'ART-001', type: 'log', title: 'Server log', + handout_letter: 'A', + artifact_content: 'Connection from 1.2.3.4 detected.', // real routable IP + }], + }; + + const result = parseToolResult( + await client.callTool({ + name: 'generate_exercise_qmd', + arguments: { exercise_data: exerciseData, output_dir: '/tmp', append_to: '/tmp/test-ip.qmd' }, + }) + ) as { error?: string }; + + assert(typeof result.error === 'string', 'Returns error for real routable IP in artifact_content'); + assert( + result.error!.includes('1.2.3.4') || result.error!.toLowerCase().includes('ip'), + 'Error identifies the offending IP' + ); +} + +// --------------------------------------------------------------------------- +// Test 18: generate_exercise_qmd — path traversal rejected +// --------------------------------------------------------------------------- + +console.log('\nTest 18: generate_exercise_qmd (path traversal)'); +{ + const exerciseData = { + title: 'Traversal Test', + targetAudience: 'SOC', + severity: 'HIGH', + injects: [], + gaps: [], + }; + + const result = parseToolResult( + await client.callTool({ + name: 'generate_exercise_qmd', + arguments: { + exercise_data: exerciseData, + output_dir: '/tmp/../../etc', + append_to: '/tmp/test.qmd', + }, + }) + ) as { error?: string }; + + assert(typeof result.error === 'string', 'Returns error for path traversal in output_dir'); + assert(result.error!.toLowerCase().includes('traversal'), 'Error mentions path traversal'); +} + // --------------------------------------------------------------------------- // Summary // --------------------------------------------------------------------------- From 5625a2b04153d1a0544ceea614b60cfd6a4ba3f9 Mon Sep 17 00:00:00 2001 From: Klaus Agnoletti Date: Sat, 7 Mar 2026 08:53:20 +0100 Subject: [PATCH 3/8] Update README with MCP server docs for Claude Code, Gemini, Codex, Mistral Vibe Documents setup for all four CLIs with correct config formats: - Claude Code: claude mcp add CLI command - Gemini CLI: ~/.gemini/settings.json (JSON mcpServers block) - OpenAI Codex CLI: codex mcp add command + config.toml format - Mistral Vibe: ~/.vibe/config.toml TOML format Also adds v3.0 version entry and updated file structure. Co-Authored-By: Claude Sonnet 4.6 --- TabletopExercise/README.md | 136 +++++++++++++++++++++++++++++++++++-- 1 file changed, 132 insertions(+), 4 deletions(-) diff --git a/TabletopExercise/README.md b/TabletopExercise/README.md index a6fac9d..e21b4c2 100644 --- a/TabletopExercise/README.md +++ b/TabletopExercise/README.md @@ -84,13 +84,123 @@ Goal: Identify gaps and improve processes --- +## MCP Server + +The TabletopExercise skill also ships as an **MCP (Model Context Protocol) server**, allowing any AI coding agent to call it programmatically to enrich scenario cards in a schema-validated, additive-only way. + +### Setup + +Replace `/path/to/TabletopExercise/generators/mcp-server.ts` with the absolute path on your machine. + +#### Claude Code (CLI) + +```bash +claude mcp add tabletop-exercise -- bun run /path/to/TabletopExercise/generators/mcp-server.ts +``` + +#### Gemini CLI + +Add to `~/.gemini/settings.json` (global) or `.gemini/settings.json` (project): + +```json +{ + "mcpServers": { + "tabletop-exercise": { + "command": "bun", + "args": ["run", "/path/to/TabletopExercise/generators/mcp-server.ts"] + } + } +} +``` + +#### OpenAI Codex CLI + +Via CLI: + +```bash +codex mcp add tabletop-exercise -- bun run /path/to/TabletopExercise/generators/mcp-server.ts +``` + +Or add to `~/.codex/config.toml` (global) or `.codex/config.toml` (project): + +```toml +[mcp_servers.tabletop-exercise] +command = "bun" +args = ["run", "/path/to/TabletopExercise/generators/mcp-server.ts"] +``` + +#### Mistral Vibe (CLI) + +Add to `~/.vibe/config.toml` (global) or `.vibe/config.toml` (project): + +```toml +[mcp_servers.tabletop-exercise] +command = "bun" +args = ["run", "/path/to/TabletopExercise/generators/mcp-server.ts"] +``` + +### Tools (7) + +| Tool | Description | +|------|-------------| +| `check_scenario_completeness` | Parse a `.qmd` scenario card and return which enrichment sections are present/missing | +| `validate_exercise_data` | Validate an exercise-data object against the Zod schema; returns structured errors | +| `generate_exercise` | Produce `facilitator.html` + `participant.html` from exercise-data | +| `merge_exercise_data` | Additive-only merge — never overwrites existing keys in the base JSON | +| `validate_m_and_m_formatting` | Enforce M&M-specific rules (contemporary vs. historical scenario types) | +| `generate_exercise_qmd` | Generate native Quarto markdown: 4 sections appended to `index.qmd` + handout QMD files | +| `list_scenario_cards` | Walk a directory tree and return a completeness summary for every `.qmd` found | + +### Resources (3) + +| URI | Content | +|-----|---------| +| `tabletop://schema` | Full JSON Schema derived from Zod — describes every field and enrichment section | +| `tabletop://atomics` | Full content of `ATOMICS-LIBRARY.md` | +| `tabletop://template` | Annotated template showing how each schema field maps to HTML output | + +### `generate_exercise_qmd` output + +Given a validated `exercise-data.json`, this tool appends four sections to a Quarto `index.qmd`: + +1. **Inject Sequence** — timed injects with read-aloud text, conditional branches, IM notes +2. **NPC Dialogue Scripts** — verbatim lines with three emotional beats (under pressure / escalating / conceding) +3. **Red Herrings** — false leads with resolution scripts +4. **Post-Session Gap Analysis** — debrief-focused gap write-ups with remediation lists + +It also writes `handout-a-[slug].qmd` and `handout-b-[slug].qmd` with print-safe CSS, IM-notes divs, and key discovery questions. + +**Guards enforced before any file is written:** +- Em dash (`—`) in generated text → error (use `--` instead) +- Contemporary scenario `read_aloud` naming the malmon family → error +- `artifact_content` containing non-TEST-NET IP addresses → error +- Path traversal (`..`) in any file path argument → rejected + +### Running the tests + +```bash +cd TabletopExercise/generators +bun run test-mcp.ts # 63 assertions across 18 test groups — all in-process via InMemoryTransport +``` + +--- + ## File Structure ``` -/root/.claude/skills/TabletopExercise/ -├── README.md # This file - overview and quick start -├── SKILL.md # Main skill definition (PAI integration) -└── ATOMICS-LIBRARY.md # Pre-built technical inject sequences +TabletopExercise/ +├── README.md # This file +├── SKILL.md # PAI skill definition +├── ATOMICS-LIBRARY.md # Pre-built atomic inject sequences +└── generators/ + ├── mcp-server.ts # MCP server (7 tools + 3 resources) + ├── schema.ts # Zod schemas — single source of truth + ├── generate-qmd.ts # Native Quarto markdown generator + ├── generate-pdf.ts # PDF/HTML generator (core rendering logic) + ├── generate-html.ts # Standalone HTML generator + ├── generate-both.ts # Facilitator + participant HTML pair + ├── test-mcp.ts # Integration tests (InMemoryTransport) + └── package.json ``` **Generated Outputs** (when skill is invoked): @@ -105,6 +215,15 @@ Goal: Identify gaps and improve processes └── gap-analysis-checklist.md # Missing SOPs/playbooks ``` +**MCP server outputs** (when `generate_exercise_qmd` is called): +``` +[scenario-dir]/ +├── index.qmd # 4 sections appended (inject sequence, NPC dialogue, red herrings, gap analysis) +├── handout-a-[slug].qmd # Player handout A (print-safe) +├── handout-b-[slug].qmd # Player handout B (print-safe, if present) +└── exercise-data.json # Validated exercise data (serialized) +``` + --- ## Core Capabilities @@ -614,6 +733,15 @@ Skill enhanced with learnings from: ## Version History +**v3.0** (2026-03-07) - MCP Server + Quarto Output +- Added MCP server (`mcp-server.ts`) with 7 tools and 3 resources +- Added `schema.ts` — Zod v3 schemas as single source of truth for types, validation, and JSON Schema resource +- Added `generate_exercise_qmd` tool for native Quarto markdown output (inject sequence, NPC dialogue, red herrings, gap analysis, handout QMD files) +- Added M&M-specific validation: contemporary/historical scenario type rules, malmon family name guards, TEST-NET IP enforcement, em dash guard +- Added `merge_exercise_data` additive-only merge tool +- Added 63-assertion integration test suite using `InMemoryTransport` (18 test groups) +- Verified against M&M scenario cards: handout CSS, `quarto render` clean output, all 13 malmon family names + **v2.0** (2026-02-06) - Enhanced PAI Skill - Added technical atomics generation for exercise runners - Integrated SOP/playbook gap analysis framework From 6525c58b3d1ff062e95e36cacce1554cabd79709 Mon Sep 17 00:00:00 2001 From: Klaus Agnoletti Date: Sat, 7 Mar 2026 09:11:39 +0100 Subject: [PATCH 4/8] =?UTF-8?q?Remove=20CLAUDE.md=20and=20.gitignore=20?= =?UTF-8?q?=E2=80=94=20project-private=20files=20not=20for=20upstream?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 3 -- CLAUDE.md | 115 ----------------------------------------------------- 2 files changed, 118 deletions(-) delete mode 100644 .gitignore delete mode 100644 CLAUDE.md diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 6bdda49..0000000 --- a/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -TabletopExercise/generators/node_modules/ -TabletopExercise/generators/facilitator.html -TabletopExercise/generators/participant.html diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index c3d9526..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,115 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Project Overview - -This is the **TabletopExercise PAI Skill** — a cybersecurity tabletop exercise design and facilitation framework. It is a Claude skill (not a standalone app) that generates professional exercise materials from structured JSON data. - -The skill produces three output formats for each exercise: -- **Facilitator HTML** — full content with facilitator notes, expected answers, timing guidance -- **Participant HTML** — clean version with spoilers hidden -- **PDF** — professional client-ready document (via Playwright/Chromium) - -## Directory Structure - -``` -TabletopExercise/ -├── SKILL.md # Main skill definition loaded by PAI -├── ATOMICS-LIBRARY.md # Pre-built atomic inject sequences -├── README.md # Usage documentation -├── generators/ # TypeScript generators (run with bun) -│ ├── generate-html.ts # Standalone HTML generator -│ ├── generate-html-new.ts # Updated standalone HTML generator -│ ├── generate-html-standalone.ts -│ ├── generate-both.ts # Generates facilitator + participant versions -│ ├── generate-pdf.ts # PDF generator (core logic + HTML renderer) -│ └── package.json -├── templates/ -│ └── tabletop-exercise.html -└── examples/ - ├── ssrf-aws-compromise/ # SSRF → AWS credential theft scenario - └── rainbow-six-ddos-attack/ # DDoS scenario -``` - -## Generator Commands - -All generators use **Bun** (not Node/npm). - -```bash -cd TabletopExercise/generators - -# Install dependencies (first time only) -bun add playwright && bunx playwright install chromium - -# Generate both facilitator and participant HTML -bun run generate-both.ts ../examples/[slug]/exercise-data.json - -# Generate standalone HTML -bun run generate-html.ts --input ../examples/[slug]/exercise-data.json --output ../examples/[slug]/ - -# Generate PDF -bun run generate-pdf.ts -``` - -## Exercise Data Format - -Each exercise lives in `examples/[slug]/exercise-data.json`. Key top-level fields: - -```json -{ - "title": "...", - "severity": "CRITICAL|HIGH|MEDIUM|LOW", - "targetAudience": "...", - "facilitatorGuide": { ... }, - "timelineEvents": [ { "time": "T+0", "title": "...", "facilitatorNotes": {...} } ], - "gapAnalysis": [ ... ], - "atomics": [ ... ] -} -``` - -The `generate-pdf.ts` file contains the `generateTabletopHTML(data, mode)` function where `mode` is `'facilitator'` or `'participant'`. Participant mode hides facilitator-only fields. - -## Architecture - -- **SKILL.md** is the PAI skill definition — it defines how Claude should behave when invoked as the `TabletopExercise` skill. It is not a script to run. -- **ATOMICS-LIBRARY.md** provides reusable inject sequences that can be referenced when building `timelineEvents` in exercise JSON. -- **generators/** are TypeScript scripts consumed directly by Bun (no build step needed). -- HTML output is fully self-contained (CSS/JS inlined, no external dependencies). - -## Deployment Context - -This skill is used within the PAI (Personal AI) framework. The generators run locally or on the production server. Do not run Docker containers locally — testing happens in production. - -# Coding Standards - -## Security - -- **Input validation first**: All tool inputs are validated against the Zod schema before any file I/O or generator calls. Never process unvalidated data. - -- **Path traversal prevention (OWASP A01)**: When accepting file paths as tool input (e.g. qmd_path in check_scenario_completeness), resolve and verify the path stays within the expected base directory before reading. Reject paths containing `..`. - -- **No injection (OWASP A03)**: Never pass tool input directly to shell commands, template strings, or eval. All generator calls use direct function imports — no child_process or exec. - -- **Dependency hygiene (OWASP A06)**: Minimize dependencies. This project uses @modelcontextprotocol/sdk, zod, and zod-to-json-schema only. Add nothing else without a strong reason. - -- **No secrets in code**: API keys, tokens, or credentials must come from environment variables. Never hardcode or log them. - -## TypeScript - -- Strict mode always. No `any` — use `z.infer` for Zod-derived types. -- Explicit return types on all exported functions. -- Prefer `unknown` over `any` when the type is genuinely unknown, then narrow it. - -## MCP Tool Design - -- Tools NEVER throw. Return structured error objects instead so the agent can read, fix, and retry. Only `validate_exercise_data` and `check_scenario_completeness` return errors as data — all others call validate_exercise_data internally first. -- Resources are read-only. No resource handler modifies state. -- The Zod schema in schema.ts is the single source of truth — TypeScript types, MCP validation, and the tabletop://schema resource all derive from it. Never duplicate type definitions. - -## Code Quality - -- Small, focused functions. If a function needs a comment to explain what it does, it should probably be split or renamed. -- DRY: if the same logic appears twice, extract it. -- Parse atomics at startup once, index by ID and category — don't re-parse on every tool call. -- Explicit error handling — no silent catch blocks. From f86e9d25e98f5f543f5d0872f1abbd10dee74402 Mon Sep 17 00:00:00 2001 From: Klaus Agnoletti Date: Sat, 7 Mar 2026 09:13:19 +0100 Subject: [PATCH 5/8] Rebrand README: TabletopExercise is a PAI skill and MCP server Co-Authored-By: Claude Sonnet 4.6 --- TabletopExercise/README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/TabletopExercise/README.md b/TabletopExercise/README.md index e21b4c2..4ab87a7 100644 --- a/TabletopExercise/README.md +++ b/TabletopExercise/README.md @@ -1,12 +1,14 @@ -# TabletopExercise Skill +# TabletopExercise **Comprehensive cybersecurity tabletop exercise design and facilitation framework** +TabletopExercise is both a **PAI skill** and an **MCP server**. As a PAI skill it guides an AI agent through designing, facilitating, and evaluating exercises interactively. As an MCP server it exposes 7 schema-validated tools that any AI coding agent (Claude Code, Gemini CLI, Codex CLI, Mistral Vibe) can call programmatically to generate, validate, and enrich exercise materials. + --- ## Overview -Enhanced from the original SOC Manager Table Top Designer, this PAI skill provides a complete methodology for designing, facilitating, and evaluating cybersecurity tabletop exercises for both technical and executive audiences. +Enhanced from the original SOC Manager Table Top Designer, this framework provides a complete methodology for designing, facilitating, and evaluating cybersecurity tabletop exercises for both technical and executive audiences. **Key Enhancements:** - ✅ **Technical Atomics**: Executable inject sequences for realistic scenario delivery From 7ca3970635fb0bc18265f4609ef3176545433ef2 Mon Sep 17 00:00:00 2001 From: Klaus Agnoletti Date: Sun, 8 Mar 2026 00:22:49 +0100 Subject: [PATCH 6/8] =?UTF-8?q?Add=20image=20generation=20and=20HTML=20art?= =?UTF-8?q?ifact=20templates=20(Tools=208=E2=80=9310)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three new MCP tools for visual artifact rendering: - generate_attack_vector_images: phishing emails, ransomware notes, invoices, USB photos - generate_evidence_images: SIEM/Wireshark captures, dark web listings, SCADA HMI - generate_atmosphere_images: cover art, NPC portraits, location illustrations Routing strategy — UI subtypes use CSS/HTML templates (no API key needed, text always legible); physical subtypes use AI image providers: - HTML templates: phishing_email, ransomware_note, fraudulent_invoice, network_capture, dark_web_listing, scada_interface - AI providers: usb_device, network_diagram, portrait, location_illustration, cover_art, period_photograph AI provider chain: IMAGE_PROVIDER accepts comma-separated fallback list (openai,replicate). Providers: OpenAI DALL-E 3, Gemini Imagen 4, Stability AI, Replicate Flux Schnell, Ollama (self-hosted). API keys from .env or shell. Schema: adds ImageSubtypeSchema (12 subtypes), VisualStyleSchema, image_data, html_data, and cover_image_data fields. PDF/HTML output: generate_exercise renders html_data as inline
embed and image_data as ; generate_exercise_qmd writes [slug].png for handouts. Tests expanded from 63 to 83 assertions (Tests 19–26 cover image paths). Co-Authored-By: Claude Sonnet 4.6 --- TabletopExercise/README.md | 75 ++- TabletopExercise/SKILL.md | 54 ++ .../generators/generate-html-artifacts.ts | 555 ++++++++++++++++++ .../generators/generate-images.ts | 478 +++++++++++++++ TabletopExercise/generators/generate-pdf.ts | 38 +- TabletopExercise/generators/generate-qmd.ts | 184 ++++-- TabletopExercise/generators/mcp-server.ts | 173 +++++- TabletopExercise/generators/schema.ts | 35 ++ TabletopExercise/generators/test-mcp.ts | 246 +++++++- 9 files changed, 1783 insertions(+), 55 deletions(-) create mode 100644 TabletopExercise/generators/generate-html-artifacts.ts create mode 100644 TabletopExercise/generators/generate-images.ts diff --git a/TabletopExercise/README.md b/TabletopExercise/README.md index 4ab87a7..6a1f290 100644 --- a/TabletopExercise/README.md +++ b/TabletopExercise/README.md @@ -2,7 +2,7 @@ **Comprehensive cybersecurity tabletop exercise design and facilitation framework** -TabletopExercise is both a **PAI skill** and an **MCP server**. As a PAI skill it guides an AI agent through designing, facilitating, and evaluating exercises interactively. As an MCP server it exposes 7 schema-validated tools that any AI coding agent (Claude Code, Gemini CLI, Codex CLI, Mistral Vibe) can call programmatically to generate, validate, and enrich exercise materials. +TabletopExercise is both a **PAI skill** and an **MCP server**. As a PAI skill it guides an AI agent through designing, facilitating, and evaluating exercises interactively. As an MCP server it exposes 10 schema-validated tools that any AI coding agent (Claude Code, Gemini CLI, Codex CLI, Mistral Vibe) can call programmatically to generate, validate, and enrich exercise materials — including AI-generated images for attack vectors, evidence, and atmosphere. --- @@ -141,7 +141,7 @@ command = "bun" args = ["run", "/path/to/TabletopExercise/generators/mcp-server.ts"] ``` -### Tools (7) +### Tools (10) | Tool | Description | |------|-------------| @@ -152,6 +152,9 @@ args = ["run", "/path/to/TabletopExercise/generators/mcp-server.ts"] | `validate_m_and_m_formatting` | Enforce M&M-specific rules (contemporary vs. historical scenario types) | | `generate_exercise_qmd` | Generate native Quarto markdown: 4 sections appended to `index.qmd` + handout QMD files | | `list_scenario_cards` | Walk a directory tree and return a completeness summary for every `.qmd` found | +| `generate_attack_vector_images` | Render attack-vector artifacts — HTML/CSS templates for UI subtypes (phishing, ransomware, invoices), AI images for physical subtypes (USB device) | +| `generate_evidence_images` | Render evidence artifacts — HTML/CSS templates for UI subtypes (SIEM logs, dark web listings, SCADA), AI images for physical subtypes (network diagrams) | +| `generate_atmosphere_images` | Generate AI atmosphere images: cover art, NPC portraits, location illustrations | ### Resources (3) @@ -178,13 +181,56 @@ It also writes `handout-a-[slug].qmd` and `handout-b-[slug].qmd` with print-safe - `artifact_content` containing non-TEST-NET IP addresses → error - Path traversal (`..`) in any file path argument → rejected +### Image generation + +`generate_attack_vector_images` and `generate_evidence_images` use a **two-path routing strategy** based on artifact subtype: + +| Path | Subtypes | API key required? | +|------|----------|-------------------| +| **HTML/CSS template** | `phishing_email`, `ransomware_note`, `fraudulent_invoice`, `network_capture`, `dark_web_listing`, `scada_interface` | No | +| **AI provider** | `usb_device`, `network_diagram`, `period_photograph`, `portrait`, `location_illustration`, `cover_art` | Yes | + +**Why two paths?** Diffusion models produce fuzzy, unreadable text when rendering UI-heavy artifacts. HTML templates inject `artifact_content` verbatim so text is always accurate and legible at any zoom. Physical and atmospheric artifacts (USB photos, portraits, location art) have no text to render and benefit from AI imagery. + +UI-subtype artifacts receive `html_data` (a self-contained HTML string); AI-rendered artifacts receive `image_data` (base64 PNG). Both are rendered in `facilitator.html` — `html_data` as an inline `
` embed, `image_data` as an `` tag. + +**AI provider setup** (required only for physical/atmosphere subtypes): + +```bash +cp TabletopExercise/generators/.env.example TabletopExercise/generators/.env +# edit .env — set IMAGE_PROVIDER and the matching API key +``` + +Set `IMAGE_PROVIDER` to a single provider or a **comma-separated priority chain** — each is tried in order, first success wins: + +``` +IMAGE_PROVIDER=openai # single provider +IMAGE_PROVIDER=openai,replicate # priority chain with fallbacks +``` + +If a provider fails (missing key, rate limit, API error), the next one in the chain is tried automatically. `provider_used` in the tool response reports which provider(s) fired. + +Supported providers: `openai` (DALL-E 3, default), `gemini` (Imagen 4), `stability`, `replicate` (Flux Schnell), `ollama` (self-hosted). + +Recommended workflow: +``` +validate_exercise_data + → generate_attack_vector_images # html_data set for UI subtypes; image_data for physical + → generate_evidence_images # html_data set for UI subtypes; image_data for diagrams + → generate_atmosphere_images # image_data for all atmosphere subtypes + → generate_exercise # facilitator.html renders both html_data and image_data + → generate_exercise_qmd # handout PNGs written alongside .qmd files +``` + ### Running the tests ```bash cd TabletopExercise/generators -bun run test-mcp.ts # 63 assertions across 18 test groups — all in-process via InMemoryTransport +bun run test-mcp.ts # 83 assertions across 26 test groups — all in-process via InMemoryTransport ``` +Tests 19-24 cover AI image generation: no-key error paths (physical subtypes), schema validation before provider check, `visual_style` round-trip, and `image_subtype` acceptance. Tests 25-26 cover the HTML template path (`email` → `html_data`, `log` → `html_data`). No API key required to run the test suite. + --- ## File Structure @@ -195,13 +241,16 @@ TabletopExercise/ ├── SKILL.md # PAI skill definition ├── ATOMICS-LIBRARY.md # Pre-built atomic inject sequences └── generators/ - ├── mcp-server.ts # MCP server (7 tools + 3 resources) + ├── mcp-server.ts # MCP server (10 tools + 3 resources) ├── schema.ts # Zod schemas — single source of truth + ├── generate-images.ts # Image generation — routes UI subtypes to HTML templates, physical subtypes to AI + ├── generate-html-artifacts.ts # HTML/CSS templates for UI-heavy artifact subtypes (6 templates) ├── generate-qmd.ts # Native Quarto markdown generator ├── generate-pdf.ts # PDF/HTML generator (core rendering logic) ├── generate-html.ts # Standalone HTML generator ├── generate-both.ts # Facilitator + participant HTML pair ├── test-mcp.ts # Integration tests (InMemoryTransport) + ├── .env.example # API key template for image providers └── package.json ``` @@ -222,6 +271,7 @@ TabletopExercise/ [scenario-dir]/ ├── index.qmd # 4 sections appended (inject sequence, NPC dialogue, red herrings, gap analysis) ├── handout-a-[slug].qmd # Player handout A (print-safe) +├── handout-a-[slug].png # AI-generated image (if image_data set on artifact) ├── handout-b-[slug].qmd # Player handout B (print-safe, if present) └── exercise-data.json # Validated exercise data (serialized) ``` @@ -735,6 +785,23 @@ Skill enhanced with learnings from: ## Version History +**v3.2** (2026-03-07) - HTML Artifact Templates +- Added `generate-html-artifacts.ts` — six self-contained HTML/CSS templates for UI-heavy artifact subtypes: `phishing_email` (macOS Mail client chrome), `ransomware_note` (dark splash screen with BTC address), `fraudulent_invoice` (white paper layout), `network_capture` (Wireshark dark table), `dark_web_listing` (terminal green-on-black), `scada_interface` (industrial HMI with CSS gauges) +- `generate_attack_vector_images` and `generate_evidence_images` now route UI subtypes to HTML templates (no API key required); physical subtypes still use AI providers +- UI-subtype artifacts receive `html_data` (self-contained HTML string); AI-rendered artifacts receive `image_data` (base64 PNG); `generate_exercise` renders both inline +- Added `html_data` field to `ArtifactSchema` in `schema.ts` +- Tests expanded from 75 to 83 assertions (Tests 25-26 verify HTML template path; Tests 19-20 updated to use physical subtypes for AI fallback coverage) + +**v3.1** (2026-03-07) - AI Image Generation +- Added `generate-images.ts` — provider registry supporting OpenAI (DALL-E 3), Gemini (Imagen 4), Stability AI, Replicate (Flux Schnell), Ollama (self-hosted) +- `IMAGE_PROVIDER` accepts a comma-separated priority chain (`openai,replicate`) — each provider tried in order, first success wins; `provider_used` in tool response shows which provider(s) fired +- Added 3 new MCP tools: `generate_attack_vector_images`, `generate_evidence_images`, `generate_atmosphere_images` +- Added `ImageSubtypeSchema` (12 subtypes) and `VisualStyleSchema` for cross-scenario style consistency +- `generate_exercise` renders `` tags for artifacts with `image_data`; cover art embedded in cover page +- `generate_exercise_qmd` writes `[slug].png` alongside handout QMD files when `image_data` is present +- API keys loaded from `.env` in generators directory; shell environment always takes precedence +- Tests expanded from 63 to 75 assertions (Tests 19-24 cover image generation error paths and schema) + **v3.0** (2026-03-07) - MCP Server + Quarto Output - Added MCP server (`mcp-server.ts`) with 7 tools and 3 resources - Added `schema.ts` — Zod v3 schemas as single source of truth for types, validation, and JSON Schema resource diff --git a/TabletopExercise/SKILL.md b/TabletopExercise/SKILL.md index 0257e14..612d22d 100644 --- a/TabletopExercise/SKILL.md +++ b/TabletopExercise/SKILL.md @@ -1349,6 +1349,60 @@ bun run generate-pdf.ts \ --- +## Image Generation + +TabletopExercise can generate AI images for three pedagogically distinct purposes. Use the MCP tools or invoke them in sequence during exercise creation. + +### Three image categories + +| Category | MCP Tool | What it generates | Difficulty guidance | +|----------|----------|-------------------|---------------------| +| **Attack vector** | `generate_attack_vector_images` | What the victim saw: phishing emails, ransomware notes, fake invoices, USB devices | All levels (phishing/invoice), Intermediate+ (ransomware note) | +| **Evidence** | `generate_evidence_images` | What investigators find: SIEM logs, network diagrams, dark web listings, SCADA interfaces | All (network diagram), Advanced (SCADA, packet captures) | +| **Atmosphere** | `generate_atmosphere_images` | World-building: cover art, NPC portraits, location illustrations, period photographs | All (portraits, cover), Historical only (period_photograph) | + +### Recommended workflow + +``` +validate_exercise_data + → generate_attack_vector_images # populates artifact.image_data + → generate_evidence_images # populates artifact.image_data + → generate_atmosphere_images # populates cover_image_data + NPC portraits + → generate_exercise # facilitator.html contains tags + → generate_exercise_qmd # handout PNGs written to output_dir +``` + +### Provider selection + +Set `IMAGE_PROVIDER` env var (default: `openai`). Set the corresponding API key. + +| Provider | Env var | Notes | +|----------|---------|-------| +| `openai` | `OPENAI_API_KEY` | DALL-E 3 -- best prompt adherence | +| `gemini` | `GEMINI_API_KEY` | Google Imagen (imagen-3.0-generate-002) | +| `stability` | `STABILITY_API_KEY` | Stability AI REST API | +| `replicate` | `REPLICATE_API_KEY` | Default model: flux-schnell. Override with `REPLICATE_MODEL` | +| `ollama` | none | Self-hosted at `OLLAMA_BASE_URL` (default: http://localhost:11434) | + +### Style consistency via `visual_style` + +Add a `visual_style` block to exercise data to ensure cohesive images within a scenario. Reuse the same block across all scenario cards in a malmon family for a unified visual identity across the handbook. + +```json +{ + "visual_style": { + "art_style": "photorealistic", + "color_palette": "high-contrast blue-grey", + "mood": "tense, clinical", + "seed": 42 + } +} +``` + +Pass `visual_style` directly to any image tool to override the data-level setting for that call. + +--- + **Version**: 2.0 (Enhanced from original SOC Manager Table Top Designer) **Enhancements**: - Added technical atomics generation for exercise runners diff --git a/TabletopExercise/generators/generate-html-artifacts.ts b/TabletopExercise/generators/generate-html-artifacts.ts new file mode 100644 index 0000000..40f3bb6 --- /dev/null +++ b/TabletopExercise/generators/generate-html-artifacts.ts @@ -0,0 +1,555 @@ +/** + * HTML/CSS template renderer for UI-heavy artifact subtypes. + * + * AI diffusion models produce fuzzy, unreadable text when asked to render + * phishing emails, ransomware notes, log viewers, etc. These templates inject + * artifact_content verbatim so text is always accurate and legible at any zoom. + * + * Routing: + * HTML template — phishing_email, ransomware_note, fraudulent_invoice, + * network_capture, dark_web_listing, scada_interface + * AI provider — usb_device, network_diagram, period_photograph, + * portrait, location_illustration, cover_art + */ + +import type { ImageSubtype, VisualStyle } from './schema.ts'; + +// --------------------------------------------------------------------------- +// HTML subtype set (caller checks this before deciding which path to take) +// --------------------------------------------------------------------------- + +const HTML_SUBTYPES = new Set([ + 'phishing_email', + 'ransomware_note', + 'fraudulent_invoice', + 'network_capture', + 'dark_web_listing', + 'scada_interface', +]); + +export function isHtmlSubtype(subtype: ImageSubtype): boolean { + return HTML_SUBTYPES.has(subtype); +} + +// --------------------------------------------------------------------------- +// Shared utility +// --------------------------------------------------------------------------- + +function esc(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** Normalise content so both real newlines and literal \n sequences split correctly. */ +function splitLines(content: string): string[] { + return content.replace(/\\n/g, '\n').split('\n').filter(l => l.trim()); +} + +// --------------------------------------------------------------------------- +// Phishing email — macOS/Outlook mail client chrome +// --------------------------------------------------------------------------- + +function renderPhishingEmail(title: string, content: string): string { + const lines = splitLines(content); + const bodyHtml = lines.map(l => `

${esc(l)}

`).join('') || '

(no content)

'; + const previewText = esc(lines[0] ?? ''); + + return ` + +
+ +
+
+ 10:34 AM +
IT Security Team
+
${esc(title)}
+
${previewText}
+
+
+ Yesterday +
HR Department
+
Q4 Review Reminder
+
Please complete your self-assessment by...
+
+
+ Mon +
IT Helpdesk
+
Scheduled Maintenance
+
Systems will be unavailable Saturday 2–4 AM...
+
+
+ +
+`; +} + +// --------------------------------------------------------------------------- +// Ransomware note — full-bleed dark splash screen +// --------------------------------------------------------------------------- + +function renderRansomwareNote(title: string, content: string): string { + const lines = splitLines(content); + const bodyHtml = lines.map(l => `
${esc(l)}
`).join('') + || '
All your files have been encrypted with military-grade encryption. Pay the ransom to recover them.
'; + + return ` + +
+
+

${esc(title)}

+
⚠ CRITICAL SECURITY ALERT ⚠
+
${bodyHtml}
+
+
Bitcoin Payment Address
+
bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh
+
+
+
Your Unique Decryption Key ID
+
DCRYPT-A7F3B9C2-E1D4F8A6
+
+
Time Remaining to Pay
+
71:58:43
+ +
+`; +} + +// --------------------------------------------------------------------------- +// Fraudulent invoice — white paper with invoice layout +// --------------------------------------------------------------------------- + +function renderFraudulentInvoice(title: string, content: string): string { + const contentHtml = esc(content.trim()) || 'Professional consulting services rendered per agreement.'; + + return ` + +
+
+
COMPANY LOGO
+
+

INVOICE

+
Invoice #: INV-20847
+
Date: 28/02/2026
+
Due: 30/03/2026
+
+
+
+
+

From

+

GlobalTech Solutions Ltd

+

123 Business Park
London, EC2A 4NE
VAT: GB123456789

+
+
+

Bill To

+

Your Company Inc.

+

456 Corporate Drive
Manchester, M1 5AN

+
+
+
${esc(title)}
+ + + + + + + + +
#DescriptionQtyUnit PriceAmount
1Professional Services — Q1 Retainer1$4,750.00$4,750.00
2Expenses & Disbursements1$950.00$950.00
+ + + + +
Subtotal:$5,700.00
VAT (20%):$1,140.00
AMOUNT DUE:$6,840.00
+
Notes: ${contentHtml}
+
+

Payment Instructions

+ Bank: HSBC UK · Sort Code: 40-00-01 · Account: 12345678
+ Reference: INV-20847 · Payment due within 30 days +
+ +
+`; +} + +// --------------------------------------------------------------------------- +// Network capture — dark Wireshark/Splunk table +// --------------------------------------------------------------------------- + +function renderNetworkCapture(title: string, content: string): string { + const lines = splitLines(content); + + const protocols = ['TCP', 'TLS', 'HTTP', 'DNS', 'ICMP'] as const; + const protocolColors: Record = { + TCP: '#dbeafe', TLS: '#d1fae5', HTTP: '#fef3c7', DNS: '#f3e8ff', ICMP: '#fee2e2', + }; + + const dataLines = lines.length > 0 ? lines : [ + 'SYN -> 203.0.113.42:443 [suspicious outbound]', + 'TLS ClientHello -> 203.0.113.42:443', + 'HTTP GET /config.php?id=exfil HTTP/1.1', + 'DNS query: evil.example.com A?', + 'ICMP Echo Request -> 10.0.0.254', + 'HTTP POST /upload?token=abc123', + 'TCP FIN -> 203.0.113.42:443', + ]; + + const rows = dataLines.slice(0, 20).map((line, i) => { + const ipMatch = line.match(/\b(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\b/); + const src = ipMatch ? ipMatch[1] : `192.0.2.${10 + i}`; + const dst = i % 3 === 0 ? '203.0.113.42' : `10.0.0.${i + 1}`; + const proto = protocols[i % protocols.length]; + const bg = protocolColors[proto]; + const ts = (i * 0.347).toFixed(3); + return ` + ${i + 1}${ts}${esc(src)} + ${esc(dst)}${proto} + ${esc(line.slice(0, 80))} + `; + }); + + return ` + +
+ Wireshark · ${esc(title)} + FileEditView + CaptureAnalyze + ● Live Capture +
+
+ Filter: + + Apply + Clear +
+
${rows.length} packets captured · Elapsed: 00:04:23 · Interface: eth0
+ + + ${rows.join('')} +
No.TimeSourceDestinationProtocolInfo
+
+

Packet Details

+
> Frame 1 (54 bytes on wire)
+
> Ethernet II, Src: 00:11:22:33:44:55
+
> Internet Protocol, Src: 192.0.2.10, Dst: 203.0.113.42
+
> Transmission Control Protocol, Src Port: 54312, Dst Port: 443
+
+`; +} + +// --------------------------------------------------------------------------- +// Dark web listing — terminal green-on-black +// --------------------------------------------------------------------------- + +function renderDarkWebListing(title: string, content: string): string { + const lines = splitLines(content); + const bodyHtml = lines.map(l => `
${esc(l)}
`).join('') + || '
Sensitive corporate data available for purchase. Premium quality verified dump. Sample provided on request via secure channel.
'; + + return ` + +
[tor@anon ~]$ lynx http://breach4sale7z2sxj.onion
+
+
+
BreachMarket [v3.1]
+
[ Verified · PGP Required · XMR Only ]
+
+
Anonymous · Secure · No Logs · Since 2019
+ +
+
+
+
${esc(title)}
+
#LST-D4C9E2F1
+
+
+
Seller
@d4rkspectr3
+
Posted
2 days ago
+
Views
847
+
Verified
✓ VERIFIED
+
Escrow
Available
+
+
+ CorporateFinance + PIICredentials +
+
${bodyHtml}
+
+
+
ASKING PRICE
+
2.5 XMR
+
+
[ BUY NOW ]
+
+
+ +`; +} + +// --------------------------------------------------------------------------- +// SCADA/ICS HMI interface — industrial control panel +// --------------------------------------------------------------------------- + +function renderScadaInterface(title: string, content: string): string { + const lines = splitLines(content); + + const severities = ['CRITICAL', 'WARNING', 'ALARM', 'WARNING'] as const; + const severityColors: Record = { + CRITICAL: '#ef4444', WARNING: '#f59e0b', ALARM: '#f97316', + }; + + const dataLines = lines.length > 0 ? lines : [ + 'Pressure sensor PV-101: value out of range (142.3 bar)', + 'Temperature TX-202 exceeds threshold: 87.4°C', + 'Flow meter FM-305: communication failure', + 'Valve V-107: position feedback mismatch', + ]; + + const alarmRows = dataLines.slice(0, 8).map((line, i) => { + const sev = severities[i % severities.length]; + const color = severityColors[sev]; + const timeOffset = i * 73000; + const t = new Date(1741356187000 - timeOffset); + const timeStr = `${String(t.getUTCHours()).padStart(2,'0')}:${String(t.getUTCMinutes()).padStart(2,'0')}:${String(t.getUTCSeconds()).padStart(2,'0')}`; + return ` + ${sev} + ${esc(line.slice(0, 70))} + ${timeStr} + ACTIVE + `; + }); + + return ` + +
+ +
+ ● ALARM STATE + · PLC: ONLINE + · Historian: ONLINE +
+
+
+
+

${esc(title)}

+
Last Update: 14:23:07 · Operator: OPS-02
+
+
+
Pressure PV-101
+
142
+
bar
+
OVER LIMIT
+
+
+
Temperature TX-202
+
87
+
°C
+
HIGH
+
+
+
Flow FM-305
+
--
+
m³/h
+
COMM FAULT
+
+
+

Active Alarms & Events

+ + + ${alarmRows.join('')} +
SeverityDescriptionTimeStatus
+
+
+`; +} + +// --------------------------------------------------------------------------- +// Router +// --------------------------------------------------------------------------- + +/** + * Returns a self-contained HTML string for UI-heavy artifact subtypes, + * or null if the subtype should be rendered by an AI image provider. + */ +export function htmlArtifactForSubtype( + subtype: ImageSubtype, + title: string, + content: string, + _style?: VisualStyle, +): string | null { + switch (subtype) { + case 'phishing_email': return renderPhishingEmail(title, content); + case 'ransomware_note': return renderRansomwareNote(title, content); + case 'fraudulent_invoice': return renderFraudulentInvoice(title, content); + case 'network_capture': return renderNetworkCapture(title, content); + case 'dark_web_listing': return renderDarkWebListing(title, content); + case 'scada_interface': return renderScadaInterface(title, content); + default: return null; + } +} diff --git a/TabletopExercise/generators/generate-images.ts b/TabletopExercise/generators/generate-images.ts new file mode 100644 index 0000000..bc1b23c --- /dev/null +++ b/TabletopExercise/generators/generate-images.ts @@ -0,0 +1,478 @@ +/** + * AI Image Generation for TabletopExercise + * + * Three image categories (inspired by TTRPG design): + * 1. Attack vector — what the victim saw (phishing, ransomware note, USB, etc.) + * 2. Evidence — what investigators find (SIEM alert, network diagram, dark web listing) + * 3. Atmosphere — world-building (NPC portraits, location art, cover art) + * + * Provider selection: IMAGE_PROVIDER env var (default: openai) + * API keys read from env — never hardcoded. + * Add new providers by implementing ImageProviderImpl and registering in PROVIDERS. + */ + +import type { Artifact, VisualStyle, ImageSubtype } from './schema.ts'; +import { htmlArtifactForSubtype } from './generate-html-artifacts.ts'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type ImageProviderName = 'openai' | 'gemini' | 'stability' | 'replicate' | 'ollama'; + +export interface GenerateOptions { + width?: number; + height?: number; + quality?: 'standard' | 'hd'; + style?: VisualStyle; +} + +interface ImageProviderImpl { + /** Returns base64 data URI: data:image/png;base64,... */ + generate(prompt: string, options: GenerateOptions): Promise; +} + +// --------------------------------------------------------------------------- +// Prompt builders +// --------------------------------------------------------------------------- + +const STYLE_SAFE = 'Safe for work. No violence, gore, or explicit content.'; + +function styleToSuffix(style?: VisualStyle): string { + if (!style) return ''; + const parts: string[] = []; + if (style.art_style) parts.push(`Art style: ${style.art_style}.`); + if (style.color_palette) parts.push(`Color palette: ${style.color_palette}.`); + if (style.mood) parts.push(`Mood: ${style.mood}.`); + return parts.length > 0 ? `\n\n${parts.join(' ')}` : ''; +} + +function subtypeFromArtifact(artifact: Artifact): ImageSubtype { + if (artifact.image_subtype) return artifact.image_subtype; + switch (artifact.type) { + case 'email': return 'phishing_email'; + case 'screenshot': return 'ransomware_note'; + case 'document': return 'fraudulent_invoice'; + case 'log': return 'network_capture'; + case 'alert': return 'dark_web_listing'; + default: return 'network_diagram'; + } +} + +function buildAttackVectorPrompt(artifact: Artifact, style?: VisualStyle): string { + const subtype = subtypeFromArtifact(artifact); + const title = artifact.title; + const preview = (artifact.artifact_content ?? artifact.content ?? '').slice(0, 300); + + let base: string; + switch (subtype) { + case 'phishing_email': + base = `A realistic screenshot of a phishing email in a standard email client. ` + + `The email is titled "${title}". ` + + (preview ? `Email body text begins: "${preview}". ` : '') + + `The email appears legitimate but has subtle red flags: mismatched sender domain, urgency cues. ` + + `Wide desktop screenshot showing full email client UI with inbox sidebar.`; + break; + case 'ransomware_note': + base = `A screenshot of a computer screen showing a ransomware splash screen. ` + + `Title: "${title}". Dark background, threatening text in bright red or white. ` + + (preview ? `Message preview: "${preview}". ` : '') + + `Includes a countdown timer, bitcoin address field, and contact instructions.`; + break; + case 'fraudulent_invoice': + base = `A scan or photo of a fraudulent corporate invoice document. ` + + `Document title: "${title}". ` + + (preview ? `Content excerpt: "${preview}". ` : '') + + `Professional layout with company logo placeholder, line items table, and payment instructions. ` + + `Realistic paper texture, slightly off-center scan.`; + break; + case 'usb_device': + base = `A close-up photo of a USB flash drive found at a reception desk. ` + + `Label reads "${title}". ` + + `Generic office environment background, slightly suspicious placement. ` + + `Photorealistic product shot.`; + break; + default: + base = `A realistic screenshot or visual representation of a cybersecurity artifact titled "${title}". ` + + (preview ? `Content: "${preview}". ` : ''); + } + + return `${base} ${STYLE_SAFE}${styleToSuffix(style)}`; +} + +function buildEvidencePrompt(artifact: Artifact, style?: VisualStyle): string { + const subtype = subtypeFromArtifact(artifact); + const title = artifact.title; + const preview = (artifact.artifact_content ?? artifact.content ?? '').slice(0, 300); + + let base: string; + switch (subtype) { + case 'network_diagram': + base = `A professional network topology diagram titled "${title}". ` + + `Shows interconnected nodes: workstations, servers, firewalls, cloud services. ` + + `Includes color-coded threat paths highlighted in red. Clean technical illustration style.`; + break; + case 'dark_web_listing': + base = `A screenshot of a dark web forum or marketplace listing titled "${title}". ` + + `Dark background, monospaced font, anonymous handles. ` + + (preview ? `Listing preview: "${preview}". ` : '') + + `Realistic underground forum UI. Redacted/fictional PII. No real credentials.`; + break; + case 'scada_interface': + base = `A screenshot of a SCADA/ICS industrial control system HMI interface titled "${title}". ` + + `Shows plant process diagram with sensors, valves, and status indicators. ` + + (preview ? `Status data: "${preview}". ` : '') + + `Green and amber status lights, slightly dated industrial software aesthetic.`; + break; + case 'network_capture': + base = `A screenshot of a network packet capture or SIEM log viewer titled "${title}". ` + + `Shows rows of log entries with timestamps, IP addresses (RFC 5737 TEST-NET), protocols. ` + + (preview ? `Sample entries: "${preview}". ` : '') + + `Wireshark or Splunk-style dark theme UI.`; + break; + default: + base = `A professional cybersecurity investigation evidence screenshot titled "${title}". ` + + (preview ? `Content visible: "${preview}". ` : '') + + `Technical, analytical aesthetic with data tables and charts.`; + } + + return `${base} ${STYLE_SAFE}${styleToSuffix(style)}`; +} + +export interface AtmosphereContext { + scenario_title: string; + npc_name?: string; + npc_role?: string; + location?: string; + scenario_overview?: string; + subtype: ImageSubtype; +} + +function buildAtmospherePrompt(ctx: AtmosphereContext, style?: VisualStyle): string { + let base: string; + switch (ctx.subtype) { + case 'portrait': + base = `A professional portrait illustration of ${ctx.npc_name ?? 'a corporate employee'}` + + (ctx.npc_role ? `, ${ctx.npc_role}` : '') + + `. The portrait conveys their personality and role in a cybersecurity incident scenario. ` + + `Stylized corporate headshot, neutral background.`; + break; + case 'location_illustration': + base = `An establishing illustration of ${ctx.location ?? 'a corporate office environment'} ` + + `relevant to the scenario "${ctx.scenario_title}". ` + + `Cinematic wide angle, atmospheric, sets the scene before the incident begins.`; + break; + case 'cover_art': + base = `Cover art for a cybersecurity tabletop exercise titled "${ctx.scenario_title}". ` + + (ctx.scenario_overview ? `Scenario context: "${ctx.scenario_overview.slice(0, 200)}". ` : '') + + `Professional, dramatic composition suitable for a printed exercise booklet cover. ` + + `No text overlay needed.`; + break; + case 'period_photograph': + base = `A period-accurate archival photograph style image depicting ${ctx.location ?? 'a historical computing environment'} ` + + `related to the scenario "${ctx.scenario_title}". ` + + `Sepia or muted color tone, era-appropriate equipment and clothing.`; + break; + default: + base = `An atmospheric illustration for the cybersecurity exercise "${ctx.scenario_title}". ` + + `Evocative, professional, sets the tone for a serious incident response scenario.`; + } + + return `${base} ${STYLE_SAFE}${styleToSuffix(style)}`; +} + +// --------------------------------------------------------------------------- +// Provider implementations +// --------------------------------------------------------------------------- + +class OpenAIProvider implements ImageProviderImpl { + async generate(prompt: string, options: GenerateOptions): Promise { + const apiKey = process.env.OPENAI_API_KEY; + if (!apiKey) throw new Error('OPENAI_API_KEY not set'); + + const resp = await fetch('https://api.openai.com/v1/images/generations', { + method: 'POST', + headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: process.env.OPENAI_IMAGE_MODEL ?? 'dall-e-3', + prompt, + n: 1, + size: `${options.width ?? 1024}x${options.height ?? 1024}`, + quality: options.quality ?? 'standard', + response_format: 'b64_json', + }), + }); + if (!resp.ok) { + const err = await resp.text().catch(() => resp.statusText); + throw new Error(`OpenAI API error ${resp.status}: ${err}`); + } + const json = await resp.json() as { data: Array<{ b64_json: string }> }; + return `data:image/png;base64,${json.data[0].b64_json}`; + } +} + +class GeminiProvider implements ImageProviderImpl { + async generate(prompt: string, _options: GenerateOptions): Promise { + const apiKey = process.env.GEMINI_API_KEY; + if (!apiKey) throw new Error('GEMINI_API_KEY not set'); + + // Imagen 4 via predict endpoint (default), or override with GEMINI_IMAGE_MODEL + const model = process.env.GEMINI_IMAGE_MODEL ?? 'imagen-4.0-generate-001'; + const resp = await fetch( + `https://generativelanguage.googleapis.com/v1beta/models/${model}:predict?key=${apiKey}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + instances: [{ prompt }], + parameters: { sampleCount: 1 }, + }), + } + ); + if (!resp.ok) { + const err = await resp.text().catch(() => resp.statusText); + throw new Error(`Gemini API error ${resp.status}: ${err}`); + } + const json = await resp.json() as { predictions: Array<{ bytesBase64Encoded: string; mimeType?: string }> }; + const prediction = json.predictions[0]; + if (!prediction?.bytesBase64Encoded) throw new Error('Gemini returned no image data'); + return `data:${prediction.mimeType ?? 'image/png'};base64,${prediction.bytesBase64Encoded}`; + } +} + +class StabilityProvider implements ImageProviderImpl { + async generate(prompt: string, options: GenerateOptions): Promise { + const apiKey = process.env.STABILITY_API_KEY; + if (!apiKey) throw new Error('STABILITY_API_KEY not set'); + + const model = process.env.STABILITY_MODEL ?? 'core'; + const endpoint = `https://api.stability.ai/v2beta/stable-image/generate/${model}`; + const resp = await fetch(endpoint, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${apiKey}`, + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + prompt, + output_format: 'png', + width: options.width ?? 1024, + height: options.height ?? 1024, + }), + }); + if (!resp.ok) { + const err = await resp.text().catch(() => resp.statusText); + throw new Error(`Stability API error ${resp.status}: ${err}`); + } + const json = await resp.json() as { image: string }; + return `data:image/png;base64,${json.image}`; + } +} + +class ReplicateProvider implements ImageProviderImpl { + async generate(prompt: string, _options: GenerateOptions): Promise { + const apiKey = process.env.REPLICATE_API_KEY; + if (!apiKey) throw new Error('REPLICATE_API_KEY not set'); + + const model = process.env.REPLICATE_MODEL ?? 'black-forest-labs/flux-schnell'; + + // Start prediction + const startResp = await fetch('https://api.replicate.com/v1/predictions', { + method: 'POST', + headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ version: model, input: { prompt } }), + }); + if (!startResp.ok) { + const err = await startResp.text().catch(() => startResp.statusText); + throw new Error(`Replicate API error ${startResp.status}: ${err}`); + } + const prediction = await startResp.json() as { id: string; urls: { get: string } }; + + // Poll until done + for (let i = 0; i < 30; i++) { + await new Promise(r => setTimeout(r, 2000)); + const pollResp = await fetch(prediction.urls.get, { + headers: { 'Authorization': `Bearer ${apiKey}` }, + }); + const poll = await pollResp.json() as { status: string; output?: string[] }; + if (poll.status === 'succeeded' && poll.output?.[0]) { + const imgResp = await fetch(poll.output[0]); + const buf = await imgResp.arrayBuffer(); + const b64 = Buffer.from(buf).toString('base64'); + return `data:image/png;base64,${b64}`; + } + if (poll.status === 'failed') throw new Error('Replicate prediction failed'); + } + throw new Error('Replicate prediction timed out'); + } +} + +class OllamaProvider implements ImageProviderImpl { + async generate(prompt: string, _options: GenerateOptions): Promise { + const baseUrl = process.env.OLLAMA_BASE_URL ?? 'http://localhost:11434'; + const model = process.env.OLLAMA_IMAGE_MODEL ?? 'llava'; + + const resp = await fetch(`${baseUrl}/api/generate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model, prompt, stream: false }), + }); + if (!resp.ok) { + const err = await resp.text().catch(() => resp.statusText); + throw new Error(`Ollama API error ${resp.status}: ${err}`); + } + const json = await resp.json() as { images?: string[] }; + if (!json.images?.[0]) throw new Error('Ollama returned no image data'); + return `data:image/png;base64,${json.images[0]}`; + } +} + +// --------------------------------------------------------------------------- +// Provider registry +// --------------------------------------------------------------------------- + +const PROVIDERS: Record ImageProviderImpl> = { + openai: () => new OpenAIProvider(), + gemini: () => new GeminiProvider(), + stability: () => new StabilityProvider(), + replicate: () => new ReplicateProvider(), + ollama: () => new OllamaProvider(), +}; + +function getProvider(name: ImageProviderName): ImageProviderImpl { + const factory = PROVIDERS[name]; + if (!factory) throw new Error(`Unknown image provider: "${name}". Valid providers: ${Object.keys(PROVIDERS).join(', ')}`); + return factory(); +} + +/** + * Parse IMAGE_PROVIDER into an ordered chain. + * Accepts a single name ("openai") or comma-separated priority list + * ("anthropic,openai,replicate"). Each entry is tried in order; the + * first success wins. + */ +function buildProviderChain(): Array<[string, ImageProviderImpl]> { + const raw = process.env.IMAGE_PROVIDER ?? 'openai'; + return raw.split(',').map(s => s.trim()).filter(Boolean).map(name => [name, getProvider(name as ImageProviderName)] as [string, ImageProviderImpl]); +} + +/** + * Try each provider in the chain. Returns on first success. + * Throws a combined error listing every failure if all providers fail. + */ +async function generateWithFallback(prompt: string, options: GenerateOptions): Promise<{ imageData: string; providerUsed: string }> { + const chain = buildProviderChain(); + const errors: string[] = []; + + for (const [name, impl] of chain) { + try { + const imageData = await impl.generate(prompt, options); + return { imageData, providerUsed: name }; + } catch (err) { + errors.push(`${name}: ${String(err)}`); + } + } + + throw new Error(`All providers failed:\n${errors.join('\n')}`); +} + +// --------------------------------------------------------------------------- +// Three exported generator functions (one per MCP tool) +// --------------------------------------------------------------------------- + +/** + * Tool 8: Generate attack vector images. + * Populates artifact.image_data for attack-vector-type artifacts. + * Returns updated artifacts array + provider used. + */ +export async function generateAttackVectorImages( + artifacts: Artifact[], + style?: VisualStyle, +): Promise<{ updatedArtifacts: Artifact[]; imagesGenerated: number; providerUsed: string }> { + const attackSubtypes = new Set(['ransomware_note', 'phishing_email', 'fraudulent_invoice', 'usb_device']); + const providersUsed = new Set(); + + let imagesGenerated = 0; + const updatedArtifacts = await Promise.all( + artifacts.map(async (artifact) => { + const subtype = subtypeFromArtifact(artifact); + if (!attackSubtypes.has(subtype) && artifact.type !== 'email' && artifact.type !== 'screenshot' && artifact.type !== 'document') { + return artifact; + } + // UI subtypes: render via CSS/HTML template (no API key needed, text always legible) + const content = artifact.artifact_content ?? artifact.content ?? ''; + const htmlTemplate = htmlArtifactForSubtype(subtype, artifact.title, content, style); + if (htmlTemplate) { + imagesGenerated++; + return { ...artifact, html_data: htmlTemplate }; + } + // Physical subtypes: fall through to AI provider + const prompt = buildAttackVectorPrompt(artifact, style); + const { imageData, providerUsed } = await generateWithFallback(prompt, { style }); + providersUsed.add(providerUsed); + imagesGenerated++; + return { ...artifact, image_data: imageData }; + }) + ); + + return { updatedArtifacts, imagesGenerated, providerUsed: [...providersUsed].join(', ') }; +} + +/** + * Tool 9: Generate evidence images. + * Populates artifact.image_data for evidence-type artifacts. + */ +export async function generateEvidenceImages( + artifacts: Artifact[], + style?: VisualStyle, +): Promise<{ updatedArtifacts: Artifact[]; imagesGenerated: number; providerUsed: string }> { + const evidenceSubtypes = new Set(['scada_interface', 'network_capture', 'dark_web_listing', 'network_diagram']); + const providersUsed = new Set(); + + let imagesGenerated = 0; + const updatedArtifacts = await Promise.all( + artifacts.map(async (artifact) => { + const subtype = subtypeFromArtifact(artifact); + if (!evidenceSubtypes.has(subtype) && artifact.type !== 'log' && artifact.type !== 'alert') { + return artifact; + } + // UI subtypes: render via CSS/HTML template + const content = artifact.artifact_content ?? artifact.content ?? ''; + const htmlTemplate = htmlArtifactForSubtype(subtype, artifact.title, content, style); + if (htmlTemplate) { + imagesGenerated++; + return { ...artifact, html_data: htmlTemplate }; + } + // Physical/diagram subtypes: fall through to AI provider + const prompt = buildEvidencePrompt(artifact, style); + const { imageData, providerUsed } = await generateWithFallback(prompt, { style }); + providersUsed.add(providerUsed); + imagesGenerated++; + return { ...artifact, image_data: imageData }; + }) + ); + + return { updatedArtifacts, imagesGenerated, providerUsed: [...providersUsed].join(', ') }; +} + +/** + * Tool 10: Generate atmosphere images. + * Generates cover_image_data and NPC portrait_data. + */ +export async function generateAtmosphereImages( + context: AtmosphereContext[], + style?: VisualStyle, +): Promise<{ results: Array<{ subtype: string; image_data: string }>; imagesGenerated: number; providerUsed: string }> { + const providersUsed = new Set(); + const results: Array<{ subtype: string; image_data: string }> = []; + + for (const ctx of context) { + const prompt = buildAtmospherePrompt(ctx, style); + const { imageData, providerUsed } = await generateWithFallback(prompt, { style }); + providersUsed.add(providerUsed); + results.push({ subtype: ctx.subtype, image_data: imageData }); + } + + return { results, imagesGenerated: results.length, providerUsed: [...providersUsed].join(', ') }; +} diff --git a/TabletopExercise/generators/generate-pdf.ts b/TabletopExercise/generators/generate-pdf.ts index 2651f7b..dc3481f 100644 --- a/TabletopExercise/generators/generate-pdf.ts +++ b/TabletopExercise/generators/generate-pdf.ts @@ -27,6 +27,18 @@ interface TabletopExerciseData { date: string; version: string; + // Image generation + cover_image_data?: string; + artifacts?: Array<{ + id: string; + type: string; + title: string; + content?: string; + image_data?: string; + html_data?: string; + image_subtype?: string; + }>; + // Executive Summary executiveSummary: string; attackVector: string; @@ -1006,12 +1018,14 @@ function buildHtml(data: TabletopExerciseData): string {
- `}
@@ -1516,6 +1530,26 @@ function buildHtml(data: TabletopExerciseData): string {
+ ${(data.artifacts ?? []).some(a => a.html_data || a.image_data) ? ` +
+
+

Artifact Images

+
+
+ ${(data.artifacts ?? []).filter(a => a.html_data || a.image_data).map(a => ` +
+

${escapeHtml(a.title)}

+ ${a.html_data + ? `
${a.html_data}
` + : `${escapeHtml(a.title)}` + } + ${a.content ? `

${escapeHtml(a.content)}

` : ''} +
+ `).join('')} +
+
+ ` : ''} +