diff --git a/.claude/skills/deep-review/SKILL.md b/.claude/skills/deep-review/SKILL.md index 43d52d3..6108df5 100644 --- a/.claude/skills/deep-review/SKILL.md +++ b/.claude/skills/deep-review/SKILL.md @@ -15,6 +15,11 @@ pushing significant changes or creating pull requests. ## Steps +0. **Security MCP preflight (when security tooling is in scope)** + - Run `/security-run` or `pnpm security:mcp-preflight` with the session MCP descriptor path and `--allow-fallback` + - See [docs/security/security-runbook.md](../../../docs/security/security-runbook.md) + - Do not skip scans when Opsera/JFrog/Semgrep MCP is degraded; use documented fallbacks + 1. **Run preflight checks** - Execute `pnpm preflight` (format, lint, typecheck, spellcheck, knip, test:unit) - If any check fails, report issues and stop diff --git a/.claude/skills/security-run/SKILL.md b/.claude/skills/security-run/SKILL.md new file mode 100644 index 0000000..99c273e --- /dev/null +++ b/.claude/skills/security-run/SKILL.md @@ -0,0 +1,74 @@ +--- +description: Run MCP security preflight, governance checks, and documented fallbacks for Blit-Tech security workflows. +--- + +# Security Run + +Deterministic security workflow for `blit-tech` and cross-repo assessments. Use before comprehensive security scans, hardening passes, or when MCP scanner availability is uncertain. + +## Usage + +```text +/security-run +``` + +## Prerequisites + +- Cursor MCP descriptor path from the session (`mcps` folder for the active workspace). +- Node.js and pnpm per repo `package.json`. + +## Steps + +1. **MCP preflight (required)** + - Read tool schemas before any MCP tool calls (per environment rules). + - Run: + + ```bash + pnpm security:mcp-preflight -- \ + --mcps-dir "" \ + --repo-root . \ + --allow-fallback \ + --output-json security-reports/mcp-preflight-latest.json + ``` + + - Record each security MCP status: `healthy`, `auth_required`, `errored`, or `absent`. + - If Opsera (`plugin-opsera-devsecops-opsera`) is not `healthy`, do **not** skip scans; continue with fallbacks from [docs/security/security-runbook.md](../../../docs/security/security-runbook.md). + +2. **Repo-native checks (this repo)** + - `pnpm security:audit` + - `pnpm audit --prod --audit-level=moderate` + - `pnpm audit --dev --audit-level=moderate` + - `pnpm preflight` + +3. **Optional MCP-backed scans (only when healthy)** + - Opsera: `architecture-analyze`, `security-scan`, `compliance-audit` (inspect plugin tool schemas first). + - JFrog / Semgrep: only when server status is `healthy`. + +4. **Cross-repo (when assessing both repos)** + - Repeat step 2 in `blit-tech-demos` using paths from the runbook. + - Use the same `--mcps-dir` for both repos. + +5. **Report** + - Emit the report template from the runbook. + - List every fallback executed and why. + +## Periodic governance (monthly) + +Run once per month for each repo: + +```bash +pnpm security:mcp-preflight -- \ + --mcps-dir "" \ + --repo-root . \ + --governance-only \ + --include-user-config \ + --output-json security-reports/mcp-governance-$(date +%Y-%m).json +``` + +Review shadow MCP flags. Do not output secrets or full MCP config values (server names only). + +## References + +- [docs/security/security-runbook.md](../../../docs/security/security-runbook.md) +- `scripts/security/mcp-preflight.mjs` +- Runlayer MCP governance rule (shadow MCP detection) diff --git a/.gitignore b/.gitignore index 45a1faa..aeca18d 100644 --- a/.gitignore +++ b/.gitignore @@ -60,6 +60,9 @@ pnpm-debug.log* tmp/ temp/ +# Local security run reports (MCP preflight / governance) +security-reports/ + # Output of 'npm pack' *.tgz diff --git a/cspell.json b/cspell.json index 16656fe..ac9677e 100644 --- a/cspell.json +++ b/cspell.json @@ -41,8 +41,10 @@ "lerps", "libretro", "Lottes", + "mcps", "millis", "MULT", + "opsera", "nsis", "optimisation", "PICO", @@ -51,6 +53,7 @@ "Playwright", "pnpm", "blits", + "semgrep", "rasterize", "rasterized", "rasterizes", @@ -63,6 +66,8 @@ "RGBA", "rgba8unorm", "Rollup", + "runbook", + "Runlayer", "RRGGBB", "RRGGBBAA", "spritesheet", diff --git a/docs/developer-experience-guide.md b/docs/developer-experience-guide.md index e794edd..1045d93 100644 --- a/docs/developer-experience-guide.md +++ b/docs/developer-experience-guide.md @@ -23,36 +23,38 @@ See [CONTRIBUTING.md](../CONTRIBUTING.md) for the full contributor workflow. Key These commands apply when building or maintaining **blit-tech** from a repository checkout (not when consuming the npm package). -| Command | Description | -| --------------------------- | ------------------------------------------------------------------------ | -| `pnpm build` | Build the library for npm distribution | -| `pnpm lint` | Run ESLint | -| `pnpm lint:fix` | Run ESLint with auto-fix | -| `pnpm format` | Format all code (Biome + Prettier) | -| `pnpm format:check` | Check all formatting without changes | -| `pnpm format:biome` | Format TS/JS/JSON/CSS only (Biome) | -| `pnpm format:prettier` | Format Markdown/YAML/HTML/HBS (Prettier) | -| `pnpm typecheck` | Run TypeScript type checking | -| `pnpm spellcheck` | Check spelling in source files | -| `pnpm test` | Run all unit tests (alias for `test:unit`) | -| `pnpm test:unit` | Run all unit tests | -| `pnpm test:unit:watch` | Run unit tests in watch mode | -| `pnpm test:unit:coverage` | Run unit tests with coverage report (80% threshold) | -| `pnpm test:visual` | Playwright visual regression tests (requires Chrome with WebGPU) | -| `pnpm test:visual:update` | Update visual test baseline screenshots | -| `pnpm test:visual:coverage` | Run visual tests with Istanbul coverage report | -| `pnpm bench` | Run Tier 1 CPU benchmarks (Vitest bench) | -| `pnpm bench:json` | Run Tier 1 benchmarks and write `benchmark-results.json` | -| `pnpm preflight` | Run all quality checks (format, lint, typecheck, spellcheck, knip, test) | -| `pnpm knip` | Find unused exports and dependencies | -| `pnpm knip:fix` | Auto-fix unused exports and dependencies | -| `pnpm clean` | Remove dist and cache directories | -| `pnpm release` | Build library and publish to npm | -| `pnpm convert-font` | Convert BMFont to .btfont format | -| `pnpm system-font:export` | Export system font data to PNG atlas (`assets/system-font.png`) | -| `pnpm system-font:convert` | Regenerate `systemFontData.ts` from edited PNG atlas | -| `pnpm security:audit` | Run dependency security audit | -| `pnpm security:audit:fix` | Run dependency security audit and auto-fix | +| Command | Description | +| ------------------------------ | ------------------------------------------------------------------------ | +| `pnpm build` | Build the library for npm distribution | +| `pnpm lint` | Run ESLint | +| `pnpm lint:fix` | Run ESLint with auto-fix | +| `pnpm format` | Format all code (Biome + Prettier) | +| `pnpm format:check` | Check all formatting without changes | +| `pnpm format:biome` | Format TS/JS/JSON/CSS only (Biome) | +| `pnpm format:prettier` | Format Markdown/YAML/HTML/HBS (Prettier) | +| `pnpm typecheck` | Run TypeScript type checking | +| `pnpm spellcheck` | Check spelling in source files | +| `pnpm test` | Run all unit tests (alias for `test:unit`) | +| `pnpm test:unit` | Run all unit tests | +| `pnpm test:unit:watch` | Run unit tests in watch mode | +| `pnpm test:unit:coverage` | Run unit tests with coverage report (80% threshold) | +| `pnpm test:visual` | Playwright visual regression tests (requires Chrome with WebGPU) | +| `pnpm test:visual:update` | Update visual test baseline screenshots | +| `pnpm test:visual:coverage` | Run visual tests with Istanbul coverage report | +| `pnpm bench` | Run Tier 1 CPU benchmarks (Vitest bench) | +| `pnpm bench:json` | Run Tier 1 benchmarks and write `benchmark-results.json` | +| `pnpm preflight` | Run all quality checks (format, lint, typecheck, spellcheck, knip, test) | +| `pnpm knip` | Find unused exports and dependencies | +| `pnpm knip:fix` | Auto-fix unused exports and dependencies | +| `pnpm clean` | Remove dist and cache directories | +| `pnpm release` | Build library and publish to npm | +| `pnpm convert-font` | Convert BMFont to .btfont format | +| `pnpm system-font:export` | Export system font data to PNG atlas (`assets/system-font.png`) | +| `pnpm system-font:convert` | Regenerate `systemFontData.ts` from edited PNG atlas | +| `pnpm security:audit` | Run dependency security audit | +| `pnpm security:audit:fix` | Run dependency security audit and auto-fix | +| `pnpm security:mcp-preflight` | MCP health/auth preflight and governance scan (requires `-- --mcps-dir`) | +| `pnpm test:security-preflight` | Unit tests for MCP preflight script | --- @@ -73,6 +75,7 @@ package). | [Performance Testing](performance-testing.md) | CPU benchmarks, CI regression checks | | [Performance Best Practices](performance-best-practices.md) | optimization guidelines | | [Developer Experience](developer-experience-guide.md) | contributing workflow, IDE setup | +| [Security runbook](security/security-runbook.md) | MCP preflight, fallbacks, governance, security runs | | [Tooling](tooling.md) | TypeScript pin, declaration checks, CI enforcement | | [Voice Guide](voice.md) | error messages and user-facing string style | @@ -201,6 +204,10 @@ each build. It fails on known drift-warning patterns and verifies the API Extrac - [ ] Review analytics/usage (if available) - [ ] Update roadmap - [ ] Check for security advisories +- [ ] Run MCP governance preflight for `blit-tech` and `blit-tech-demos` + (`pnpm security:mcp-preflight -- --governance-only`; see + [docs/security/security-runbook.md](security/security-runbook.md)) +- [ ] Review shadow MCP flags and re-auth critical security MCPs if needed - [ ] Review and close stale issues ### Before Releases diff --git a/docs/security/security-runbook.md b/docs/security/security-runbook.md new file mode 100644 index 0000000..ea6227f --- /dev/null +++ b/docs/security/security-runbook.md @@ -0,0 +1,164 @@ +# Security runbook + +Deterministic security workflow for Blit-Tech repos when MCP scanners are healthy, degraded, or unavailable. Use with +the `/security-run` skill and `pnpm security:mcp-preflight`. + +## When to run + +- Before a comprehensive security assessment or hardening pass. +- Before `/deep-review` when security tooling is in scope. +- Monthly MCP governance audit (see [Periodic governance](#periodic-governance-monthly)). + +## MCP preflight (required first step) + +Agents must pass the Cursor project MCP descriptor path from the session (for example +`~/.cursor/projects//mcps`). + +```bash +cd /Users/vancura/Repos/_BLIT_TECH_/blit-tech + +pnpm security:mcp-preflight -- \ + --mcps-dir "" \ + --repo-root . \ + --allow-fallback \ + --output-json security-reports/mcp-preflight-latest.json +``` + +Governance-only (monthly): + +```bash +pnpm security:mcp-preflight -- \ + --mcps-dir "" \ + --repo-root . \ + --governance-only \ + --include-user-config \ + --output-json security-reports/mcp-governance-$(date +%Y-%m).json +``` + +Exit codes: + +- `0` — proceed (critical MCP healthy, or `--allow-fallback` with documented fallbacks). +- `1` — missing `--mcps-dir`, invalid path, or critical MCP down without `--allow-fallback`. + +Never skip the preflight silently. If a tier is unavailable, run the fallback row from the matrix below and record it in +the report. + +## Fallback matrix + +| Capability | Primary MCP | Fallback (always available) | +| --------------------- | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Dependency / SCA | Opsera `security-scan`, JFrog | `pnpm security:audit`, `pnpm audit --prod --audit-level=moderate` (per repo) | +| SAST / code patterns | Opsera, Semgrep MCP | `pnpm lint` (eslint-plugin-security), targeted `rg` patterns (below), optional `semgrep --config auto` only if CLI is already installed (do not install) | +| Compliance | Opsera `compliance-audit` | Manual checklist below | +| Architecture | Opsera `architecture-analyze` | `security-threat-model` and `security-ownership-map` skills under `~/.codex/skills/` | +| Supply chain metadata | JFrog MCP | `pnpm outdated --format json`, `npm view version time.modified license` for key direct deps | +| MCP governance | — | `pnpm security:mcp-preflight --governance-only` plus Runlayer MCP governance rules | + +### SAST `rg` patterns (fallback) + +Run from each repo root when Semgrep/Opsera SAST is unavailable: + +```bash +rg -n "innerHTML|outerHTML|insertAdjacentHTML|document\\.write\\(|eval\\(|new Function|postMessage\\(|localStorage|sessionStorage" src/ +rg -n "CSP|Content-Security-Policy|X-Frame-Options|frame-ancestors|Referrer-Policy" . +``` + +## Compliance fallback checklist + +When Opsera `compliance-audit` MCP is unavailable, gather evidence manually: + +| Control area | Evidence source | +| ---------------------------- | -------------------------------------------------------------------------------------------- | +| Dependency vulnerabilities | `pnpm security:audit`, `pnpm audit --prod --audit-level=moderate` | +| Code quality / static checks | `pnpm preflight`, `pnpm lint` | +| Secrets in repo | `.gitignore`, hooks blocking `.env`; `rg` for hardcoded tokens (no secret values in reports) | +| CI integrity | `.github/workflows/*.yml` — pinned actions, least privilege | +| Deploy headers (demos) | `blit-tech-demos/public/_headers`, `curl -I` on deployed URLs | +| Ownership / bus factor | `security-ownership-map` skill output (`summary.json`) | +| MCP governance | `pnpm security:mcp-preflight --governance-only` | + +## Repo-native commands + +### blit-tech + +```bash +cd /Users/vancura/Repos/_BLIT_TECH_/blit-tech + +pnpm security:mcp-preflight -- --mcps-dir "" --repo-root . --allow-fallback +pnpm security:audit +pnpm audit --prod --audit-level=moderate +pnpm audit --dev --audit-level=moderate +pnpm preflight +``` + +Key direct dependencies for supply-chain spot checks: `vite`, `typescript`, `eslint`, `vitest`, `happy-dom`. + +```bash +npm view vite version time.modified license +npm view typescript version time.modified license +``` + +### blit-tech-demos + +```bash +cd /Users/vancura/Repos/_BLIT_TECH_/blit-tech-demos + +pnpm security:mcp-preflight -- \ + --mcps-dir "" \ + --repo-root /Users/vancura/Repos/_BLIT_TECH_/blit-tech-demos \ + --allow-fallback + +pnpm security:audit +pnpm audit --prod --audit-level=moderate +pnpm audit --dev --audit-level=moderate +pnpm preflight +pnpm build +``` + +After toolchain or dependency upgrades, always run `pnpm build` as a smoke test. + +Demos can invoke the canonical preflight script from the library repo: + +```bash +node ../blit-tech/scripts/security/mcp-preflight.mjs --mcps-dir "" --repo-root . --allow-fallback +``` + +## Periodic governance (monthly) + +1. Run governance-only preflight for **both** repos (use each repo as `--repo-root`). +2. Review shadow MCP flags; migrate or remove unmanaged servers per organizational policy. +3. Re-authenticate critical MCPs (Opsera) if status is `auth_required`. +4. Store reports under `security-reports/` (gitignored). + +## Report template + +Use this structure in agent output or Linear comments: + +```md +## Security run report + +### MCP preflight + +- Opsera: +- JFrog: +- Semgrep: +- Fallbacks used: + +### blit-tech + +- security:audit: +- prod audit: +- preflight: + +### blit-tech-demos + +- security:audit: +- prod audit: +- preflight: +- build: + +### Governance + +- Shadow MCPs: +- Config paths scanned: +``` diff --git a/package.json b/package.json index 50c59b5..e76cb18 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,9 @@ "prepublishOnly": "pnpm run build", "prepare": "husky", "security:audit": "pnpm audit --audit-level=moderate", - "security:audit:fix": "pnpm audit --fix" + "security:audit:fix": "pnpm audit --fix", + "security:mcp-preflight": "node scripts/security/mcp-preflight.mjs", + "test:security-preflight": "node --test scripts/security/mcp-preflight.test.mjs" }, "packageManager": "pnpm@10.26.2", "pnpm": { diff --git a/scripts/security/fixtures/governance/runlayer.mcp.json b/scripts/security/fixtures/governance/runlayer.mcp.json new file mode 100644 index 0000000..a68aa5b --- /dev/null +++ b/scripts/security/fixtures/governance/runlayer.mcp.json @@ -0,0 +1,7 @@ +{ + "mcpServers": { + "managed": { + "url": "https://api.runlayer.com/mcp/example" + } + } +} diff --git a/scripts/security/fixtures/governance/shadow.mcp.json b/scripts/security/fixtures/governance/shadow.mcp.json new file mode 100644 index 0000000..3fadf31 --- /dev/null +++ b/scripts/security/fixtures/governance/shadow.mcp.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "shadow-remote": { + "url": "https://example.com/mcp" + }, + "shadow-stdio": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-example"] + } + } +} diff --git a/scripts/security/fixtures/mcps/auth-server/STATUS.md b/scripts/security/fixtures/mcps/auth-server/STATUS.md new file mode 100644 index 0000000..7eb30cc --- /dev/null +++ b/scripts/security/fixtures/mcps/auth-server/STATUS.md @@ -0,0 +1 @@ +The MCP server needs authentication. You must call the `mcp_auth` tool. diff --git a/scripts/security/fixtures/mcps/auth-server/tools/mcp_auth.json b/scripts/security/fixtures/mcps/auth-server/tools/mcp_auth.json new file mode 100644 index 0000000..2576878 --- /dev/null +++ b/scripts/security/fixtures/mcps/auth-server/tools/mcp_auth.json @@ -0,0 +1 @@ +{ "name": "mcp_auth", "description": "fixture" } diff --git a/scripts/security/fixtures/mcps/errored-server/STATUS.md b/scripts/security/fixtures/mcps/errored-server/STATUS.md new file mode 100644 index 0000000..bec2ce6 --- /dev/null +++ b/scripts/security/fixtures/mcps/errored-server/STATUS.md @@ -0,0 +1 @@ +The MCP server errored and is unavailable. diff --git a/scripts/security/fixtures/mcps/healthy-server/STATUS.md b/scripts/security/fixtures/mcps/healthy-server/STATUS.md new file mode 100644 index 0000000..0cbe6c2 --- /dev/null +++ b/scripts/security/fixtures/mcps/healthy-server/STATUS.md @@ -0,0 +1 @@ +Ready. diff --git a/scripts/security/fixtures/mcps/healthy-server/tools/scan.json b/scripts/security/fixtures/mcps/healthy-server/tools/scan.json new file mode 100644 index 0000000..381f942 --- /dev/null +++ b/scripts/security/fixtures/mcps/healthy-server/tools/scan.json @@ -0,0 +1 @@ +{ "name": "scan", "description": "fixture" } diff --git a/scripts/security/mcp-preflight.mjs b/scripts/security/mcp-preflight.mjs new file mode 100644 index 0000000..c648eec --- /dev/null +++ b/scripts/security/mcp-preflight.mjs @@ -0,0 +1,451 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { parseArgs as nodeParseArgs } from 'node:util'; +import { fileURLToPath } from 'node:url'; + +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); +const DEFAULT_REPO_ROOT = path.resolve(SCRIPT_DIR, '../..'); + +/** @typedef {'critical' | 'recommended'} McpTier */ +/** @typedef {'healthy' | 'auth_required' | 'errored' | 'absent'} McpStatus */ + +/** + * Security MCP servers checked before agent security runs. + * @type {{ id: string, tier: McpTier, fallbacks: string[] }[]} + */ +export const SECURITY_MCP_REGISTRY = [ + { + id: 'plugin-opsera-devsecops-opsera', + tier: 'critical', + fallbacks: [ + 'pnpm security:audit', + 'pnpm audit --prod --audit-level=moderate', + 'pnpm lint', + 'docs/security/security-runbook.md (SAST/compliance fallbacks)', + ], + }, + { + id: 'plugin-jfrog-jfrog', + tier: 'recommended', + fallbacks: ['pnpm security:audit', 'pnpm outdated --format json', 'npm view version'], + }, + { + id: 'plugin-semgrep-plugin-semgrep', + tier: 'recommended', + fallbacks: ['pnpm lint', 'rg security patterns (see runbook)', 'semgrep CLI only if already installed'], + }, +]; + +const MCP_CONFIG_FILENAMES = ['.mcp.json', 'mcp.json', '.cursor/mcp.json']; +const RUNLAYER_URL_PATTERN = /runlayer\.com/i; +const RUNLAYER_COMMAND_PATTERN = /runlayer\s+run\b/i; + +/** + * @param {string} statusText + * @returns {boolean} + */ +export function statusIndicatesAuthRequired(statusText) { + const lower = statusText.toLowerCase(); + return ( + lower.includes('authentication') || + lower.includes('mcp_auth') || + lower.includes('needs authentication') || + lower.includes('must call the `mcp_auth`') + ); +} + +/** + * @param {string} statusText + * @returns {boolean} + */ +export function statusIndicatesError(statusText) { + const lower = statusText.toLowerCase(); + return lower.includes('errored') || lower.includes('unavailable') || lower.includes('error'); +} + +/** + * Classify one MCP server directory under the Cursor project mcps folder. + * + * @param {string} mcpsDir + * @param {string} serverId + * @returns {{ status: McpStatus, toolCount: number, statusMessage: string | null }} + */ +export function classifyMcpServer(mcpsDir, serverId) { + const serverDir = path.join(mcpsDir, serverId); + + if (!fs.existsSync(serverDir)) { + return { status: 'absent', toolCount: 0, statusMessage: null }; + } + + const statusPath = path.join(serverDir, 'STATUS.md'); + let statusMessage = null; + + if (fs.existsSync(statusPath)) { + statusMessage = fs.readFileSync(statusPath, 'utf8').trim(); + if (statusIndicatesError(statusMessage)) { + return { status: 'errored', toolCount: 0, statusMessage }; + } + if (statusIndicatesAuthRequired(statusMessage)) { + return { status: 'auth_required', toolCount: 0, statusMessage }; + } + } + + const toolsDir = path.join(serverDir, 'tools'); + let toolCount = 0; + + if (fs.existsSync(toolsDir)) { + toolCount = fs + .readdirSync(toolsDir) + .filter((name) => name.endsWith('.json') && name !== 'mcp_auth.json').length; + } + + if (toolCount === 0) { + const authOnly = + fs.existsSync(path.join(toolsDir, 'mcp_auth.json')) || + (statusMessage !== null && statusIndicatesAuthRequired(statusMessage)); + if (authOnly) { + return { status: 'auth_required', toolCount: 0, statusMessage }; + } + if (statusMessage !== null && statusIndicatesError(statusMessage)) { + return { status: 'errored', toolCount: 0, statusMessage }; + } + return { status: 'absent', toolCount: 0, statusMessage }; + } + + return { status: 'healthy', toolCount, statusMessage }; +} + +/** + * @param {unknown} entry + * @returns {boolean} + */ +export function isRunlayerManagedEntry(entry) { + if (entry === null || typeof entry !== 'object') { + return false; + } + + const record = /** @type {Record} */ (entry); + const url = typeof record.url === 'string' ? record.url : ''; + const command = typeof record.command === 'string' ? record.command : ''; + const args = Array.isArray(record.args) ? record.args.join(' ') : ''; + + if (RUNLAYER_URL_PATTERN.test(url)) { + return true; + } + + const commandLine = `${command} ${args}`.trim(); + return RUNLAYER_COMMAND_PATTERN.test(commandLine); +} + +/** + * @param {string} configPath + * @returns {{ path: string, servers: { name: string, classification: 'runlayer-managed' | 'shadow-remote' | 'shadow-stdio' | 'unknown' }[] }} + */ +export function scanMcpConfigFile(configPath) { + const raw = fs.readFileSync(configPath, 'utf8'); + const parsed = JSON.parse(raw); + const serversObject = parsed.mcpServers; + + if (serversObject === null || typeof serversObject !== 'object') { + return { path: configPath, servers: [] }; + } + + /** @type {{ name: string, classification: 'runlayer-managed' | 'shadow-remote' | 'shadow-stdio' | 'unknown' }[]} */ + const servers = []; + + for (const [name, entry] of Object.entries(serversObject)) { + if (isRunlayerManagedEntry(entry)) { + servers.push({ name, classification: 'runlayer-managed' }); + continue; + } + + const record = /** @type {Record} */ (entry ?? {}); + const url = typeof record.url === 'string' ? record.url : ''; + const command = typeof record.command === 'string' ? record.command : ''; + + if (url.length > 0) { + servers.push({ name, classification: 'shadow-remote' }); + } else if (command.length > 0) { + servers.push({ name, classification: 'shadow-stdio' }); + } else { + servers.push({ name, classification: 'unknown' }); + } + } + + return { path: configPath, servers }; +} + +/** + * @param {string} repoRoot + * @param {{ includeUserConfig?: boolean }} [options] + * @returns {{ path: string, servers: { name: string, classification: string }[] }[]} + */ +export function discoverMcpConfigPaths(repoRoot, options = {}) { + /** @type {string[]} */ + const candidates = []; + + for (const name of MCP_CONFIG_FILENAMES) { + candidates.push(path.join(repoRoot, name)); + } + + const parentRoot = path.resolve(repoRoot, '..'); + for (const name of MCP_CONFIG_FILENAMES) { + candidates.push(path.join(parentRoot, name)); + } + + if (options.includeUserConfig) { + candidates.push(path.join(process.env.HOME ?? '', '.cursor', 'mcp.json')); + } + + const existing = [...new Set(candidates)].filter((candidate) => fs.existsSync(candidate)); + return existing.map((configPath) => scanMcpConfigFile(configPath)); +} + +/** + * @param {string} mcpsDir + * @returns {import('./mcp-preflight.mjs').McpPreflightReport['mcpServers']} + */ +export function buildMcpServerReport(mcpsDir) { + return SECURITY_MCP_REGISTRY.map((entry) => { + const classification = classifyMcpServer(mcpsDir, entry.id); + return { + id: entry.id, + tier: entry.tier, + status: classification.status, + toolCount: classification.toolCount, + statusMessage: classification.statusMessage, + fallbacks: [...entry.fallbacks], + usable: classification.status === 'healthy', + }; + }); +} + +/** + * @param {ReturnType} mcpServers + * @returns {boolean} + */ +export function criticalMcpUsable(mcpServers) { + return mcpServers.filter((server) => server.tier === 'critical').every((server) => server.usable); +} + +/** + * @param {ReturnType} mcpServers + * @returns {string[]} + */ +export function collectRecommendedFallbacks(mcpServers) { + const fallbacks = new Set(); + for (const server of mcpServers) { + if (!server.usable) { + for (const fallback of server.fallbacks) { + fallbacks.add(fallback); + } + } + } + return [...fallbacks]; +} + +/** + * @param {{ + * mcpsDir: string, + * repoRoot?: string, + * includeUserConfig?: boolean, + * governanceOnly?: boolean, + * allowFallback?: boolean, + * outputJsonPath?: string, + * }} options + * @returns {McpPreflightReport} + */ +export function runMcpPreflight(options) { + const repoRoot = options.repoRoot ?? DEFAULT_REPO_ROOT; + const mcpServers = buildMcpServerReport(options.mcpsDir); + const governance = discoverMcpConfigPaths(repoRoot, { + includeUserConfig: options.includeUserConfig ?? false, + }); + + const shadowServers = governance.flatMap((config) => + config.servers + .filter((server) => server.classification.startsWith('shadow')) + .map((server) => ({ config: config.path, name: server.name, classification: server.classification })), + ); + + const criticalUsable = criticalMcpUsable(mcpServers); + const recommendedFallbacks = collectRecommendedFallbacks(mcpServers); + + /** @type {McpPreflightReport} */ + const report = { + generatedAt: new Date().toISOString(), + mcpsDir: options.mcpsDir, + repoRoot, + governanceOnly: options.governanceOnly ?? false, + mcpServers: options.governanceOnly ? [] : mcpServers, + governance: { + configs: governance.map((config) => ({ + path: config.path, + serverNames: config.servers.map((server) => server.name), + shadowServers: config.servers + .filter((server) => server.classification.startsWith('shadow')) + .map((server) => ({ name: server.name, classification: server.classification })), + })), + shadowCount: shadowServers.length, + shadowServers, + }, + summary: { + criticalUsable, + allowFallback: options.allowFallback ?? false, + recommendedFallbacks: options.governanceOnly ? [] : recommendedFallbacks, + proceed: options.governanceOnly ? true : criticalUsable || (options.allowFallback ?? false), + }, + }; + + if (options.outputJsonPath) { + fs.mkdirSync(path.dirname(options.outputJsonPath), { recursive: true }); + fs.writeFileSync(options.outputJsonPath, `${JSON.stringify(report, null, 2)}\n`); + } + + return report; +} + +/** + * @param {McpPreflightReport} report + * @returns {string} + */ +export function formatPreflightSummary(report) { + const lines = ['MCP security preflight', `Generated: ${report.generatedAt}`, `mcps-dir: ${report.mcpsDir}`]; + + if (report.governanceOnly) { + lines.push('', 'Governance-only mode'); + } else { + lines.push('', 'Security MCP servers:'); + for (const server of report.mcpServers) { + lines.push( + ` - ${server.id} [${server.tier}] status=${server.status} tools=${server.toolCount} usable=${server.usable}`, + ); + } + if (report.summary.recommendedFallbacks.length > 0) { + lines.push('', 'Recommended fallbacks:'); + for (const fallback of report.summary.recommendedFallbacks) { + lines.push(` - ${fallback}`); + } + } + } + + lines.push('', 'MCP config governance:'); + for (const config of report.governance.configs) { + const names = config.serverNames.length > 0 ? config.serverNames.join(', ') : '(none)'; + lines.push(` - ${config.path}: ${names}`); + } + if (report.governance.shadowCount > 0) { + lines.push('', 'Shadow MCP servers flagged:'); + for (const shadow of report.governance.shadowServers) { + lines.push(` - ${shadow.name} (${shadow.classification}) in ${shadow.config}`); + } + } else { + lines.push('', 'No shadow MCP servers flagged in scanned configs.'); + } + + lines.push('', `Proceed: ${report.summary.proceed}`); + return lines.join('\n'); +} + +/** + * @typedef {Object} McpPreflightReport + * @property {string} generatedAt + * @property {string} mcpsDir + * @property {string} repoRoot + * @property {boolean} governanceOnly + * @property {Array<{ + * id: string, + * tier: McpTier, + * status: McpStatus, + * toolCount: number, + * statusMessage: string | null, + * fallbacks: string[], + * usable: boolean, + * }>} mcpServers + * @property {{ + * configs: Array<{ path: string, serverNames: string[], shadowServers: Array<{ name: string, classification: string }> }>, + * shadowCount: number, + * shadowServers: Array<{ config: string, name: string, classification: string }>, + * }} governance + * @property {{ + * criticalUsable: boolean, + * allowFallback: boolean, + * recommendedFallbacks: string[], + * proceed: boolean, + * }} summary + */ + +/** + * @param {string[]} argv + * @returns {{ + * mcpsDir: string | null, + * repoRoot: string, + * includeUserConfig: boolean, + * governanceOnly: boolean, + * allowFallback: boolean, + * outputJsonPath: string | null, + * }} + */ +export function parseArgs(argv) { + const { values } = nodeParseArgs({ + args: argv, + options: { + 'allow-fallback': { type: 'boolean' }, + 'governance-only': { type: 'boolean' }, + 'include-user-config': { type: 'boolean' }, + 'mcps-dir': { type: 'string' }, + 'output-json': { type: 'string' }, + 'repo-root': { type: 'string' }, + }, + strict: false, + }); + + const outputJson = values['output-json']; + + return { + mcpsDir: values['mcps-dir'] ?? null, + repoRoot: path.resolve(values['repo-root'] ?? DEFAULT_REPO_ROOT), + includeUserConfig: values['include-user-config'] ?? false, + governanceOnly: values['governance-only'] ?? false, + allowFallback: values['allow-fallback'] ?? false, + outputJsonPath: typeof outputJson === 'string' && outputJson.length > 0 ? path.resolve(outputJson) : null, + }; +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + + if (!args.mcpsDir) { + console.error( + 'Usage: node scripts/security/mcp-preflight.mjs --mcps-dir [--repo-root ] [--include-user-config] [--governance-only] [--allow-fallback] [--output-json ]', + ); + process.exit(1); + } + + const mcpsDir = path.resolve(args.mcpsDir); + if (!fs.existsSync(mcpsDir)) { + console.error(`mcps-dir does not exist: ${mcpsDir}`); + process.exit(1); + } + + const report = runMcpPreflight({ + mcpsDir, + repoRoot: args.repoRoot, + includeUserConfig: args.includeUserConfig, + governanceOnly: args.governanceOnly, + allowFallback: args.allowFallback, + outputJsonPath: args.outputJsonPath ?? undefined, + }); + + console.log(formatPreflightSummary(report)); + console.log(''); + console.log(JSON.stringify(report, null, 2)); + + if (!report.summary.proceed) { + process.exit(1); + } +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) { + main(); +} diff --git a/scripts/security/mcp-preflight.test.mjs b/scripts/security/mcp-preflight.test.mjs new file mode 100644 index 0000000..aac024c --- /dev/null +++ b/scripts/security/mcp-preflight.test.mjs @@ -0,0 +1,167 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { describe, it } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import { + buildMcpServerReport, + classifyMcpServer, + collectRecommendedFallbacks, + criticalMcpUsable, + discoverMcpConfigPaths, + isRunlayerManagedEntry, + parseArgs, + runMcpPreflight, + scanMcpConfigFile, + statusIndicatesAuthRequired, + statusIndicatesError, +} from './mcp-preflight.mjs'; + +const FIXTURE_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), 'fixtures'); +const MCPS_FIXTURE = path.join(FIXTURE_ROOT, 'mcps'); + +describe('mcp-preflight status helpers', () => { + it('detects auth-required status text', () => { + assert.equal(statusIndicatesAuthRequired('You must call the `mcp_auth` tool for authentication.'), true); + }); + + it('detects errored status text', () => { + assert.equal(statusIndicatesError('The MCP server errored and is unavailable.'), true); + }); +}); + +describe('classifyMcpServer', () => { + it('classifies healthy when non-auth tools exist', () => { + const result = classifyMcpServer(MCPS_FIXTURE, 'healthy-server'); + assert.equal(result.status, 'healthy'); + assert.equal(result.toolCount, 1); + }); + + it('classifies auth_required from STATUS.md', () => { + const result = classifyMcpServer(MCPS_FIXTURE, 'auth-server'); + assert.equal(result.status, 'auth_required'); + }); + + it('classifies errored from STATUS.md', () => { + const result = classifyMcpServer(MCPS_FIXTURE, 'errored-server'); + assert.equal(result.status, 'errored'); + }); + + it('classifies absent when server directory is missing', () => { + const result = classifyMcpServer(MCPS_FIXTURE, 'missing-server'); + assert.equal(result.status, 'absent'); + }); +}); + +describe('governance classification', () => { + it('flags Runlayer-managed entries', () => { + const config = scanMcpConfigFile(path.join(FIXTURE_ROOT, 'governance', 'runlayer.mcp.json')); + assert.equal(config.servers[0]?.classification, 'runlayer-managed'); + }); + + it('flags shadow remote and stdio entries', () => { + const config = scanMcpConfigFile(path.join(FIXTURE_ROOT, 'governance', 'shadow.mcp.json')); + const byName = Object.fromEntries(config.servers.map((server) => [server.name, server.classification])); + assert.equal(byName['shadow-remote'], 'shadow-remote'); + assert.equal(byName['shadow-stdio'], 'shadow-stdio'); + }); + + it('detects runlayer command entries', () => { + assert.equal( + isRunlayerManagedEntry({ command: 'runlayer', args: ['run', '00000000-0000-0000-0000-000000000000'] }), + true, + ); + }); +}); + +describe('runMcpPreflight', () => { + it('collects fallbacks when critical MCP is absent and allowFallback is set', () => { + const report = runMcpPreflight({ + mcpsDir: MCPS_FIXTURE, + repoRoot: FIXTURE_ROOT, + allowFallback: true, + }); + + assert.equal(report.summary.proceed, true); + assert.equal(report.summary.criticalUsable, false); + assert.ok(report.summary.recommendedFallbacks.length > 0); + }); + + it('fails proceed when critical MCP is absent and allowFallback is false', () => { + const report = runMcpPreflight({ + mcpsDir: MCPS_FIXTURE, + repoRoot: FIXTURE_ROOT, + allowFallback: false, + }); + + assert.equal(report.summary.proceed, false); + }); + + it('supports governance-only mode without MCP server checks', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mcp-governance-')); + const configPath = path.join(tempDir, '.mcp.json'); + fs.copyFileSync(path.join(FIXTURE_ROOT, 'governance', 'shadow.mcp.json'), configPath); + + const report = runMcpPreflight({ + mcpsDir: MCPS_FIXTURE, + repoRoot: tempDir, + governanceOnly: true, + }); + + assert.equal(report.governanceOnly, true); + assert.equal(report.mcpServers.length, 0); + assert.equal(report.summary.proceed, true); + assert.ok(report.governance.shadowCount >= 2); + }); +}); + +describe('registry helpers', () => { + it('builds report entries for all registered security MCP servers', () => { + const report = buildMcpServerReport(MCPS_FIXTURE); + assert.equal(report.length, 3); + assert.ok(report.some((server) => server.tier === 'critical')); + }); + + it('aggregates recommended fallbacks for unusable servers', () => { + const report = buildMcpServerReport(MCPS_FIXTURE); + const fallbacks = collectRecommendedFallbacks(report); + assert.ok(fallbacks.length > 0); + assert.equal(criticalMcpUsable(report), false); + }); +}); + +describe('parseArgs', () => { + it('requires mcps-dir to be provided explicitly', () => { + const args = parseArgs(['--allow-fallback']); + assert.equal(args.mcpsDir, null); + assert.equal(args.allowFallback, true); + }); + + it('parses governance and output flags', () => { + const args = parseArgs([ + '--mcps-dir', + '/tmp/mcps', + '--governance-only', + '--include-user-config', + '--output-json', + '/tmp/report.json', + ]); + assert.equal(args.mcpsDir, '/tmp/mcps'); + assert.equal(args.governanceOnly, true); + assert.equal(args.includeUserConfig, true); + assert.equal(args.outputJsonPath, '/tmp/report.json'); + }); +}); + +describe('discoverMcpConfigPaths', () => { + it('finds repo-local MCP config files', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mcp-repo-')); + fs.copyFileSync(path.join(FIXTURE_ROOT, 'governance', 'runlayer.mcp.json'), path.join(tempDir, '.mcp.json')); + + const configs = discoverMcpConfigPaths(tempDir); + assert.equal(configs.length, 1); + assert.equal(configs[0]?.servers[0]?.name, 'managed'); + }); +});