diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 000000000..0836562bf --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,29 @@ +{ + "name": "clawdstrike", + "owner": { + "name": "Backbay Labs", + "email": "hello@backbay.io" + }, + "metadata": { + "description": "ClawdStrike security plugins for Claude Code — runtime policy enforcement, threat hunting, and audit tools.", + "version": "0.1.0" + }, + "plugins": [ + { + "name": "clawdstrike", + "source": "./clawdstrike-plugin", + "description": "Runtime security enforcement for AI coding agents — policy hooks, audit receipts, threat hunting, and 15 MCP security tools.", + "version": "0.1.0", + "author": { + "name": "Backbay Labs", + "email": "hello@backbay.io" + }, + "homepage": "https://github.com/backbay-labs/clawdstrike", + "repository": "https://github.com/backbay-labs/clawdstrike", + "license": "Apache-2.0", + "keywords": ["security", "policy", "audit", "edr", "agent-security", "mcp", "threat-hunting"], + "category": "security", + "tags": ["security", "policy-enforcement", "audit-trail", "threat-hunting", "mcp-tools"] + } + ] +} diff --git a/README.md b/README.md index cd97627c2..9cc8236f2 100644 --- a/README.md +++ b/README.md @@ -205,6 +205,24 @@ openclaw plugins enable clawdstrike-security [Configure the plugin](docs/src/guides/openclaw-integration.md#configuration) in your project's `openclaw.json`. +### Claude Code Plugin + +Clawdstrike ships as a native [Claude Code](https://docs.anthropic.com/en/docs/claude-code) plugin. Every tool call Claude makes is checked against your security policy before execution, with a full audit trail of signed receipts. + +```shell +# From inside Claude Code: +/plugin marketplace add backbay-labs/clawdstrike +/plugin install clawdstrike@clawdstrike +``` + +Or from a local clone: + +```bash +claude --plugin-dir ./clawdstrike-plugin +``` + +The plugin adds 6 hooks (pre-tool, post-tool, session lifecycle, prompt injection screening), 15 MCP tools, 3 auto-triggering skills, 6 slash commands, and a specialist security reviewer agent. See [`clawdstrike-plugin/README.md`](clawdstrike-plugin/README.md) for the full reference. + ### Additional SDKs & Bindings Framework adapters: [OpenAI](packages/adapters/clawdstrike-openai/README.md) · [Claude](packages/adapters/clawdstrike-claude/README.md) · [Vercel AI](docs/src/guides/vercel-ai-integration.md) · [LangChain](docs/src/guides/langchain-integration.md) diff --git a/apps/README.md b/apps/README.md index 362cad926..483971911 100644 --- a/apps/README.md +++ b/apps/README.md @@ -7,6 +7,7 @@ Current apps: 1. `apps/desktop/` - Tauri desktop app. 2. `apps/agent/` - Tauri agent app. 3. `apps/cloud-dashboard/` - web dashboard app. +4. `apps/terminal/` - ClawdStrike TUI (security-aware AI agent orchestration) Ownership and maturity: diff --git a/apps/terminal/.gitignore b/apps/terminal/.gitignore new file mode 100644 index 000000000..9b1ee42e8 --- /dev/null +++ b/apps/terminal/.gitignore @@ -0,0 +1,175 @@ +# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore + +# Logs + +logs +_.log +npm-debug.log_ +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Caches + +.cache + +# Diagnostic reports (https://nodejs.org/api/report.html) + +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# Runtime data + +pids +_.pid +_.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover + +lib-cov + +# Coverage directory used by tools like istanbul + +coverage +*.lcov + +# nyc test coverage + +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) + +.grunt + +# Bower dependency directory (https://bower.io/) + +bower_components + +# node-waf configuration + +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) + +build/Release + +# Dependency directories + +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) + +web_modules/ + +# TypeScript cache + +*.tsbuildinfo + +# Optional npm cache directory + +.npm + +# Optional eslint cache + +.eslintcache + +# Optional stylelint cache + +.stylelintcache + +# Microbundle cache + +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history + +.node_repl_history + +# Output of 'npm pack' + +*.tgz + +# Yarn Integrity file + +.yarn-integrity + +# dotenv environment variable files + +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) + +.parcel-cache + +# Next.js build output + +.next +out + +# Nuxt.js build / generate output + +.nuxt +dist + +# Gatsby files + +# Comment in the public line in if your project uses Gatsby and not Next.js + +# https://nextjs.org/blog/next-9-1#public-directory-support + +# public + +# vuepress build output + +.vuepress/dist + +# vuepress v2.x temp and cache directory + +.temp + +# Docusaurus cache and generated files + +.docusaurus + +# Serverless directories + +.serverless/ + +# FuseBox cache + +.fusebox/ + +# DynamoDB Local files + +.dynamodb/ + +# TernJS port file + +.tern-port + +# Stores VSCode versions used for testing VSCode extensions + +.vscode-test + +# yarn v2 + +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/apps/terminal/README.md b/apps/terminal/README.md new file mode 100644 index 000000000..b33c9a541 --- /dev/null +++ b/apps/terminal/README.md @@ -0,0 +1,320 @@ +# ClawdStrike TUI + +**Security-aware orchestration engine for AI coding agents** + +ClawdStrike TUI dispatches coding tasks to native AI CLIs (Codex, Claude Code, OpenCode) with intelligent routing, parallel execution with voting, and quality gates — all with ambient runtime security enforcement via [hushd](../../crates/services/hushd/). + +## Features + +- **Intelligent Routing** - Route tasks based on risk, size, labels, and prompt patterns +- **Speculate+Vote** - Run multiple agents in parallel, select best result +- **Quality Gates** - pytest, mypy, ruff, and ClawdStrike policy checks +- **Workcell Isolation** - Git worktree sandboxes for safe concurrent execution +- **Security Integration** - Live hushd connection with SSE event streaming, audit log, and policy viewer +- **Telemetry** - Execution tracking with rollout persistence +- **Interactive TUI** - Full-screen gothic terminal dashboard with security indicators + +## Interactive TUI + +Running `clawdstrike` without arguments launches an interactive terminal UI: + +``` + ██████╗██╗ █████╗ ██╗ ██╗██████╗ +██╔════╝██║ ██╔══██╗██║ ██║██╔══██╗ +██║ ██║ ███████║██║ █╗ ██║██║ ██║ +██║ ██║ ██╔══██║██║███╗██║██║ ██║ +╚██████╗███████╗██║ ██║╚███╔███╔╝██████╔╝ + ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝ ╚═════╝ + ███████╗████████╗██████╗ ██╗██╗ ██╗███████╗ + ██╔════╝╚══██╔══╝██╔══██╗██║██║ ██╔╝██╔════╝ + ███████╗ ██║ ██████╔╝██║█████╔╝ █████╗ + ╚════██║ ██║ ██╔══██╗██║██╔═██╗ ██╔══╝ + ███████║ ██║ ██║ ██║██║██║ ██╗███████╗ + ╚══════╝ ╚═╝ ╚═╝ ╚═╝╚═╝╚═╝ ╚═╝╚══════╝ +``` + +**Keyboard Shortcuts:** + +| Key | Action | +|-----|--------| +| `d` | Quick dispatch | +| `s` | Quick speculate | +| `g` | Run quality gates | +| `b` | View work graph (beads) | +| `r` | View active rollouts | +| `Ctrl+S` | Security overview | +| `a` | Audit log | +| `p` | Policy viewer | +| `h` | Help | +| `q` | Quit | +| `↑/↓` or `j/k` | Navigate / scroll | +| `Enter` | Select item | + +## Security Integration + +ClawdStrike TUI connects to a running [hushd](../../crates/services/hushd/) daemon for ambient security enforcement: + +- **Status bar indicator** — `◆sec` turns green when hushd is connected, dim when unavailable +- **Live event ticker** — Latest security decisions stream on the main screen via SSE +- **Security overview** (`Ctrl+S`) — Real-time event table and audit statistics +- **Audit log** (`a`) — Paginated table of all policy decisions with filtering +- **Policy viewer** (`p`) — Active policy name, version, hash, and guard list +- **Pre-dispatch check** — Optionally validates prompts against hushd policy before sending to agents (fail-open) +- **ClawdStrike quality gate** — Posts agent diffs to hushd for patch integrity and secret leak scanning + +All security features degrade gracefully when hushd is not running. + +## Installation + +```bash +cd apps/terminal +bun install +``` + +## CLI Usage + +```bash +# Run via bun +bun run cli + +# Or link globally +bun link +clawdstrike +``` + +### Commands + +```bash +clawdstrike # Launch interactive TUI +clawdstrike dispatch # Submit task for AI execution +clawdstrike speculate # Run with multiple agents +clawdstrike gate [gates...] # Run quality gates +clawdstrike beads list # List issues +clawdstrike beads ready # Get ready issues +clawdstrike beads create # Create issue +clawdstrike status # Show kernel status +clawdstrike init # Initialize in current directory +clawdstrike help # Show CLI help +``` + +### Options + +```bash +-t, --toolchain <name> # Force toolchain (codex, claude, opencode, crush) +-s, --strategy <name> # Vote strategy (first_pass, best_score, consensus) +-g, --gate <name> # Gates to run (can repeat) +--timeout <ms> # Execution timeout +-j, --json # JSON output +--no-color # Disable colors +--cwd <path> # Working directory +-p, --project <id> # Project identifier +``` + +### Examples + +```bash +# Simple dispatch +clawdstrike dispatch "Fix the null pointer in auth.ts" + +# Force Claude toolchain +clawdstrike dispatch -t claude "Add unit tests for utils.ts" + +# Speculate with best score voting +clawdstrike speculate -s best_score "Refactor the database module" + +# Run specific gates (including security) +clawdstrike gate pytest mypy clawdstrike + +# List open issues as JSON +clawdstrike beads list -j +``` + +## Programmatic Usage + +```typescript +import { + init, + shutdown, + Router, + Dispatcher, + Workcell, + Verifier, + Speculate, + Beads, + Telemetry, + Hushd, + tools, + executeTool, +} from "@clawdstrike/tui" + +// Initialize (also starts hushd client) +await init({ + beadsPath: ".beads", + telemetryDir: ".clawdstrike/runs", +}) + +// Route a task +const routing = await Router.route({ + prompt: "Fix the bug in auth.ts", + context: { cwd: process.cwd(), projectId: "my-project" }, +}) + +// Execute via tool +const result = await executeTool("dispatch", { + prompt: "Fix the bug", + toolchain: "claude", +}) + +// Check hushd connectivity +const client = Hushd.getClient() +const connected = await client.probe() + +// Cleanup +await shutdown() +``` + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ CLI / Tools │ +│ dispatch, speculate, gate commands │ +└────────────────────────────┬────────────────────────────────┘ + │ +┌────────────────────────────▼────────────────────────────────┐ +│ Router │ +│ Rule-based routing with priority, labels, patterns │ +└────────────────────────────┬────────────────────────────────┘ + │ + ┌───────────────────┤ + │ (optional) │ +┌────────▼────────┐ ┌──────▼──────────────────────────────┐ +│ hushd Policy │ │ Dispatcher │ +│ Pre-check │ │ Adapters: codex | claude | │ +│ (fail-open) │ │ opencode | crush │ +└─────────────────┘ └──────┬──────────────────────────────┘ + │ +┌────────────────────────────▼────────────────────────────────┐ +│ Workcell Pool │ +│ Git worktree isolation with lifecycle management │ +└────────────────────────────┬────────────────────────────────┘ + │ +┌────────────────────────────▼────────────────────────────────┐ +│ Verifier │ +│ Gates: pytest, mypy, ruff, clawdstrike │ +└────────────────────────────┬────────────────────────────────┘ + │ +┌────────────────────────────▼────────────────────────────────┐ +│ hushd (optional) │ +│ Patch integrity + secret leak scanning via HTTP API │ +└─────────────────────────────────────────────────────────────┘ +``` + +## Toolchains + +| Toolchain | CLI | Best For | +|-----------|-----|----------| +| `codex` | OpenAI Codex CLI | Complex reasoning, architecture | +| `claude` | Claude Code | General purpose, fast | +| `opencode` | OpenCode | Local execution, no network | +| `crush` | Multi-provider | Fallback with retries | + +## Vote Strategies + +When using `speculate`, multiple agents run in parallel and results are voted on: + +| Strategy | Description | +|----------|-------------| +| `first_pass` | First result passing all gates wins (fastest) | +| `best_score` | Highest gate score wins (best quality) | +| `consensus` | Most similar patch wins (most deterministic) | + +## Quality Gates + +| Gate | Critical | Description | +|------|----------|-------------| +| `pytest` | Yes | Run Python tests | +| `mypy` | Yes | Type check Python | +| `ruff` | No | Lint and format Python | +| `clawdstrike` | No | Policy check via hushd (patch integrity, secret leak) | + +## Module Structure + +``` +src/ +├── cli/ # Command-line interface +├── router/ # Task routing rules +├── dispatcher/ # Toolchain adapters +│ └── adapters/ # codex, claude, opencode, crush +├── workcell/ # Git worktree management +├── verifier/ # Quality gates +│ └── gates/ # pytest, mypy, ruff, clawdstrike +├── speculate/ # Parallel execution + voting +├── beads/ # Work graph (JSONL) +├── hushd/ # Security daemon client +│ ├── types.ts # hushd API types +│ ├── client.ts # HTTP + SSE client +│ └── index.ts # Namespace entry point +├── telemetry/ # Execution tracking +├── health/ # Integration health checks +├── tui/ # Terminal UI and formatting +│ ├── index.ts # TUI formatting utilities +│ └── app.ts # Interactive TUI application +├── mcp/ # MCP server (JSON-RPC) +├── tools/ # MCP tool definitions +├── patch/ # Patch lifecycle +├── types.ts # Zod schemas +└── index.ts # Main exports +``` + +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `CLAWDSTRIKE_HUSHD_URL` | `http://127.0.0.1:8080` | hushd daemon URL | +| `CLAWDSTRIKE_SANDBOX` | - | Sandbox mode for codex adapter | +| `NO_COLOR` | - | Disable color output | + +## Development + +```bash +# Run tests +bun test + +# Type check +bun run typecheck + +# Run CLI in dev mode +bun run cli help + +# Launch TUI +bun run cli +``` + +## Testing + +335 tests covering: +- Type validation and Zod schemas +- Router rules and routing decisions +- Dispatcher adapters (codex, claude, opencode, crush) +- Workcell pool management and git operations +- Verifier gates and scoring (including clawdstrike gate) +- Speculate voting strategies +- Beads JSONL operations +- Telemetry tracking +- hushd client (mocked fetch) +- Health check integrations +- TUI formatting +- MCP server protocol +- CLI argument parsing and integration + +```bash +bun test # All tests +bun test test/router # Router tests only +bun test -t "hushd" # hushd client tests +bun test -t "speculate" # Tests matching pattern +``` + +## License + +MIT diff --git a/apps/terminal/bun.lockb b/apps/terminal/bun.lockb new file mode 100755 index 000000000..9127b4189 Binary files /dev/null and b/apps/terminal/bun.lockb differ diff --git a/apps/terminal/package.json b/apps/terminal/package.json new file mode 100644 index 000000000..c5624bb29 --- /dev/null +++ b/apps/terminal/package.json @@ -0,0 +1,33 @@ +{ + "name": "@clawdstrike/tui", + "version": "0.1.0", + "description": "Security-aware orchestration engine for AI coding agents", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "bin": { + "clawdstrike": "./src/cli/index.ts" + }, + "exports": { + ".": "./src/index.ts", + "./types": "./src/types.ts", + "./cli": "./src/cli/index.ts" + }, + "scripts": { + "dev": "bun run --watch src/index.ts", + "build": "bun build ./src/index.ts --outdir ./dist", + "cli": "bun run ./src/cli/index.ts", + "test": "bun test", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/bun": "latest", + "typescript": "^5.7.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + } +} diff --git a/apps/terminal/src/beads/index.ts b/apps/terminal/src/beads/index.ts new file mode 100644 index 000000000..0a7ffad00 --- /dev/null +++ b/apps/terminal/src/beads/index.ts @@ -0,0 +1,269 @@ +/** + * Beads - Work graph integration + * + * Manages integration with the Beads work graph (issues.jsonl). + * Handles issue queries, status updates, and dependency tracking. + */ + +import type { Bead, BeadId, BeadStatus, BeadPriority } from "../types" +import { JSONL } from "./jsonl" + +export { JSONL } from "./jsonl" + +export interface BeadsConfig { + path: string // Path to .beads directory + autoSync?: boolean +} + +export interface QueryOptions { + status?: BeadStatus | BeadStatus[] + priority?: BeadPriority | BeadPriority[] + labels?: string[] + assignee?: string + limit?: number + offset?: number +} + +export interface ReadyIssue extends Bead { + reasoning: string + suggestedToolchain?: string +} + +// Module state +let config: BeadsConfig | null = null + +/** + * Beads namespace - Work graph operations + */ +export namespace Beads { + /** + * Initialize Beads connection + */ + export async function init(cfg: BeadsConfig): Promise<void> { + config = cfg + await JSONL.init(cfg.path) + } + + /** + * Get current config (throws if not initialized) + */ + function getConfig(): BeadsConfig { + if (!config) { + throw new Error("Beads not initialized. Call Beads.init() first.") + } + return config + } + + /** + * Query issues from work graph + */ + export async function query(options?: QueryOptions): Promise<Bead[]> { + const cfg = getConfig() + let issues = await JSONL.read(cfg.path) + + // Apply filters + if (options?.status) { + const statuses = Array.isArray(options.status) + ? options.status + : [options.status] + issues = issues.filter((i) => statuses.includes(i.status)) + } + + if (options?.priority) { + const priorities = Array.isArray(options.priority) + ? options.priority + : [options.priority] + issues = issues.filter((i) => i.priority && priorities.includes(i.priority)) + } + + if (options?.labels && options.labels.length > 0) { + issues = issues.filter((i) => + options.labels!.some((label) => i.labels?.includes(label)) + ) + } + + if (options?.assignee) { + issues = issues.filter((i) => i.assignee === options.assignee) + } + + // Apply pagination + if (options?.offset) { + issues = issues.slice(options.offset) + } + + if (options?.limit) { + issues = issues.slice(0, options.limit) + } + + return issues + } + + /** + * Get issues ready for execution (status: "open", no blocking deps) + */ + export async function getReady(): Promise<ReadyIssue[]> { + const cfg = getConfig() + const issues = await JSONL.read(cfg.path) + + // Get all open issues + const openIssues = issues.filter((i) => i.status === "open") + + // For now, all open issues are considered ready + // In a full implementation, we'd check dependencies in deps.jsonl + return openIssues.map((issue) => ({ + ...issue, + reasoning: `Issue ${issue.id} is open and has no blocking dependencies`, + suggestedToolchain: inferToolchain(issue), + })) + } + + /** + * Get single issue by ID + */ + export async function get(id: BeadId): Promise<Bead | undefined> { + const cfg = getConfig() + const issues = await JSONL.read(cfg.path) + return issues.find((i) => i.id === id) + } + + /** + * Create new issue + */ + export async function create( + issue: Omit<Bead, "id" | "createdAt" | "updatedAt"> + ): Promise<Bead> { + const cfg = getConfig() + const issues = await JSONL.read(cfg.path) + + // Generate new ID (find max number and increment) + const prefix = extractPrefix(issues) + const maxNum = issues.reduce((max, i) => { + const num = parseInt(i.id.split("-")[1], 10) + return num > max ? num : max + }, 0) + + const newBead: Bead = { + ...issue, + id: `${prefix}-${maxNum + 1}` as BeadId, + createdAt: Date.now(), + updatedAt: Date.now(), + } + + await JSONL.append(cfg.path, newBead) + return newBead + } + + /** + * Update issue status + */ + export async function updateStatus( + id: BeadId, + status: BeadStatus + ): Promise<Bead> { + const cfg = getConfig() + + return JSONL.update(cfg.path, id, (issue) => ({ + ...issue, + status, + closedAt: status === "completed" || status === "cancelled" ? Date.now() : issue.closedAt, + })) + } + + /** + * Update issue + */ + export async function update( + id: BeadId, + updates: Partial<Bead> + ): Promise<Bead> { + const cfg = getConfig() + + return JSONL.update(cfg.path, id, (issue) => ({ + ...issue, + ...updates, + id: issue.id, // Prevent ID changes + createdAt: issue.createdAt, // Prevent createdAt changes + })) + } + + /** + * Get dependencies for an issue + * Note: In a full implementation, this would read from deps.jsonl + */ + export async function getDependencies(_id: BeadId): Promise<Bead[]> { + // STUB: Would read from .beads/deps.jsonl + return [] + } + + /** + * Get dependents (issues that depend on this one) + * Note: In a full implementation, this would read from deps.jsonl + */ + export async function getDependents(_id: BeadId): Promise<Bead[]> { + // STUB: Would read from .beads/deps.jsonl + return [] + } + + /** + * Sync with external source (if configured) + * Note: This is a placeholder for GitHub/Linear sync + */ + export async function sync(): Promise<void> { + const cfg = getConfig() + if (cfg.autoSync) { + // STUB: Would sync with external provider + console.log("Beads sync not implemented") + } + } + + /** + * Check if Beads is initialized + */ + export function isInitialized(): boolean { + return config !== null + } + + /** + * Reset Beads state (mainly for testing) + */ + export function reset(): void { + config = null + } +} + +/** + * Extract project prefix from existing issues + */ +function extractPrefix(issues: Bead[]): string { + if (issues.length === 0) { + return "PROJ" + } + return issues[0].id.split("-")[0] +} + +/** + * Infer suggested toolchain from issue labels/content + */ +function inferToolchain(issue: Bead): string | undefined { + const labels = issue.labels || [] + + // Check for explicit hints + const hintLabel = labels.find((l) => l.startsWith("dk_tool_hint:")) + if (hintLabel) { + return hintLabel.split(":")[1] + } + + // Check for risk level + if (labels.includes("dk_risk:high")) { + return "codex" + } + + // Check for size + if (labels.includes("dk_size:xs") || labels.includes("dk_size:s")) { + return "opencode" + } + + return undefined +} + +export default Beads diff --git a/apps/terminal/src/beads/jsonl.ts b/apps/terminal/src/beads/jsonl.ts new file mode 100644 index 000000000..f8ef3eb6f --- /dev/null +++ b/apps/terminal/src/beads/jsonl.ts @@ -0,0 +1,139 @@ +/** + * JSONL - Line-delimited JSON storage for Beads + * + * Handles reading and writing the .beads/issues.jsonl file format. + */ + +import * as fs from "node:fs/promises" +import * as path from "node:path" +import type { Bead, BeadId } from "../types" + +const ISSUES_FILE = "issues.jsonl" + +/** + * JSONL namespace - File operations for beads storage + */ +export namespace JSONL { + /** + * Get the full path to issues.jsonl + */ + export function getIssuesPath(beadsDir: string): string { + return path.join(beadsDir, ISSUES_FILE) + } + + /** + * Read all issues from JSONL file + */ + export async function read(beadsDir: string): Promise<Bead[]> { + const filePath = getIssuesPath(beadsDir) + + try { + const content = await Bun.file(filePath).text() + return content + .split("\n") + .filter((line) => line.trim()) + .map((line) => JSON.parse(line) as Bead) + } catch (e) { + if ((e as NodeJS.ErrnoException).code === "ENOENT") { + return [] + } + throw e + } + } + + /** + * Write all issues to JSONL file (overwrites) + */ + export async function write(beadsDir: string, issues: Bead[]): Promise<void> { + const filePath = getIssuesPath(beadsDir) + await fs.mkdir(path.dirname(filePath), { recursive: true }) + + const content = issues.map((issue) => JSON.stringify(issue)).join("\n") + + await Bun.write(filePath, content ? content + "\n" : "") + } + + /** + * Append a single issue to JSONL file + */ + export async function append(beadsDir: string, issue: Bead): Promise<void> { + const filePath = getIssuesPath(beadsDir) + await fs.mkdir(path.dirname(filePath), { recursive: true }) + + const file = Bun.file(filePath) + const exists = await file.exists() + + if (exists) { + // Append to existing file + const existingContent = await file.text() + const newContent = existingContent.endsWith("\n") + ? existingContent + JSON.stringify(issue) + "\n" + : existingContent + "\n" + JSON.stringify(issue) + "\n" + await Bun.write(filePath, newContent) + } else { + await Bun.write(filePath, JSON.stringify(issue) + "\n") + } + } + + /** + * Update a single issue in JSONL file + */ + export async function update( + beadsDir: string, + id: BeadId, + updater: (issue: Bead) => Bead + ): Promise<Bead> { + const issues = await read(beadsDir) + const index = issues.findIndex((i) => i.id === id) + + if (index === -1) { + throw new Error(`Issue not found: ${id}`) + } + + const updated = updater(issues[index]) + issues[index] = { + ...updated, + updatedAt: Date.now(), + } + + await write(beadsDir, issues) + return issues[index] + } + + /** + * Delete a single issue from JSONL file + */ + export async function remove(beadsDir: string, id: BeadId): Promise<boolean> { + const issues = await read(beadsDir) + const filtered = issues.filter((i) => i.id !== id) + + if (filtered.length === issues.length) { + return false // Issue not found + } + + await write(beadsDir, filtered) + return true + } + + /** + * Check if issues file exists + */ + export async function exists(beadsDir: string): Promise<boolean> { + const filePath = getIssuesPath(beadsDir) + return await Bun.file(filePath).exists() + } + + /** + * Initialize empty issues file + */ + export async function init(beadsDir: string): Promise<void> { + const filePath = getIssuesPath(beadsDir) + await fs.mkdir(path.dirname(filePath), { recursive: true }) + + if (!(await Bun.file(filePath).exists())) { + await Bun.write(filePath, "") + } + } +} + +export default JSONL diff --git a/apps/terminal/src/cli/index.ts b/apps/terminal/src/cli/index.ts new file mode 100755 index 000000000..ad9ec1ed8 --- /dev/null +++ b/apps/terminal/src/cli/index.ts @@ -0,0 +1,656 @@ +#!/usr/bin/env bun +/** + * clawdstrike CLI - Command-line interface for the orchestration engine + * + * Usage: + * clawdstrike dispatch <prompt> Submit task for execution + * clawdstrike speculate <prompt> Run task with multiple agents + * clawdstrike gate [gates...] Run quality gates + * clawdstrike beads <subcommand> Manage work graph + * clawdstrike status Show kernel status + * clawdstrike init Initialize clawdstrike + * clawdstrike version Show version + */ + +import { parseArgs } from "util" +import { TUI, launchTUI } from "../tui" +import { VERSION, init, shutdown, isInitialized } from "../index" +import { Beads } from "../beads" +import { Telemetry } from "../telemetry" +import { executeTool } from "../tools" +import type { ToolContext } from "../tools" + +// ============================================================================= +// CLI TYPES +// ============================================================================= + +interface CLIOptions { + help?: boolean + version?: boolean + color?: boolean + json?: boolean + toolchain?: string + gates?: string[] + timeout?: number + strategy?: string + cwd?: string + project?: string + limit?: number + offset?: number +} + +// ============================================================================= +// ARGUMENT PARSING +// ============================================================================= + +function parseCliArgs(): { command: string; args: string[]; options: CLIOptions } { + const { values, positionals } = parseArgs({ + args: process.argv.slice(2), + options: { + help: { type: "boolean", short: "h" }, + version: { type: "boolean", short: "v" }, + "no-color": { type: "boolean" }, + json: { type: "boolean", short: "j" }, + toolchain: { type: "string", short: "t" }, + gate: { type: "string", short: "g", multiple: true }, + timeout: { type: "string" }, + strategy: { type: "string", short: "s" }, + cwd: { type: "string" }, + project: { type: "string", short: "p" }, + limit: { type: "string", short: "n" }, + offset: { type: "string" }, + }, + allowPositionals: true, + strict: false, + }) + + const command = positionals[0] ?? "" // Empty = launch TUI + const args = positionals.slice(1) + + // Handle --no-color flag + const noColor = values["no-color"] as boolean | undefined + const color = noColor ? false : true + + return { + command, + args, + options: { + help: values.help as boolean | undefined, + version: values.version as boolean | undefined, + color, + json: values.json as boolean | undefined, + toolchain: values.toolchain as string | undefined, + gates: values.gate as string[] | undefined, + timeout: values.timeout ? parseInt(values.timeout as string, 10) : undefined, + strategy: values.strategy as string | undefined, + cwd: values.cwd as string | undefined, + project: values.project as string | undefined, + limit: values.limit ? parseInt(values.limit as string, 10) : undefined, + offset: values.offset ? parseInt(values.offset as string, 10) : undefined, + }, + } +} + +// ============================================================================= +// HELP TEXT +// ============================================================================= + +function getHelpText(): string { + return ` +${TUI.header("clawdstrike - Security-Aware AI Coding Agent Orchestrator")} + +${TUI.info("Usage:")} clawdstrike <command> [options] [args] + +${TUI.info("Commands:")} + dispatch <prompt> Submit task for execution by an AI agent + speculate <prompt> Run task with multiple agents in parallel + gate [gates...] Run quality gates on current directory + beads <subcommand> Manage work graph (list, get, ready, create) + status Show active rollouts and kernel status + init Initialize clawdstrike in current directory + version Show version information + help Show this help message + +${TUI.info("Global Options:")} + -h, --help Show help for a command + -v, --version Show version + --no-color Disable colored output + -j, --json Output as JSON + --cwd <path> Working directory (default: current) + -p, --project <id> Project identifier (default: from cwd) + +${TUI.info("Dispatch Options:")} + -t, --toolchain <name> Force toolchain (codex, claude, opencode, crush) + -g, --gate <name> Quality gates to run (can specify multiple) + --timeout <ms> Execution timeout in milliseconds + +${TUI.info("Speculate Options:")} + -t, --toolchain <name> Toolchains to use (can specify multiple) + -s, --strategy <name> Vote strategy (first_pass, best_score, consensus) + -g, --gate <name> Quality gates to run (can specify multiple) + --timeout <ms> Execution timeout in milliseconds + +${TUI.info("Examples:")} + clawdstrike dispatch "Fix the bug in auth.ts" + clawdstrike dispatch -t claude "Add unit tests for utils.ts" + clawdstrike speculate -s best_score "Refactor the database module" + clawdstrike gate pytest mypy + clawdstrike beads list --status open + clawdstrike beads ready +` +} + +function getBeadsHelp(): string { + return ` +${TUI.header("clawdstrike beads - Work Graph Management")} + +${TUI.info("Subcommands:")} + list List all issues + get <id> Get issue details + ready Get issues ready for execution + create <title> Create new issue + +${TUI.info("List Options:")} + --status <status> Filter by status (open, in_progress, done, cancelled) + --priority <n> Filter by priority (0-100) + --label <label> Filter by label + --limit <n> Maximum results + --offset <n> Skip first n results + +${TUI.info("Examples:")} + clawdstrike beads list + clawdstrike beads list --status open --limit 10 + clawdstrike beads get proj-123 + clawdstrike beads ready + clawdstrike beads create "Fix authentication bug" +` +} + +// ============================================================================= +// COMMANDS +// ============================================================================= + +async function cmdDispatch(args: string[], options: CLIOptions): Promise<void> { + const prompt = args.join(" ") + if (!prompt) { + console.error(TUI.error("Missing prompt. Usage: clawdstrike dispatch <prompt>")) + process.exit(1) + } + + await ensureInitialized(options) + + const context: ToolContext = { + cwd: options.cwd ?? process.cwd(), + projectId: options.project ?? "default", + } + + console.log(TUI.progress(`Dispatching task...`)) + + try { + const result = await executeTool( + "dispatch", + { + prompt, + toolchain: options.toolchain, + gates: options.gates, + timeout: options.timeout, + }, + context + ) + + if (options.json) { + console.log(JSON.stringify(result, null, 2)) + } else { + const r = result as { + success: boolean + taskId: string + routing?: { toolchain: string } + verification?: { score: number } + error?: string + } + + if (r.success) { + console.log(TUI.success(`Task completed successfully`)) + console.log( + TUI.formatTable([ + ["Task ID", r.taskId.slice(0, 8)], + ["Toolchain", r.routing?.toolchain ?? "unknown"], + ["Gate Score", `${r.verification?.score ?? 0}/100`], + ]) + ) + } else { + console.log(TUI.error(`Task failed: ${r.error ?? "Unknown error"}`)) + process.exit(1) + } + } + } catch (err) { + console.error(TUI.error(`Dispatch failed: ${err}`)) + process.exit(1) + } +} + +async function cmdSpeculate(args: string[], options: CLIOptions): Promise<void> { + const prompt = args.join(" ") + if (!prompt) { + console.error(TUI.error("Missing prompt. Usage: clawdstrike speculate <prompt>")) + process.exit(1) + } + + await ensureInitialized(options) + + const context: ToolContext = { + cwd: options.cwd ?? process.cwd(), + projectId: options.project ?? "default", + } + + console.log(TUI.progress(`Running speculation...`)) + + try { + const result = await executeTool( + "speculate", + { + prompt, + toolchains: options.toolchain ? [options.toolchain] : undefined, + voteStrategy: options.strategy, + gates: options.gates, + timeout: options.timeout, + }, + context + ) + + if (options.json) { + console.log(JSON.stringify(result, null, 2)) + } else { + const r = result as { + success: boolean + winner?: { toolchain: string; score: number } + allResults: Array<{ toolchain: string; passed: boolean; score: number }> + } + + if (r.success && r.winner) { + console.log(TUI.success(`Speculation complete - Winner: ${r.winner.toolchain}`)) + console.log(TUI.info("Results:")) + for (const res of r.allResults) { + const icon = res.passed ? "✓" : "✗" + const suffix = res.toolchain === r.winner.toolchain ? " ← winner" : "" + console.log(` ${icon} ${res.toolchain}: ${res.score}/100${suffix}`) + } + } else { + console.log(TUI.error("No passing result found")) + for (const res of r.allResults) { + console.log(` ✗ ${res.toolchain}: ${res.score}/100`) + } + process.exit(1) + } + } + } catch (err) { + console.error(TUI.error(`Speculation failed: ${err}`)) + process.exit(1) + } +} + +async function cmdGate(args: string[], options: CLIOptions): Promise<void> { + await ensureInitialized(options) + + const gates = args.length > 0 ? args : undefined + const context: ToolContext = { + cwd: options.cwd ?? process.cwd(), + projectId: options.project ?? "default", + } + + console.log(TUI.progress(`Running gates...`)) + + try { + const result = await executeTool( + "gate", + { + gates, + directory: options.cwd, + }, + context + ) + + if (options.json) { + console.log(JSON.stringify(result, null, 2)) + } else { + const r = result as { + success: boolean + score: number + summary: string + results: Array<{ + gate: string + passed: boolean + errorCount: number + warningCount: number + }> + } + + if (r.success) { + console.log(TUI.success(`All gates passed (${r.score}/100)`)) + } else { + console.log(TUI.error(`Gates failed (${r.score}/100)`)) + } + + for (const res of r.results) { + const icon = res.passed ? "✓" : "✗" + let suffix = "" + if (res.errorCount > 0) suffix += ` ${res.errorCount} errors` + if (res.warningCount > 0) suffix += ` ${res.warningCount} warnings` + console.log(` ${icon} ${res.gate}${suffix}`) + } + + console.log(TUI.info(r.summary)) + + if (!r.success) { + process.exit(1) + } + } + } catch (err) { + console.error(TUI.error(`Gate check failed: ${err}`)) + process.exit(1) + } +} + +async function cmdBeads(args: string[], options: CLIOptions): Promise<void> { + const subcommand = args[0] + const subargs = args.slice(1) + + if (!subcommand || options.help) { + console.log(getBeadsHelp()) + return + } + + await ensureInitialized(options) + + switch (subcommand) { + case "list": { + const issues = await Beads.query({ + limit: options.limit, + offset: options.offset, + }) + + if (options.json) { + console.log(JSON.stringify(issues, null, 2)) + } else { + if (issues.length === 0) { + console.log(TUI.info("No issues found")) + } else { + console.log(TUI.header(`Issues (${issues.length})`)) + for (const issue of issues) { + const status = TUI.formatStatus( + issue.status === "open" + ? "pending" + : issue.status === "in_progress" + ? "executing" + : issue.status === "completed" + ? "completed" + : "cancelled" + ) + console.log(` ${issue.id} ${status} ${issue.title}`) + } + } + } + break + } + + case "get": { + const id = subargs[0] + if (!id) { + console.error(TUI.error("Missing issue ID")) + process.exit(1) + } + + const issue = await Beads.get(id as `${string}-${number}`) + + if (!issue) { + console.error(TUI.error(`Issue not found: ${id}`)) + process.exit(1) + } + + if (options.json) { + console.log(JSON.stringify(issue, null, 2)) + } else { + console.log(TUI.header(`Issue ${issue.id}`)) + console.log( + TUI.formatTable([ + ["Title", issue.title], + ["Status", issue.status], + ["Priority", issue.priority ?? "none"], + ["Labels", issue.labels?.join(", ") || "none"], + ["Created", new Date(issue.createdAt).toISOString()], + ]) + ) + if (issue.description) { + console.log(`\n${issue.description}`) + } + } + break + } + + case "ready": { + const ready = await Beads.getReady() + + if (options.json) { + console.log(JSON.stringify(ready, null, 2)) + } else { + if (ready.length === 0) { + console.log(TUI.info("No issues ready for execution")) + } else { + console.log(TUI.header(`Ready Issues (${ready.length})`)) + for (const issue of ready) { + const toolchain = issue.suggestedToolchain + ? TUI.formatToolchain(issue.suggestedToolchain as "codex" | "claude" | "opencode" | "crush") + : "auto" + console.log(` ${issue.id} [${toolchain}] ${issue.title}`) + } + } + } + break + } + + case "create": { + const title = subargs.join(" ") + if (!title) { + console.error(TUI.error("Missing title")) + process.exit(1) + } + + const issue = await Beads.create({ + title, + description: "", + status: "open", + priority: "p2", + labels: [], + }) + + if (options.json) { + console.log(JSON.stringify(issue, null, 2)) + } else { + console.log(TUI.success(`Created issue ${issue.id}: ${issue.title}`)) + } + break + } + + default: + console.error(TUI.error(`Unknown beads subcommand: ${subcommand}`)) + console.log(getBeadsHelp()) + process.exit(1) + } +} + +async function cmdStatus(options: CLIOptions): Promise<void> { + await ensureInitialized(options) + + const active = Telemetry.getActive() + + if (options.json) { + const rollouts = await Promise.all(active.map((id) => Telemetry.getRollout(id))) + console.log(JSON.stringify({ active: rollouts.filter(Boolean) }, null, 2)) + } else { + console.log(TUI.header("clawdstrike Status")) + console.log( + TUI.formatTable([ + ["Version", VERSION], + ["Initialized", isInitialized() ? "yes" : "no"], + ["Active Rollouts", String(active.length)], + ]) + ) + + if (active.length > 0) { + console.log(TUI.info("\nActive Rollouts:")) + for (const id of active) { + const rollout = await Telemetry.getRollout(id) + if (rollout) { + console.log(` ${id.slice(0, 8)} ${TUI.formatStatus(rollout.status)}`) + } + } + } + } +} + +async function cmdInit(options: CLIOptions): Promise<void> { + const cwd = options.cwd ?? process.cwd() + + console.log(TUI.progress(`Initializing clawdstrike in ${cwd}...`)) + + try { + await init({ + beadsPath: `${cwd}/.beads`, + telemetryDir: `${cwd}/.clawdstrike/runs`, + }) + + // Detect available adapters and write config + const { Config } = await import("../config") + const detection = await Config.detect(cwd) + + const config = { + schema_version: "1.0.0" as const, + sandbox: "inplace" as const, + toolchain: detection.recommended_toolchain, + adapters: detection.adapters, + git_available: detection.git_available, + project_id: options.project ?? "default", + } + await Config.save(cwd, config) + + console.log(TUI.success("clawdstrike initialized")) + + // Show detection summary + const rows: [string, string][] = [ + ["Config", ".clawdstrike/config.json"], + ["Beads", ".beads/issues.jsonl"], + ["Telemetry", ".clawdstrike/runs/"], + ["Sandbox", "inplace"], + ["Git", detection.git_available ? "detected" : "not found"], + ] + + // Add adapter status + for (const [id, info] of Object.entries(detection.adapters)) { + rows.push([id, info.available ? "available" : "not found"]) + } + + if (detection.recommended_toolchain) { + rows.push(["Default", detection.recommended_toolchain]) + } + + console.log(TUI.formatTable(rows, { indent: 2 })) + } catch (err) { + console.error(TUI.error(`Initialization failed: ${err}`)) + process.exit(1) + } +} + +async function cmdVersion(): Promise<void> { + console.log(`clawdstrike ${VERSION}`) +} + +async function cmdHelp(): Promise<void> { + console.log(getHelpText()) +} + +// ============================================================================= +// HELPERS +// ============================================================================= + +async function ensureInitialized(options: CLIOptions): Promise<void> { + if (!isInitialized()) { + const cwd = options.cwd ?? process.cwd() + await init({ + beadsPath: `${cwd}/.beads`, + telemetryDir: `${cwd}/.clawdstrike/runs`, + }) + } +} + +// ============================================================================= +// MAIN +// ============================================================================= + +async function main(): Promise<void> { + const { command, args, options } = parseCliArgs() + + // Configure TUI colors + TUI.setColors(options.color !== false) + + // Handle global flags + if (options.version) { + await cmdVersion() + return + } + + if (options.help && command === "help") { + await cmdHelp() + return + } + + // Route to command + try { + switch (command) { + case "": + // No command - launch interactive TUI + await launchTUI(options.cwd) + break + case "dispatch": + await cmdDispatch(args, options) + break + case "speculate": + await cmdSpeculate(args, options) + break + case "gate": + await cmdGate(args, options) + break + case "beads": + await cmdBeads(args, options) + break + case "status": + await cmdStatus(options) + break + case "init": + await cmdInit(options) + break + case "version": + await cmdVersion() + break + case "help": + await cmdHelp() + break + default: + console.error(TUI.error(`Unknown command: ${command}`)) + await cmdHelp() + process.exit(1) + } + } finally { + // Clean shutdown + if (isInitialized()) { + await shutdown() + } + } +} + +// Run CLI +main().catch((err) => { + console.error(TUI.error(`Fatal error: ${err}`)) + process.exit(1) +}) + +export { main, parseCliArgs } diff --git a/apps/terminal/src/config/index.ts b/apps/terminal/src/config/index.ts new file mode 100644 index 000000000..ab07e388a --- /dev/null +++ b/apps/terminal/src/config/index.ts @@ -0,0 +1,154 @@ +/** + * Config - Project configuration management + * + * Handles loading, saving, and detecting project configuration. + * Stores config as JSON in .clawdstrike/config.json. + */ + +import { z } from "zod" +import { join } from "path" +import { mkdir, readFile, writeFile, stat } from "fs/promises" +import type { Toolchain, SandboxMode } from "../types" + +// ============================================================================= +// SCHEMA +// ============================================================================= + +export const ProjectConfig = z.object({ + schema_version: z.literal("1.0.0"), + sandbox: z.enum(["inplace", "worktree", "tmpdir"]).default("inplace"), + toolchain: z.enum(["codex", "claude", "opencode", "crush"]).optional(), + adapters: z + .record( + z.string(), + z.object({ + available: z.boolean(), + version: z.string().optional(), + }) + ) + .default({}), + git_available: z.boolean().default(false), + project_id: z.string().default("default"), +}) + +export type ProjectConfig = z.infer<typeof ProjectConfig> + +// ============================================================================= +// DETECTION +// ============================================================================= + +export interface DetectionResult { + adapters: Record<string, { available: boolean; version?: string }> + git_available: boolean + recommended_sandbox: SandboxMode + recommended_toolchain?: Toolchain +} + +// ============================================================================= +// CONFIG NAMESPACE +// ============================================================================= + +const CONFIG_DIR = ".clawdstrike" +const CONFIG_FILE = "config.json" + +function configPath(cwd: string): string { + return join(cwd, CONFIG_DIR, CONFIG_FILE) +} + +export namespace Config { + /** + * Check if config file exists + */ + export async function exists(cwd: string): Promise<boolean> { + try { + await stat(configPath(cwd)) + return true + } catch { + return false + } + } + + /** + * Load project config from .clawdstrike/config.json + * Returns null if file doesn't exist + */ + export async function load(cwd: string): Promise<ProjectConfig | null> { + try { + const raw = await readFile(configPath(cwd), "utf-8") + const data = JSON.parse(raw) + return ProjectConfig.parse(data) + } catch { + return null + } + } + + /** + * Save project config to .clawdstrike/config.json + */ + export async function save( + cwd: string, + config: ProjectConfig + ): Promise<void> { + const dir = join(cwd, CONFIG_DIR) + await mkdir(dir, { recursive: true }) + const validated = ProjectConfig.parse(config) + await writeFile(configPath(cwd), JSON.stringify(validated, null, 2) + "\n") + } + + /** + * Detect available toolchains, git status, and recommend configuration + */ + export async function detect(cwd: string): Promise<DetectionResult> { + const { getAllAdapters } = await import("../dispatcher/adapters") + const allAdapters = getAllAdapters() + + // Run all adapter availability checks in parallel + git check + const adapterChecks = allAdapters.map(async (adapter) => { + const available = await adapter.isAvailable() + return { + id: adapter.info.id, + available, + } + }) + + const gitCheck = async (): Promise<boolean> => { + try { + const { getGitRoot } = await import("../workcell/git") + await getGitRoot(cwd) + return true + } catch { + return false + } + } + + const [adapterResults, gitAvailable] = await Promise.all([ + Promise.all(adapterChecks), + gitCheck(), + ]) + + // Build adapters map + const adapters: Record<string, { available: boolean; version?: string }> = + {} + for (const result of adapterResults) { + adapters[result.id] = { available: result.available } + } + + // Recommend sandbox: worktree if git available, otherwise inplace + const recommended_sandbox: SandboxMode = gitAvailable + ? "worktree" + : "inplace" + + // Recommend first available toolchain (prefer claude > codex > opencode > crush) + const priority: Toolchain[] = ["claude", "codex", "opencode", "crush"] + const recommended_toolchain = priority.find((t) => adapters[t]?.available) + + return { + adapters, + git_available: gitAvailable, + recommended_sandbox, + recommended_toolchain, + } + } +} + +export default Config diff --git a/apps/terminal/src/dispatcher/adapters/claude.ts b/apps/terminal/src/dispatcher/adapters/claude.ts new file mode 100644 index 000000000..e0a3d7158 --- /dev/null +++ b/apps/terminal/src/dispatcher/adapters/claude.ts @@ -0,0 +1,220 @@ +/** + * Claude Adapter - Anthropic Claude Code CLI integration + * + * Dispatches tasks to Claude Code using OAuth subscription auth. + * Preserves Claude Pro/Team subscription authentication. + */ + +import { $ } from "bun" +import { join } from "path" +import { stat } from "fs/promises" +import { homedir } from "os" +import type { Adapter, AdapterResult } from "../index" +import type { WorkcellInfo, TaskInput } from "../../types" + +/** + * Claude Code configuration + */ +export interface ClaudeConfig { + model?: string + allowedTools?: string[] + timeout?: number + maxTurns?: number +} + +const DEFAULT_CONFIG: ClaudeConfig = { + allowedTools: ["Read", "Glob", "Grep", "Edit", "Write", "Bash"], + timeout: 300000, // 5 minutes + maxTurns: 50, +} + +let config: ClaudeConfig = { ...DEFAULT_CONFIG } + +/** + * Configure Claude adapter + */ +export function configure(newConfig: Partial<ClaudeConfig>): void { + config = { ...config, ...newConfig } +} + +/** + * Claude Code adapter implementation + */ +export const ClaudeAdapter: Adapter = { + info: { + id: "claude", + name: "Claude Code", + description: "Anthropic Claude Code with Pro/Team subscription", + authType: "oauth", + requiresInstall: true, + }, + + async isAvailable(): Promise<boolean> { + // Check if `claude` CLI exists + const which = await $`which claude`.quiet().nothrow() + if (which.exitCode !== 0) { + return false + } + + // Check if auth is configured + // Claude Code stores OAuth in ~/.claude/ + const configPath = join(homedir(), ".claude", "config.json") + try { + await stat(configPath) + return true + } catch { + // Config doesn't exist, check if logged in via other means + const authCheck = await $`claude auth status`.quiet().nothrow() + return authCheck.exitCode === 0 + } + }, + + async execute( + workcell: WorkcellInfo, + task: TaskInput, + signal: AbortSignal + ): Promise<AdapterResult> { + const startTime = Date.now() + + // Build command arguments + // Claude Code uses --print for non-interactive single-prompt mode + const args: string[] = [ + "--print", + "--output-format", "json", + ] + + // Add allowed tools whitelist + if (config.allowedTools && config.allowedTools.length > 0) { + args.push("--allowedTools", config.allowedTools.join(",")) + } + + // Add model if specified + if (config.model) { + args.push("--model", config.model) + } + + // Add max turns if specified + if (config.maxTurns) { + args.push("--max-turns", String(config.maxTurns)) + } + + // Add the prompt as the last argument + args.push(task.prompt) + + try { + // Execute claude CLI + const proc = Bun.spawn(["claude", ...args], { + cwd: workcell.directory, + env: { + ...process.env, + // Claude Code reads OAuth from ~/.claude/ + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1", + // Sandbox markers + CLAWDSTRIKE_SANDBOX: "1", + CLAWDSTRIKE_WORKCELL_ROOT: workcell.directory, + CLAWDSTRIKE_WORKCELL_ID: workcell.id, + }, + stdout: "pipe", + stderr: "pipe", + }) + + // Handle abort signal + const abortHandler = () => { + proc.kill() + } + signal.addEventListener("abort", abortHandler) + + // Read output + const [stdout, stderr] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]) + const exitCode = await proc.exited + + signal.removeEventListener("abort", abortHandler) + + if (signal.aborted) { + return { + success: false, + output: stdout, + error: "Execution cancelled", + } + } + + if (exitCode !== 0) { + return { + success: false, + output: stdout, + error: stderr || `Claude exited with code ${exitCode}`, + } + } + + // Parse telemetry from output + const telemetry = this.parseTelemetry(stdout) + + return { + success: true, + output: stdout, + telemetry: { + ...telemetry, + startedAt: startTime, + completedAt: Date.now(), + }, + } + } catch (error) { + return { + success: false, + output: "", + error: error instanceof Error ? error.message : String(error), + } + } + }, + + parseTelemetry(output: string): Partial<AdapterResult["telemetry"]> { + try { + // Claude Code outputs JSON with usage info + const lines = output.split("\n") + for (const line of lines) { + if (line.startsWith("{") && line.includes("usage")) { + try { + const data = JSON.parse(line) + if (data.usage) { + return { + model: data.model, + tokens: { + input: data.usage.input_tokens || 0, + output: data.usage.output_tokens || 0, + }, + cost: data.cost, + } + } + } catch { + // Not valid JSON, continue + } + } + } + + // Try parsing the entire output as JSON + try { + const data = JSON.parse(output) + if (data.usage) { + return { + model: data.model, + tokens: { + input: data.usage.input_tokens || 0, + output: data.usage.output_tokens || 0, + }, + cost: data.cost, + } + } + } catch { + // Not JSON + } + } catch { + // Ignore parse errors + } + return {} + }, +} + +export default ClaudeAdapter diff --git a/apps/terminal/src/dispatcher/adapters/codex.ts b/apps/terminal/src/dispatcher/adapters/codex.ts new file mode 100644 index 000000000..20891caef --- /dev/null +++ b/apps/terminal/src/dispatcher/adapters/codex.ts @@ -0,0 +1,204 @@ +/** + * Codex Adapter - OpenAI Codex CLI integration + * + * Dispatches tasks to the Codex CLI using OAuth subscription auth. + * Preserves ChatGPT Plus/Team/Enterprise subscription authentication. + */ + +import { $ } from "bun" +import { join } from "path" +import { mkdir, writeFile } from "fs/promises" +import { homedir } from "os" +import type { Adapter, AdapterResult } from "../index" +import type { WorkcellInfo, TaskInput } from "../../types" + +/** + * Codex CLI configuration + */ +export interface CodexConfig { + approvalMode?: "suggest" | "auto-edit" | "full-auto" + model?: string + timeout?: number +} + +const DEFAULT_CONFIG: CodexConfig = { + approvalMode: "suggest", + timeout: 300000, // 5 minutes +} + +let config: CodexConfig = { ...DEFAULT_CONFIG } + +/** + * Configure Codex adapter + */ +export function configure(newConfig: Partial<CodexConfig>): void { + config = { ...config, ...newConfig } +} + +/** + * Codex CLI adapter implementation + */ +export const CodexAdapter: Adapter = { + info: { + id: "codex", + name: "Codex CLI", + description: "OpenAI Codex CLI with ChatGPT Plus/Team/Enterprise subscription", + authType: "oauth", + requiresInstall: true, + }, + + async isAvailable(): Promise<boolean> { + // Check if `codex` CLI exists + const which = await $`which codex`.quiet().nothrow() + if (which.exitCode !== 0) { + return false + } + + // Check if auth is configured + // Codex stores OAuth tokens in ~/.codex/ + const authPath = join(homedir(), ".codex", "auth.json") + try { + const authCheck = await $`test -f ${authPath}`.quiet().nothrow() + if (authCheck.exitCode !== 0) { + // Try checking via codex auth status + const statusCheck = await $`codex auth status`.quiet().nothrow() + return statusCheck.exitCode === 0 + } + return true + } catch { + return false + } + }, + + async execute( + workcell: WorkcellInfo, + task: TaskInput, + signal: AbortSignal + ): Promise<AdapterResult> { + const startTime = Date.now() + + // Write prompt to file (codex prefers file input for long prompts) + const metaDir = join(workcell.directory, ".clawdstrike") + const promptPath = join(metaDir, "prompt.md") + await mkdir(metaDir, { recursive: true }) + await writeFile(promptPath, task.prompt) + + // Build command arguments + const args: string[] = [ + "run", + "--format", "json", + "--approval-mode", config.approvalMode || "suggest", + ] + + // Add sandbox options if available + args.push("--writable-root", workcell.directory) + + // Add prompt file + args.push("--prompt-file", promptPath) + + // Add model if specified + if (config.model) { + args.push("--model", config.model) + } + + try { + // Execute codex CLI + const proc = Bun.spawn(["codex", ...args], { + cwd: workcell.directory, + env: { + ...process.env, + // Codex reads OAuth from ~/.codex/auth.json + // No API key needed when using subscription + CLAWDSTRIKE_SANDBOX: "1", + CLAWDSTRIKE_WORKCELL_ROOT: workcell.directory, + CLAWDSTRIKE_WORKCELL_ID: workcell.id, + }, + stdout: "pipe", + stderr: "pipe", + }) + + // Handle abort signal + const abortHandler = () => { + proc.kill() + } + signal.addEventListener("abort", abortHandler) + + // Read output + const [stdout, stderr] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]) + const exitCode = await proc.exited + + signal.removeEventListener("abort", abortHandler) + + if (signal.aborted) { + return { + success: false, + output: stdout, + error: "Execution cancelled", + } + } + + if (exitCode !== 0) { + return { + success: false, + output: stdout, + error: stderr || `Codex exited with code ${exitCode}`, + } + } + + // Parse JSON output + const telemetry = this.parseTelemetry(stdout) + + return { + success: true, + output: stdout, + telemetry: { + ...telemetry, + startedAt: startTime, + completedAt: Date.now(), + }, + } + } catch (error) { + return { + success: false, + output: "", + error: error instanceof Error ? error.message : String(error), + } + } + }, + + parseTelemetry(output: string): Partial<AdapterResult["telemetry"]> { + try { + // Try to parse JSON output + const lines = output.split("\n") + for (const line of lines) { + if (line.startsWith("{")) { + try { + const data = JSON.parse(line) + if (data.usage || data.model) { + return { + model: data.model, + tokens: data.usage + ? { + input: data.usage.input_tokens || data.usage.prompt_tokens || 0, + output: data.usage.output_tokens || data.usage.completion_tokens || 0, + } + : undefined, + cost: data.cost, + } + } + } catch { + // Not valid JSON, continue + } + } + } + } catch { + // Ignore parse errors + } + return {} + }, +} + +export default CodexAdapter diff --git a/apps/terminal/src/dispatcher/adapters/crush.ts b/apps/terminal/src/dispatcher/adapters/crush.ts new file mode 100644 index 000000000..dd5fadb9f --- /dev/null +++ b/apps/terminal/src/dispatcher/adapters/crush.ts @@ -0,0 +1,420 @@ +/** + * Crush Adapter - Multi-provider fallback execution + * + * Provides retry and fallback across multiple providers. + * Used for unreliable networks or batch jobs. + */ + +import { $ } from "bun" +import { join } from "path" +import { mkdir, writeFile } from "fs/promises" +import type { Adapter, AdapterResult } from "../index" +import type { WorkcellInfo, TaskInput } from "../../types" +import { callAnthropicApi, callOpenAiApi } from "./llm-api" + +/** + * Crush configuration + */ +export interface CrushConfig { + providers?: string[] + retries?: number + timeout?: number + backoffMs?: number +} + +const DEFAULT_CONFIG: CrushConfig = { + providers: ["anthropic", "openai", "google"], + retries: 3, + timeout: 300000, // 5 minutes + backoffMs: 1000, +} + +let config: CrushConfig = { ...DEFAULT_CONFIG } + +/** + * Configure Crush adapter + */ +export function configure(newConfig: Partial<CrushConfig>): void { + config = { ...config, ...newConfig } +} + +/** + * Sleep utility + */ +function sleep(ms: number): Promise<void> { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +/** + * Crush adapter implementation (multi-provider fallback) + */ +export const CrushAdapter: Adapter = { + info: { + id: "crush", + name: "Crush (Multi-provider Fallback)", + description: "Retries across multiple providers with exponential backoff", + authType: "api_key", + requiresInstall: true, + }, + + async isAvailable(): Promise<boolean> { + // Check if `crush` CLI exists + const which = await $`which crush`.quiet().nothrow() + if (which.exitCode === 0) { + return true + } + + // Crush is also available if we have any API keys for fallback + const hasAnyKey = + !!process.env.ANTHROPIC_API_KEY || + !!process.env.OPENAI_API_KEY || + !!process.env.GOOGLE_API_KEY + + return hasAnyKey + }, + + async execute( + workcell: WorkcellInfo, + task: TaskInput, + signal: AbortSignal + ): Promise<AdapterResult> { + const startTime = Date.now() + + // Try CLI first if available + const cliAvailable = (await $`which crush`.quiet().nothrow()).exitCode === 0 + + if (cliAvailable) { + return executeViaCli(workcell, task, signal, startTime) + } + + // Fall back to manual retry logic + return executeWithRetry(workcell, task, signal, startTime) + }, + + parseTelemetry(output: string): Partial<AdapterResult["telemetry"]> { + try { + const data = JSON.parse(output) + if (data.usage) { + return { + model: data.model, + tokens: { + input: data.usage.input_tokens || data.usage.prompt_tokens || 0, + output: data.usage.output_tokens || data.usage.completion_tokens || 0, + }, + cost: data.cost, + } + } + } catch { + // Ignore + } + return {} + }, +} + +/** + * Execute via crush CLI + */ +async function executeViaCli( + workcell: WorkcellInfo, + task: TaskInput, + signal: AbortSignal, + startTime: number +): Promise<AdapterResult> { + const metaDir = join(workcell.directory, ".clawdstrike") + const promptPath = join(metaDir, "prompt.md") + await mkdir(metaDir, { recursive: true }) + await writeFile(promptPath, task.prompt) + + const providers = config.providers?.join(",") || "anthropic,openai,google" + + const args: string[] = [ + "--prompt-file", promptPath, + "--providers", providers, + "--retries", String(config.retries || 3), + "--timeout", String((config.timeout || 300000) / 1000), + "--output", "json", + ] + + try { + const proc = Bun.spawn(["crush", ...args], { + cwd: workcell.directory, + env: { + ...process.env, + CLAWDSTRIKE_SANDBOX: "1", + CLAWDSTRIKE_WORKCELL_ROOT: workcell.directory, + CLAWDSTRIKE_WORKCELL_ID: workcell.id, + }, + stdout: "pipe", + stderr: "pipe", + }) + + const abortHandler = () => proc.kill() + signal.addEventListener("abort", abortHandler) + + const [stdout, stderr] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]) + const exitCode = await proc.exited + + signal.removeEventListener("abort", abortHandler) + + if (signal.aborted) { + return { + success: false, + output: stdout, + error: "Execution cancelled", + } + } + + if (exitCode !== 0) { + return { + success: false, + output: stdout, + error: stderr || `Crush exited with code ${exitCode}`, + } + } + + return { + success: true, + output: stdout, + telemetry: { + ...CrushAdapter.parseTelemetry(stdout), + startedAt: startTime, + completedAt: Date.now(), + }, + } + } catch (error) { + return { + success: false, + output: "", + error: error instanceof Error ? error.message : String(error), + } + } +} + +/** + * Execute with manual retry across providers + */ +async function executeWithRetry( + workcell: WorkcellInfo, + task: TaskInput, + signal: AbortSignal, + startTime: number +): Promise<AdapterResult> { + const providers = getAvailableProviders() + const maxRetries = config.retries || 3 + const backoffMs = config.backoffMs || 1000 + + const errors: string[] = [] + + for (let attempt = 0; attempt < maxRetries; attempt++) { + for (const provider of providers) { + if (signal.aborted) { + return { + success: false, + output: "", + error: "Execution cancelled", + } + } + + try { + const result = await executeWithProvider( + provider, + workcell, + task, + signal, + startTime + ) + + if (result.success) { + return result + } + + errors.push(`${provider}: ${result.error}`) + } catch (error) { + errors.push( + `${provider}: ${error instanceof Error ? error.message : String(error)}` + ) + } + } + + // Exponential backoff before next retry round + if (attempt < maxRetries - 1) { + await sleep(backoffMs * Math.pow(2, attempt)) + } + } + + return { + success: false, + output: "", + error: `All providers failed after ${maxRetries} attempts:\n${errors.join("\n")}`, + } +} + +/** + * Get available providers based on configured API keys + */ +function getAvailableProviders(): string[] { + const available: string[] = [] + + if (process.env.ANTHROPIC_API_KEY) { + available.push("anthropic") + } + if (process.env.OPENAI_API_KEY) { + available.push("openai") + } + if (process.env.GOOGLE_API_KEY) { + available.push("google") + } + + // Filter by configured providers if set + if (config.providers && config.providers.length > 0) { + return config.providers.filter((p) => available.includes(p)) + } + + return available +} + +/** + * Execute with a specific provider + */ +async function executeWithProvider( + provider: string, + workcell: WorkcellInfo, + task: TaskInput, + signal: AbortSignal, + startTime: number +): Promise<AdapterResult> { + switch (provider) { + case "anthropic": + return executeAnthropicApi(workcell, task, signal, startTime) + case "openai": + return executeOpenAiApi(workcell, task, signal, startTime) + case "google": + return executeGoogleApi(workcell, task, signal, startTime) + default: + return { + success: false, + output: "", + error: `Unknown provider: ${provider}`, + } + } +} + +/** + * Execute via Anthropic API + */ +async function executeAnthropicApi( + workcell: WorkcellInfo, + task: TaskInput, + signal: AbortSignal, + startTime: number +): Promise<AdapterResult> { + const apiKey = process.env.ANTHROPIC_API_KEY + if (!apiKey) { + return { success: false, output: "", error: "ANTHROPIC_API_KEY not set" } + } + + return callAnthropicApi({ + apiKey, + model: "claude-sonnet-4-20250514", + systemPrompt: `Working in: ${workcell.directory}`, + userContent: task.prompt, + signal, + startTime, + }) +} + +/** + * Execute via OpenAI API + */ +async function executeOpenAiApi( + workcell: WorkcellInfo, + task: TaskInput, + signal: AbortSignal, + startTime: number +): Promise<AdapterResult> { + const apiKey = process.env.OPENAI_API_KEY + if (!apiKey) { + return { success: false, output: "", error: "OPENAI_API_KEY not set" } + } + + return callOpenAiApi({ + apiKey, + model: "gpt-4o", + systemPrompt: `Working in: ${workcell.directory}`, + userContent: task.prompt, + signal, + startTime, + }) +} + +/** + * Execute via Google AI API + */ +async function executeGoogleApi( + workcell: WorkcellInfo, + task: TaskInput, + signal: AbortSignal, + startTime: number +): Promise<AdapterResult> { + const apiKey = process.env.GOOGLE_API_KEY + if (!apiKey) { + return { success: false, output: "", error: "GOOGLE_API_KEY not set" } + } + + const response = await fetch( + `https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-pro:generateContent?key=${apiKey}`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + contents: [ + { + role: "user", + parts: [{ text: `Working in: ${workcell.directory}\n\n${task.prompt}` }], + }, + ], + generationConfig: { + maxOutputTokens: 8192, + }, + }), + signal, + } + ) + + if (!response.ok) { + return { + success: false, + output: "", + error: `Google API error: ${response.status}`, + } + } + + const data = (await response.json()) as { + candidates?: { content?: { parts?: { text?: string }[] } }[] + usageMetadata?: { promptTokenCount?: number; candidatesTokenCount?: number } + } + const output = data.candidates?.[0]?.content?.parts?.[0]?.text || "" + + return { + success: true, + output, + telemetry: { + model: "gemini-1.5-pro", + tokens: data.usageMetadata + ? { + input: data.usageMetadata.promptTokenCount || 0, + output: data.usageMetadata.candidatesTokenCount || 0, + } + : undefined, + startedAt: startTime, + completedAt: Date.now(), + }, + } +} + +export default CrushAdapter diff --git a/apps/terminal/src/dispatcher/adapters/index.ts b/apps/terminal/src/dispatcher/adapters/index.ts new file mode 100644 index 000000000..f1dfd5b44 --- /dev/null +++ b/apps/terminal/src/dispatcher/adapters/index.ts @@ -0,0 +1,51 @@ +/** + * Adapter registry - CLI adapter exports + */ + +export { CodexAdapter } from "./codex" +export { ClaudeAdapter } from "./claude" +export { OpenCodeAdapter } from "./opencode" +export { CrushAdapter } from "./crush" + +import { CodexAdapter } from "./codex" +import { ClaudeAdapter } from "./claude" +import { OpenCodeAdapter } from "./opencode" +import { CrushAdapter } from "./crush" +import type { Adapter } from "../index" + +/** + * All available adapters + */ +export const adapters: Record<string, Adapter> = { + codex: CodexAdapter, + claude: ClaudeAdapter, + opencode: OpenCodeAdapter, + crush: CrushAdapter, +} + +/** + * Get adapter by ID + */ +export function getAdapter(id: string): Adapter | undefined { + return adapters[id] +} + +/** + * Get all adapters + */ +export function getAllAdapters(): Adapter[] { + return Object.values(adapters) +} + +/** + * Get available adapters (those that pass isAvailable check) + */ +export async function getAvailableAdapters(): Promise<Adapter[]> { + const available: Adapter[] = [] + for (const adapter of Object.values(adapters)) { + if (await adapter.isAvailable()) { + available.push(adapter) + } + } + return available +} diff --git a/apps/terminal/src/dispatcher/adapters/llm-api.ts b/apps/terminal/src/dispatcher/adapters/llm-api.ts new file mode 100644 index 000000000..dd75f7847 --- /dev/null +++ b/apps/terminal/src/dispatcher/adapters/llm-api.ts @@ -0,0 +1,127 @@ +/** + * Shared LLM API call utilities for adapter modules. + * + * Eliminates duplication of Anthropic / OpenAI fetch logic across adapters + * while allowing each adapter to customise model, system prompt, and error detail. + */ + +import type { AdapterResult } from "../index" + +export interface AnthropicCallOptions { + apiKey: string + model: string + systemPrompt: string + userContent: string + signal: AbortSignal + startTime: number + maxTokens?: number + includeErrorBody?: boolean +} + +export interface OpenAiCallOptions { + apiKey: string + model: string + systemPrompt: string + userContent: string + signal: AbortSignal + startTime: number + maxTokens?: number + includeErrorBody?: boolean +} + +export async function callAnthropicApi( + opts: AnthropicCallOptions +): Promise<AdapterResult> { + const response = await fetch("https://api.anthropic.com/v1/messages", { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": opts.apiKey, + "anthropic-version": "2023-06-01", + }, + body: JSON.stringify({ + model: opts.model, + max_tokens: opts.maxTokens ?? 8192, + system: opts.systemPrompt, + messages: [{ role: "user", content: opts.userContent }], + }), + signal: opts.signal, + }) + + if (!response.ok) { + const detail = opts.includeErrorBody ? ` - ${await response.text()}` : "" + return { + success: false, + output: "", + error: `Anthropic API error: ${response.status}${detail}`, + } + } + + const data = (await response.json()) as { + content?: { text?: string }[] + model?: string + usage?: { input_tokens?: number; output_tokens?: number } + } + + return { + success: true, + output: data.content?.[0]?.text || "", + telemetry: { + model: data.model, + tokens: data.usage + ? { input: data.usage.input_tokens || 0, output: data.usage.output_tokens || 0 } + : undefined, + startedAt: opts.startTime, + completedAt: Date.now(), + }, + } +} + +export async function callOpenAiApi( + opts: OpenAiCallOptions +): Promise<AdapterResult> { + const response = await fetch("https://api.openai.com/v1/chat/completions", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${opts.apiKey}`, + }, + body: JSON.stringify({ + model: opts.model, + messages: [ + { role: "system", content: opts.systemPrompt }, + { role: "user", content: opts.userContent }, + ], + max_tokens: opts.maxTokens ?? 8192, + }), + signal: opts.signal, + }) + + if (!response.ok) { + const detail = opts.includeErrorBody ? ` - ${await response.text()}` : "" + return { + success: false, + output: "", + error: `OpenAI API error: ${response.status}${detail}`, + } + } + + const data = (await response.json()) as { + choices?: { message?: { content?: string } }[] + model?: string + usage?: { prompt_tokens?: number; completion_tokens?: number } + } + + return { + success: true, + output: data.choices?.[0]?.message?.content || "", + telemetry: { + model: data.model, + tokens: data.usage + ? { input: data.usage.prompt_tokens || 0, output: data.usage.completion_tokens || 0 } + : undefined, + startedAt: opts.startTime, + completedAt: Date.now(), + }, + } +} diff --git a/apps/terminal/src/dispatcher/adapters/opencode.ts b/apps/terminal/src/dispatcher/adapters/opencode.ts new file mode 100644 index 000000000..e59dc038e --- /dev/null +++ b/apps/terminal/src/dispatcher/adapters/opencode.ts @@ -0,0 +1,319 @@ +/** + * OpenCode Adapter - Local OpenCode execution + * + * Executes tasks using the local OpenCode runtime or direct API calls. + * Uses API key authentication (not subscription-based). + */ + +import { $ } from "bun" +import { join } from "path" +import { mkdir, writeFile } from "fs/promises" +import type { Adapter, AdapterResult } from "../index" +import type { WorkcellInfo, TaskInput } from "../../types" +import { callAnthropicApi, callOpenAiApi } from "./llm-api" + +/** + * OpenCode configuration + */ +export interface OpenCodeConfig { + model?: string + provider?: "anthropic" | "openai" | "google" + timeout?: number + apiKeyEnvVar?: string +} + +const DEFAULT_CONFIG: OpenCodeConfig = { + provider: "anthropic", + model: "claude-sonnet-4-20250514", + timeout: 300000, // 5 minutes +} + +let config: OpenCodeConfig = { ...DEFAULT_CONFIG } + +/** + * Configure OpenCode adapter + */ +export function configure(newConfig: Partial<OpenCodeConfig>): void { + config = { ...config, ...newConfig } +} + +/** + * Check if required API key is set + */ +function hasApiKey(): boolean { + const provider = config.provider || "anthropic" + const envVars: Record<string, string> = { + anthropic: "ANTHROPIC_API_KEY", + openai: "OPENAI_API_KEY", + google: "GOOGLE_API_KEY", + } + + const envVar = config.apiKeyEnvVar || envVars[provider] + return !!process.env[envVar] +} + +/** + * OpenCode adapter implementation (local execution) + */ +export const OpenCodeAdapter: Adapter = { + info: { + id: "opencode", + name: "OpenCode (Local)", + description: "Local OpenCode execution with API key auth", + authType: "api_key", + requiresInstall: false, // Can run directly via API + }, + + async isAvailable(): Promise<boolean> { + // OpenCode is available if we have an API key + // or if the opencode CLI is installed + if (hasApiKey()) { + return true + } + + // Check for opencode CLI + const which = await $`which opencode`.quiet().nothrow() + return which.exitCode === 0 + }, + + async execute( + workcell: WorkcellInfo, + task: TaskInput, + signal: AbortSignal + ): Promise<AdapterResult> { + const startTime = Date.now() + + // Try CLI first if available + const cliAvailable = (await $`which opencode`.quiet().nothrow()).exitCode === 0 + + if (cliAvailable) { + return executeViaCli(workcell, task, signal, startTime) + } + + // Fall back to direct API execution + if (hasApiKey()) { + return executeViaApi(workcell, task, signal, startTime) + } + + return { + success: false, + output: "", + error: "No API key configured and opencode CLI not found", + } + }, + + parseTelemetry(output: string): Partial<AdapterResult["telemetry"]> { + try { + const data = JSON.parse(output) + if (data.usage) { + return { + model: data.model, + tokens: { + input: data.usage.input_tokens || data.usage.prompt_tokens || 0, + output: data.usage.output_tokens || data.usage.completion_tokens || 0, + }, + cost: data.cost, + } + } + } catch { + // Try line-by-line parsing + const lines = output.split("\n") + for (const line of lines) { + if (line.startsWith("{")) { + try { + const data = JSON.parse(line) + if (data.usage) { + return { + model: data.model, + tokens: { + input: data.usage.input_tokens || 0, + output: data.usage.output_tokens || 0, + }, + cost: data.cost, + } + } + } catch { + // Continue + } + } + } + } + return {} + }, +} + +/** + * Execute via opencode CLI + */ +async function executeViaCli( + workcell: WorkcellInfo, + task: TaskInput, + signal: AbortSignal, + startTime: number +): Promise<AdapterResult> { + // Write prompt to file + const metaDir = join(workcell.directory, ".clawdstrike") + const promptPath = join(metaDir, "prompt.md") + await mkdir(metaDir, { recursive: true }) + await writeFile(promptPath, task.prompt) + + const args: string[] = [ + "run", + "--cwd", workcell.directory, + "--prompt-file", promptPath, + "--output", "json", + ] + + if (config.model) { + args.push("--model", config.model) + } + + try { + const proc = Bun.spawn(["opencode", ...args], { + cwd: workcell.directory, + env: { + ...process.env, + CLAWDSTRIKE_SANDBOX: "1", + CLAWDSTRIKE_WORKCELL_ROOT: workcell.directory, + CLAWDSTRIKE_WORKCELL_ID: workcell.id, + }, + stdout: "pipe", + stderr: "pipe", + }) + + const abortHandler = () => proc.kill() + signal.addEventListener("abort", abortHandler) + + const [stdout, stderr] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]) + const exitCode = await proc.exited + + signal.removeEventListener("abort", abortHandler) + + if (signal.aborted) { + return { + success: false, + output: stdout, + error: "Execution cancelled", + } + } + + if (exitCode !== 0) { + return { + success: false, + output: stdout, + error: stderr || `OpenCode exited with code ${exitCode}`, + } + } + + return { + success: true, + output: stdout, + telemetry: { + ...OpenCodeAdapter.parseTelemetry(stdout), + startedAt: startTime, + completedAt: Date.now(), + }, + } + } catch (error) { + return { + success: false, + output: "", + error: error instanceof Error ? error.message : String(error), + } + } +} + +/** + * Execute via direct API call (simplified version) + */ +async function executeViaApi( + workcell: WorkcellInfo, + task: TaskInput, + signal: AbortSignal, + startTime: number +): Promise<AdapterResult> { + const provider = config.provider || "anthropic" + + try { + if (provider === "anthropic") { + return await executeAnthropicApi(workcell, task, signal, startTime) + } else if (provider === "openai") { + return await executeOpenAiApi(workcell, task, signal, startTime) + } else { + return { + success: false, + output: "", + error: `Unsupported provider: ${provider}`, + } + } + } catch (error) { + return { + success: false, + output: "", + error: error instanceof Error ? error.message : String(error), + } + } +} + +/** + * Execute via Anthropic API + */ +function buildSystemPrompt(workcell: WorkcellInfo, task: TaskInput): string { + return `You are an AI coding assistant working in directory: ${workcell.directory} +Project: ${task.context.projectId} +Branch: ${workcell.branch} + +Execute the following task and provide your response.` +} + +async function executeAnthropicApi( + workcell: WorkcellInfo, + task: TaskInput, + signal: AbortSignal, + startTime: number +): Promise<AdapterResult> { + const apiKey = process.env.ANTHROPIC_API_KEY + if (!apiKey) { + return { success: false, output: "", error: "ANTHROPIC_API_KEY not set" } + } + + return callAnthropicApi({ + apiKey, + model: config.model || "claude-sonnet-4-20250514", + systemPrompt: buildSystemPrompt(workcell, task), + userContent: task.prompt, + signal, + startTime, + includeErrorBody: true, + }) +} + +/** + * Execute via OpenAI API + */ +async function executeOpenAiApi( + workcell: WorkcellInfo, + task: TaskInput, + signal: AbortSignal, + startTime: number +): Promise<AdapterResult> { + const apiKey = process.env.OPENAI_API_KEY + if (!apiKey) { + return { success: false, output: "", error: "OPENAI_API_KEY not set" } + } + + return callOpenAiApi({ + apiKey, + model: config.model || "gpt-4o", + systemPrompt: buildSystemPrompt(workcell, task), + userContent: task.prompt, + signal, + startTime, + includeErrorBody: true, + }) +} + +export default OpenCodeAdapter diff --git a/apps/terminal/src/dispatcher/index.ts b/apps/terminal/src/dispatcher/index.ts new file mode 100644 index 000000000..ebedf3f9d --- /dev/null +++ b/apps/terminal/src/dispatcher/index.ts @@ -0,0 +1,250 @@ +/** + * Dispatcher - Workcell execution orchestrator + * + * Executes tasks in isolated workcells using native CLI adapters. + * Handles single execution and retry logic. + */ + +import type { + TaskInput, + ExecutionResult, + WorkcellInfo, + Toolchain, +} from "../types" +import * as adaptersModule from "./adapters" +import { git } from "../workcell" + +export interface ExecutionRequest { + task: TaskInput + workcell: WorkcellInfo + toolchain: Toolchain + timeout?: number +} + +export interface AdapterResult { + success: boolean + output: string + error?: string + telemetry?: { + model?: string + tokens?: { input: number; output: number } + cost?: number + startedAt?: number + completedAt?: number + } +} + +export interface Adapter { + info: { + id: string + name: string + description: string + authType: "oauth" | "api_key" | "none" + requiresInstall: boolean + } + isAvailable(): Promise<boolean> + execute( + workcell: WorkcellInfo, + task: TaskInput, + signal: AbortSignal + ): Promise<AdapterResult> + parseTelemetry(output: string): Partial<AdapterResult["telemetry"]> +} + +/** + * Default execution timeout (5 minutes) + */ +const DEFAULT_TIMEOUT = 300000 + +/** + * Sleep utility for retry backoff + */ +function sleep(ms: number): Promise<void> { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +/** + * Dispatcher namespace - Task execution operations + */ +export namespace Dispatcher { + /** + * Execute task in workcell using specified toolchain + */ + export async function execute( + request: ExecutionRequest + ): Promise<ExecutionResult> { + const { task, workcell, toolchain, timeout = DEFAULT_TIMEOUT } = request + const taskId = task.id || crypto.randomUUID() + const startTime = Date.now() + + // Get adapter for toolchain + const adapter = adaptersModule.getAdapter(toolchain) + if (!adapter) { + return { + taskId, + workcellId: workcell.id, + toolchain, + success: false, + output: "", + error: `No adapter found for toolchain: ${toolchain}`, + telemetry: { + startedAt: startTime, + completedAt: Date.now(), + }, + } + } + + // Check if adapter is available + const available = await adapter.isAvailable() + if (!available) { + return { + taskId, + workcellId: workcell.id, + toolchain, + success: false, + output: "", + error: `Adapter ${toolchain} is not available (missing auth or CLI)`, + telemetry: { + startedAt: startTime, + completedAt: Date.now(), + }, + } + } + + // Create abort controller with timeout + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), timeout) + + try { + // Execute via adapter + const result = await adapter.execute(workcell, task, controller.signal) + + clearTimeout(timeoutId) + + // Get patch (diff) if execution succeeded (skip for inplace workcells) + let patch: string | undefined + if (result.success && workcell.name !== "inplace") { + try { + patch = await git.getWorktreeDiff(workcell.directory) + } catch { + // Ignore diff errors + } + } + + return { + taskId, + workcellId: workcell.id, + toolchain, + success: result.success, + patch, + output: result.output, + error: result.error, + telemetry: { + startedAt: result.telemetry?.startedAt || startTime, + completedAt: result.telemetry?.completedAt || Date.now(), + model: result.telemetry?.model, + tokens: result.telemetry?.tokens, + cost: result.telemetry?.cost, + }, + } + } catch (error) { + clearTimeout(timeoutId) + + const errorMessage = + error instanceof Error + ? error.name === "AbortError" + ? `Execution timed out after ${timeout}ms` + : error.message + : String(error) + + return { + taskId, + workcellId: workcell.id, + toolchain, + success: false, + output: "", + error: errorMessage, + telemetry: { + startedAt: startTime, + completedAt: Date.now(), + }, + } + } + } + + /** + * Execute with automatic retry on transient failures + */ + export async function executeWithRetry( + request: ExecutionRequest, + maxRetries: number = 3 + ): Promise<ExecutionResult> { + const backoffMs = 1000 // Starting backoff + + for (let attempt = 0; attempt < maxRetries; attempt++) { + const result = await execute(request) + + if (result.success) { + return result + } + + // Check if error is transient (network issues, rate limits) + const isTransient = isTransientError(result.error) + if (!isTransient || attempt === maxRetries - 1) { + return result + } + + // Exponential backoff before retry + await sleep(backoffMs * Math.pow(2, attempt)) + } + + // Should not reach here, but satisfy TypeScript + return execute(request) + } + + /** + * Get available adapters (those that pass isAvailable check) + */ + export async function getAvailableAdapters(): Promise<Adapter[]> { + return adaptersModule.getAvailableAdapters() + } + + /** + * Get adapter by toolchain ID + */ + export function getAdapter(toolchain: Toolchain): Adapter | undefined { + return adaptersModule.getAdapter(toolchain) + } + + /** + * Get all registered adapters + */ + export function getAllAdapters(): Adapter[] { + return adaptersModule.getAllAdapters() + } +} + +/** + * Check if an error is transient (worth retrying) + */ +function isTransientError(error?: string): boolean { + if (!error) return false + + const transientPatterns = [ + /timeout/i, + /rate.?limit/i, + /too.?many.?requests/i, + /429/, + /503/, + /502/, + /network/i, + /connection/i, + /ECONNRESET/, + /ETIMEDOUT/, + /temporarily/i, + ] + + return transientPatterns.some((pattern) => pattern.test(error)) +} + +export default Dispatcher diff --git a/apps/terminal/src/health/index.ts b/apps/terminal/src/health/index.ts new file mode 100644 index 000000000..ffb1235e5 --- /dev/null +++ b/apps/terminal/src/health/index.ts @@ -0,0 +1,390 @@ +/** + * Health - Integration healthcheck system for ClawdStrike + * + * Provides lightweight, parallel healthchecks for: + * - Security: hushd, hush-cli + * - AI Toolchains: Claude, Codex, OpenCode + * - Infrastructure: Git, Python, Bun + * - MCP Server: ClawdStrike's own MCP server status + */ + +// ============================================================================= +// TYPES +// ============================================================================= + +export interface HealthStatus { + id: string + name: string + category: "security" | "ai" | "infra" | "mcp" + available: boolean + version?: string + latency?: number + error?: string + checkedAt: number +} + +export interface HealthCheckOptions { + force?: boolean + timeout?: number +} + +export interface HealthSummary { + security: HealthStatus[] + ai: HealthStatus[] + infra: HealthStatus[] + mcp: HealthStatus[] + checkedAt: number +} + +// ============================================================================= +// INTEGRATION DEFINITIONS +// ============================================================================= + +interface IntegrationDef { + id: string + name: string + category: "security" | "ai" | "infra" | "mcp" + command?: string + args?: string[] + versionParser?: (output: string) => string | undefined + httpProbe?: string +} + +const INTEGRATIONS: IntegrationDef[] = [ + // Security + { + id: "hushd", + name: "hushd", + category: "security", + httpProbe: "http://127.0.0.1:8080/health", + }, + { + id: "hush-cli", + name: "hush-cli", + category: "security", + command: "clawdstrike", + args: ["--version"], + versionParser: (out) => out.match(/(\d+\.\d+(?:\.\d+)?)/)?.[1], + }, + + // AI Toolchains + { + id: "claude", + name: "Claude", + category: "ai", + command: "claude", + args: ["--version"], + versionParser: (out) => out.match(/(\d+\.\d+(?:\.\d+)?)/)?.[1], + }, + { + id: "codex", + name: "Codex", + category: "ai", + command: "codex", + args: ["--version"], + versionParser: (out) => out.match(/(\d+\.\d+(?:\.\d+)?)/)?.[1], + }, + { + id: "opencode", + name: "OpenCode", + category: "ai", + command: "opencode", + args: ["--version"], + versionParser: (out) => out.match(/(\d+\.\d+(?:\.\d+)?)/)?.[1], + }, + + // Infrastructure + { + id: "git", + name: "Git", + category: "infra", + command: "git", + args: ["--version"], + versionParser: (out) => out.match(/git version (\d+\.\d+(?:\.\d+)?)/)?.[1], + }, + { + id: "python", + name: "Python", + category: "infra", + command: "python3", + args: ["--version"], + versionParser: (out) => out.match(/Python (\d+\.\d+(?:\.\d+)?)/)?.[1], + }, + { + id: "bun", + name: "Bun", + category: "infra", + command: "bun", + args: ["--version"], + versionParser: (out) => out.match(/(\d+\.\d+(?:\.\d+)?)/)?.[1], + }, +] + +// ============================================================================= +// CACHE +// ============================================================================= + +const CACHE_TTL = 60_000 // 60 seconds +let healthCache: Map<string, HealthStatus> = new Map() +let lastFullCheck = 0 + +// MCP server state (set externally) +let mcpServerStatus: { running: boolean; port?: number } = { running: false } + +// ============================================================================= +// HEALTHCHECK FUNCTIONS +// ============================================================================= + +/** + * Check a single integration using HTTP probe or Bun's subprocess + */ +async function checkIntegration( + def: IntegrationDef, + timeout: number +): Promise<HealthStatus> { + const startTime = Date.now() + const result: HealthStatus = { + id: def.id, + name: def.name, + category: def.category, + available: false, + checkedAt: Date.now(), + } + + // HTTP probe check + if (def.httpProbe) { + try { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeout) + const response = await fetch(def.httpProbe, { signal: controller.signal }) + clearTimeout(timer) + result.latency = Date.now() - startTime + result.available = response.ok + if (!response.ok) { + result.error = `HTTP ${response.status}` + } + } catch (err) { + result.latency = Date.now() - startTime + result.available = false + if (err instanceof Error) { + result.error = err.name === "AbortError" ? "timeout" : err.message + } else { + result.error = String(err) + } + } + return result + } + + try { + // Use Bun.spawn with proper timeout handling + const proc = Bun.spawn([def.command!, ...(def.args ?? [])], { + stdout: "pipe", + stderr: "pipe", + }) + + // Race between process completion and timeout + const timeoutPromise = new Promise<"timeout">((resolve) => { + setTimeout(() => resolve("timeout"), timeout) + }) + + const processPromise = (async () => { + const exitCode = await proc.exited + const stdout = await new Response(proc.stdout).text() + const stderr = await new Response(proc.stderr).text() + return { exitCode, stdout, stderr } + })() + + const raceResult = await Promise.race([processPromise, timeoutPromise]) + + result.latency = Date.now() - startTime + + if (raceResult === "timeout") { + proc.kill() + result.available = false + result.error = "timeout" + } else { + const { exitCode, stdout, stderr } = raceResult + const output = stdout || stderr + + if (exitCode === 0 || output.length > 0) { + const version = def.versionParser?.(output) + result.available = true + result.version = version + } else { + result.available = false + result.error = `exit code ${exitCode}` + } + } + } catch (err) { + result.latency = Date.now() - startTime + result.available = false + + if (err instanceof Error) { + // Check for command not found + if (err.message.includes("spawn") || err.message.includes("ENOENT") || err.message.includes("not found")) { + result.error = "not found" + } else { + result.error = err.message + } + } else { + result.error = String(err) + } + } + + return result +} + +/** + * Health namespace - Integration healthchecks + */ +export namespace Health { + /** + * Check all integrations in parallel + */ + export async function checkAll( + options: HealthCheckOptions = {} + ): Promise<HealthSummary> { + const { force = false, timeout = 3000 } = options + const now = Date.now() + + // Return cached if fresh and not forced + if (!force && now - lastFullCheck < CACHE_TTL && healthCache.size > 0) { + return getSummary() + } + + // Check all integrations in parallel + const checks = INTEGRATIONS.map((def) => checkIntegration(def, timeout)) + const results = await Promise.all(checks) + + // Update cache + for (const result of results) { + healthCache.set(result.id, result) + } + lastFullCheck = now + + return getSummary() + } + + /** + * Check a single integration + */ + export async function check( + id: string, + options: HealthCheckOptions = {} + ): Promise<HealthStatus | undefined> { + const { force = false, timeout = 3000 } = options + + // Return cached if fresh and not forced + const cached = healthCache.get(id) + if (!force && cached && Date.now() - cached.checkedAt < CACHE_TTL) { + return cached + } + + // Find integration definition + const def = INTEGRATIONS.find((i) => i.id === id) + if (!def) { + return undefined + } + + // Check and cache + const result = await checkIntegration(def, timeout) + healthCache.set(id, result) + return result + } + + /** + * Get cached health summary (no new checks) + */ + export function getSummary(): HealthSummary { + const summary: HealthSummary = { + security: [], + ai: [], + infra: [], + mcp: [], + checkedAt: lastFullCheck, + } + + // Group by category + for (const status of healthCache.values()) { + if (status.category === "security") { + summary.security.push(status) + } else if (status.category === "ai") { + summary.ai.push(status) + } else if (status.category === "infra") { + summary.infra.push(status) + } + } + + // Add MCP server status + summary.mcp.push({ + id: "clawdstrike-mcp", + name: "ClawdStrike MCP", + category: "mcp", + available: mcpServerStatus.running, + version: mcpServerStatus.port ? `:${mcpServerStatus.port}` : undefined, + checkedAt: Date.now(), + }) + + // Sort each category by id for consistent order + summary.security.sort((a, b) => a.id.localeCompare(b.id)) + summary.ai.sort((a, b) => a.id.localeCompare(b.id)) + summary.infra.sort((a, b) => a.id.localeCompare(b.id)) + + return summary + } + + /** + * Get status for a single integration from cache + */ + export function getStatus(id: string): HealthStatus | undefined { + if (id === "clawdstrike-mcp") { + return { + id: "clawdstrike-mcp", + name: "ClawdStrike MCP", + category: "mcp", + available: mcpServerStatus.running, + version: mcpServerStatus.port ? `:${mcpServerStatus.port}` : undefined, + checkedAt: Date.now(), + } + } + return healthCache.get(id) + } + + /** + * Set MCP server status (called by MCP module) + */ + export function setMcpStatus(running: boolean, port?: number): void { + mcpServerStatus = { running, port } + } + + /** + * Get MCP server status + */ + export function getMcpStatus(): { running: boolean; port?: number } { + return { ...mcpServerStatus } + } + + /** + * Clear the health cache + */ + export function clearCache(): void { + healthCache.clear() + lastFullCheck = 0 + } + + /** + * Get list of all integration IDs + */ + export function getIntegrationIds(): string[] { + return INTEGRATIONS.map((i) => i.id) + } + + /** + * Check if cache is stale + */ + export function isCacheStale(): boolean { + return Date.now() - lastFullCheck > CACHE_TTL + } +} + +export default Health diff --git a/apps/terminal/src/hunt/bridge-correlate.ts b/apps/terminal/src/hunt/bridge-correlate.ts new file mode 100644 index 000000000..84b833120 --- /dev/null +++ b/apps/terminal/src/hunt/bridge-correlate.ts @@ -0,0 +1,40 @@ +// hunt/bridge-correlate.ts - Correlation and watch mode bridge wrapper + +import { runHuntCommand, spawnHuntStream, type HuntStreamHandle } from "./bridge" +import type { Alert, TimelineEvent, WatchJsonLine, WatchStats } from "./types" + +export interface CorrelateOptions { + rules: string[] + since?: string + until?: string +} + +export async function runCorrelate(opts: CorrelateOptions): Promise<Alert[]> { + const args = ["correlate"] + for (const rule of opts.rules) args.push("--rules", rule) + if (opts.since) args.push("--since", opts.since) + if (opts.until) args.push("--until", opts.until) + const result = await runHuntCommand<Alert[]>(args) + return result.data ?? [] +} + +export function startWatch( + rules: string[], + onEvent: (event: TimelineEvent) => void, + onAlert: (alert: Alert) => void, + onStats?: (stats: WatchStats) => void, +): HuntStreamHandle { + const args = ["watch"] + for (const rule of rules) args.push("--rules", rule) + return spawnHuntStream( + args, + (line: WatchJsonLine) => { + if (line.type === "event") onEvent(line.data) + else if (line.type === "alert") onAlert(line.data) + else if (line.type === "stats" && onStats) onStats(line.data) + }, + (error) => { + console.error("Watch error:", error) + }, + ) +} diff --git a/apps/terminal/src/hunt/bridge-ioc.ts b/apps/terminal/src/hunt/bridge-ioc.ts new file mode 100644 index 000000000..4cf404370 --- /dev/null +++ b/apps/terminal/src/hunt/bridge-ioc.ts @@ -0,0 +1,19 @@ +// hunt/bridge-ioc.ts - IOC (Indicators of Compromise) bridge wrapper + +import { runHuntCommand } from "./bridge" +import type { IocMatch } from "./types" + +export interface IocOptions { + feeds: string[] + since?: string + until?: string +} + +export async function runIoc(opts: IocOptions): Promise<IocMatch[]> { + const args = ["ioc"] + for (const feed of opts.feeds) args.push("--feed", feed) + if (opts.since) args.push("--since", opts.since) + if (opts.until) args.push("--until", opts.until) + const result = await runHuntCommand<IocMatch[]>(args) + return result.data ?? [] +} diff --git a/apps/terminal/src/hunt/bridge-query.ts b/apps/terminal/src/hunt/bridge-query.ts new file mode 100644 index 000000000..ce498c914 --- /dev/null +++ b/apps/terminal/src/hunt/bridge-query.ts @@ -0,0 +1,38 @@ +// hunt/bridge-query.ts - Timeline query bridge wrapper + +import { runHuntCommand } from "./bridge" +import type { TimelineEvent, EventSource, NormalizedVerdict } from "./types" + +export interface QueryFilters { + nl?: string + source?: EventSource + verdict?: NormalizedVerdict + kind?: string + since?: string + until?: string + limit?: number +} + +export async function runQuery(filters: QueryFilters): Promise<TimelineEvent[]> { + const args = ["query"] + if (filters.nl) args.push(filters.nl) + if (filters.source) args.push("--source", filters.source) + if (filters.verdict) args.push("--verdict", filters.verdict) + if (filters.kind) args.push("--kind", filters.kind) + if (filters.since) args.push("--since", filters.since) + if (filters.until) args.push("--until", filters.until) + if (filters.limit) args.push("--limit", String(filters.limit)) + const result = await runHuntCommand<TimelineEvent[]>(args) + return result.data ?? [] +} + +export async function runTimeline(filters: QueryFilters): Promise<TimelineEvent[]> { + const args = ["timeline"] + if (filters.source) args.push("--source", filters.source) + if (filters.verdict) args.push("--verdict", filters.verdict) + if (filters.since) args.push("--since", filters.since) + if (filters.until) args.push("--until", filters.until) + if (filters.limit) args.push("--limit", String(filters.limit)) + const result = await runHuntCommand<TimelineEvent[]>(args) + return result.data ?? [] +} diff --git a/apps/terminal/src/hunt/bridge-scan.ts b/apps/terminal/src/hunt/bridge-scan.ts new file mode 100644 index 000000000..edbca1081 --- /dev/null +++ b/apps/terminal/src/hunt/bridge-scan.ts @@ -0,0 +1,35 @@ +// hunt/bridge-scan.ts - MCP scan bridge wrapper + +import { runHuntCommand } from "./bridge" +import type { ScanPathResult, ScanDiff } from "./types" + +export interface ScanOptions { + targets?: string[] + policy?: string + timeout?: number +} + +export async function runScan(opts?: ScanOptions): Promise<ScanPathResult[]> { + const args = ["scan"] + if (opts?.targets) args.push(...opts.targets) + if (opts?.policy) args.push("--policy", opts.policy) + const result = await runHuntCommand<ScanPathResult[]>(args, { + timeout: opts?.timeout, + }) + return result.data ?? [] +} + +export interface DiffOptions { + baseline: string + current?: string + timeout?: number +} + +export async function runScanDiff(opts: DiffOptions): Promise<ScanDiff | undefined> { + const args = ["scan", "diff", "--baseline", opts.baseline] + if (opts.current) args.push("--current", opts.current) + const result = await runHuntCommand<ScanDiff>(args, { + timeout: opts.timeout, + }) + return result.data +} diff --git a/apps/terminal/src/hunt/bridge.ts b/apps/terminal/src/hunt/bridge.ts new file mode 100644 index 000000000..c093d843c --- /dev/null +++ b/apps/terminal/src/hunt/bridge.ts @@ -0,0 +1,167 @@ +// hunt/bridge.ts - Core CLI bridge for clawdstrike hunt subcommands + +import type { WatchJsonLine } from "./types" + +const DEFAULT_TIMEOUT_MS = 30_000 +const HUNT_BINARY = "clawdstrike" + +export interface HuntCommandResult<T> { + ok: boolean + data?: T + error?: string + exitCode: number +} + +export interface HuntStreamHandle { + kill(): void +} + +export interface HuntCommandOptions { + cwd?: string + timeout?: number + env?: Record<string, string> +} + +/** + * Run a clawdstrike hunt subcommand and parse JSON output. + * + * Spawns `clawdstrike hunt <args> --json`, collects stdout, + * parses the result as JSON, and returns a typed result envelope. + */ +export async function runHuntCommand<T>( + args: string[], + opts?: HuntCommandOptions, +): Promise<HuntCommandResult<T>> { + const timeout = opts?.timeout ?? DEFAULT_TIMEOUT_MS + + const proc = Bun.spawn([HUNT_BINARY, "hunt", ...args, "--json"], { + cwd: opts?.cwd, + env: opts?.env ? { ...process.env, ...opts.env } : undefined, + stdout: "pipe", + stderr: "pipe", + }) + + const timer = timeout > 0 + ? setTimeout(() => proc.kill(), timeout) + : undefined + + try { + const [stdoutText, stderrText, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]) + + if (timer) clearTimeout(timer) + + if (exitCode !== 0) { + const errorMessage = stderrText.trim() || `Process exited with code ${exitCode}` + return { ok: false, error: errorMessage, exitCode } + } + + const trimmed = stdoutText.trim() + if (!trimmed) { + return { ok: true, data: undefined, exitCode: 0 } + } + + try { + const data = JSON.parse(trimmed) as T + return { ok: true, data, exitCode: 0 } + } catch { + return { + ok: false, + error: `Failed to parse JSON output: ${trimmed.slice(0, 200)}`, + exitCode: 0, + } + } + } catch (err) { + if (timer) clearTimeout(timer) + const message = err instanceof Error ? err.message : String(err) + return { ok: false, error: message, exitCode: -1 } + } +} + +/** + * Spawn a long-running hunt process (e.g., watch mode) that emits + * newline-delimited JSON (NDJSON) on stdout. + * + * Each line is parsed as JSON and dispatched to onLine. Parse errors + * or process failures are dispatched to onError. + * + * Returns a handle with a kill() method to terminate the process. + */ +export function spawnHuntStream( + args: string[], + onLine: (line: WatchJsonLine) => void, + onError: (error: string) => void, + opts?: HuntCommandOptions, +): HuntStreamHandle { + const proc = Bun.spawn([HUNT_BINARY, "hunt", ...args, "--json"], { + cwd: opts?.cwd, + env: opts?.env ? { ...process.env, ...opts.env } : undefined, + stdout: "pipe", + stderr: "pipe", + }) + + // Read stdout line-by-line in the background + const readLines = async () => { + const reader = proc.stdout.getReader() + const decoder = new TextDecoder() + let buffer = "" + + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split("\n") + // Keep the last (possibly incomplete) chunk in the buffer + buffer = lines.pop() ?? "" + + for (const raw of lines) { + const trimmed = raw.trim() + if (!trimmed) continue + try { + const parsed = JSON.parse(trimmed) as WatchJsonLine + onLine(parsed) + } catch { + onError(`Failed to parse stream line: ${trimmed.slice(0, 200)}`) + } + } + } + + // Flush remaining buffer + const remaining = buffer.trim() + if (remaining) { + try { + const parsed = JSON.parse(remaining) as WatchJsonLine + onLine(parsed) + } catch { + onError(`Failed to parse final stream chunk: ${remaining.slice(0, 200)}`) + } + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + onError(`Stream read error: ${message}`) + } + } + + // Read stderr for error reporting + const readStderr = async () => { + const text = await new Response(proc.stderr).text() + const trimmed = text.trim() + if (trimmed) { + onError(trimmed) + } + } + + readLines() + readStderr() + + return { + kill() { + proc.kill() + }, + } +} diff --git a/apps/terminal/src/hunt/mitre.ts b/apps/terminal/src/hunt/mitre.ts new file mode 100644 index 000000000..43926f1c0 --- /dev/null +++ b/apps/terminal/src/hunt/mitre.ts @@ -0,0 +1,133 @@ +// hunt/mitre.ts - MITRE ATT&CK mapping engine +// +// Maps timeline events to MITRE ATT&CK techniques based on event +// kind/summary pattern matching. Produces a coverage matrix for +// the heatmap screen. + +import type { TimelineEvent } from "./types" + +export interface MitreMapping { + tactic: string + technique_id: string + technique_name: string + pattern: RegExp +} + +export const MITRE_MAPPINGS: MitreMapping[] = [ + // Initial Access + { tactic: "Initial Access", technique_id: "T1190", technique_name: "Exploit Public-Facing Application", pattern: /network_connect|exploit/i }, + { tactic: "Initial Access", technique_id: "T1566", technique_name: "Phishing", pattern: /phish|email.*attach/i }, + // Execution + { tactic: "Execution", technique_id: "T1059", technique_name: "Command and Scripting Interpreter", pattern: /process_exec|shell|bash|cmd|powershell/i }, + { tactic: "Execution", technique_id: "T1204", technique_name: "User Execution", pattern: /user.*exec|click|open/i }, + { tactic: "Execution", technique_id: "T1106", technique_name: "Native API", pattern: /mcp.*tool|api.*call/i }, + // Persistence + { tactic: "Persistence", technique_id: "T1547", technique_name: "Boot or Logon Autostart", pattern: /autostart|startup|cron|systemd/i }, + { tactic: "Persistence", technique_id: "T1053", technique_name: "Scheduled Task/Job", pattern: /cron|schedule|task.*sched/i }, + // Privilege Escalation + { tactic: "Privilege Escalation", technique_id: "T1548", technique_name: "Abuse Elevation Control", pattern: /sudo|setuid|elevation|privilege/i }, + // Defense Evasion + { tactic: "Defense Evasion", technique_id: "T1070", technique_name: "Indicator Removal", pattern: /delete.*log|clear.*history|file_delete.*log/i }, + { tactic: "Defense Evasion", technique_id: "T1036", technique_name: "Masquerading", pattern: /masquerad|rename.*proc/i }, + { tactic: "Defense Evasion", technique_id: "T1562", technique_name: "Impair Defenses", pattern: /policy_violation|guard.*bypass|deny.*override/i }, + // Credential Access + { tactic: "Credential Access", technique_id: "T1003", technique_name: "OS Credential Dumping", pattern: /credential|passwd|shadow|secret/i }, + { tactic: "Credential Access", technique_id: "T1552", technique_name: "Unsecured Credentials", pattern: /\.env|api.key|token|secret.*leak/i }, + // Discovery + { tactic: "Discovery", technique_id: "T1083", technique_name: "File and Directory Discovery", pattern: /file_open.*\/etc|ls.*-la|find.*\//i }, + { tactic: "Discovery", technique_id: "T1046", technique_name: "Network Service Discovery", pattern: /network.*scan|port.*scan|nmap/i }, + // Lateral Movement + { tactic: "Lateral Movement", technique_id: "T1021", technique_name: "Remote Services", pattern: /ssh|rdp|remote.*desktop/i }, + // Collection + { tactic: "Collection", technique_id: "T1005", technique_name: "Data from Local System", pattern: /file_open.*sensitive|read.*config/i }, + { tactic: "Collection", technique_id: "T1074", technique_name: "Data Staged", pattern: /stage|collect.*data|archive/i }, + // Exfiltration + { tactic: "Exfiltration", technique_id: "T1048", technique_name: "Exfiltration Over Alternative Protocol", pattern: /exfil|dns.*tunnel|upload.*data/i }, + { tactic: "Exfiltration", technique_id: "T1041", technique_name: "Exfiltration Over C2 Channel", pattern: /c2|command.*control|beacon/i }, + // Command and Control + { tactic: "Command and Control", technique_id: "T1071", technique_name: "Application Layer Protocol", pattern: /http.*beacon|dns.*c2|covert.*channel/i }, + // Impact + { tactic: "Impact", technique_id: "T1485", technique_name: "Data Destruction", pattern: /rm\s+-rf|delete.*all|destroy|wipe/i }, + { tactic: "Impact", technique_id: "T1486", technique_name: "Data Encrypted for Impact", pattern: /encrypt|ransom/i }, +] + +/** Ordered unique list of tactics (kill-chain order) */ +export const TACTICS: string[] = [ + "Initial Access", + "Execution", + "Persistence", + "Privilege Escalation", + "Defense Evasion", + "Credential Access", + "Discovery", + "Lateral Movement", + "Collection", + "Exfiltration", + "Command and Control", + "Impact", +] + +/** Ordered unique list of technique IDs */ +export const TECHNIQUES: string[] = (() => { + const seen = new Set<string>() + const result: string[] = [] + for (const m of MITRE_MAPPINGS) { + if (!seen.has(m.technique_id)) { + seen.add(m.technique_id) + result.push(m.technique_id) + } + } + return result +})() + +export interface CoverageMatrix { + tactics: string[] + techniques: Array<{ id: string; name: string; tactic: string }> + matrix: number[][] // [technique_idx][tactic_idx] = hit count + eventsByTechnique: Map<string, TimelineEvent[]> +} + +export function buildCoverageMatrix(events: TimelineEvent[]): CoverageMatrix { + const tacticIndex = new Map<string, number>() + for (let i = 0; i < TACTICS.length; i++) { + tacticIndex.set(TACTICS[i], i) + } + + // Build technique metadata + const techniques: Array<{ id: string; name: string; tactic: string }> = [] + const techIdToIdx = new Map<string, number>() + const seen = new Set<string>() + for (const m of MITRE_MAPPINGS) { + if (!seen.has(m.technique_id)) { + seen.add(m.technique_id) + techIdToIdx.set(m.technique_id, techniques.length) + techniques.push({ id: m.technique_id, name: m.technique_name, tactic: m.tactic }) + } + } + + // Initialize matrix: rows = techniques, cols = tactics + const matrix: number[][] = [] + for (let t = 0; t < techniques.length; t++) { + matrix.push(new Array(TACTICS.length).fill(0)) + } + + const eventsByTechnique = new Map<string, TimelineEvent[]>() + + for (const evt of events) { + const matchStr = `${evt.kind} ${evt.summary}` + for (const mapping of MITRE_MAPPINGS) { + if (mapping.pattern.test(matchStr)) { + const tIdx = techIdToIdx.get(mapping.technique_id) + const cIdx = tacticIndex.get(mapping.tactic) + if (tIdx !== undefined && cIdx !== undefined) { + matrix[tIdx][cIdx]++ + const existing = eventsByTechnique.get(mapping.technique_id) ?? [] + existing.push(evt) + eventsByTechnique.set(mapping.technique_id, existing) + } + } + } + } + + return { tactics: TACTICS, techniques, matrix, eventsByTechnique } +} diff --git a/apps/terminal/src/hunt/playbook.ts b/apps/terminal/src/hunt/playbook.ts new file mode 100644 index 000000000..774a0526b --- /dev/null +++ b/apps/terminal/src/hunt/playbook.ts @@ -0,0 +1,93 @@ +// hunt/playbook.ts - Playbook builder and executor + +import type { PlaybookStep, PlaybookResult, TimelineEvent, Alert } from "./types" +import { runTimeline } from "./bridge-query" +import { runCorrelate } from "./bridge-correlate" +import { runIoc } from "./bridge-ioc" + +export interface PlaybookConfig { + name: string + description?: string + timeRange?: string // e.g., "24h" + rules?: string[] // Rule file paths + iocFeeds?: string[] // IOC feed paths +} + +export function buildDefaultPlaybook(_config: PlaybookConfig): PlaybookStep[] { + return [ + { name: "Query Events", description: "Fetch timeline events", command: "hunt", args: ["timeline"], status: "pending" }, + { name: "Filter & Analyze", description: "Filter events by severity", command: "hunt", args: ["query"], status: "pending" }, + { name: "Correlate", description: "Run correlation rules", command: "hunt", args: ["correlate"], status: "pending" }, + { name: "IOC Match", description: "Match against IOC feeds", command: "hunt", args: ["ioc"], status: "pending" }, + { name: "Generate Report", description: "Build evidence report", command: "report", args: [], status: "pending" }, + ] +} + +export async function executePlaybook( + config: PlaybookConfig, + steps: PlaybookStep[], + onStepUpdate: (index: number, step: PlaybookStep) => void, +): Promise<PlaybookResult> { + const started_at = new Date().toISOString() + let events: TimelineEvent[] = [] + let alerts: Alert[] = [] + let success = true + + for (let i = 0; i < steps.length; i++) { + const step: PlaybookStep = { ...steps[i], status: "running" } + onStepUpdate(i, step) + + const startTime = Date.now() + try { + switch (i) { + case 0: { // Query timeline + events = await runTimeline({ since: config.timeRange }) + step.output = { eventCount: events.length } + break + } + case 1: { // Filter + const denied = events.filter(e => e.verdict === "deny") + step.output = { filtered: denied.length, total: events.length } + events = denied.length > 0 ? denied : events + break + } + case 2: { // Correlate + if (config.rules && config.rules.length > 0) { + alerts = await runCorrelate({ rules: config.rules }) + } + step.output = { alertCount: alerts.length } + break + } + case 3: { // IOC + if (config.iocFeeds && config.iocFeeds.length > 0) { + const matches = await runIoc({ feeds: config.iocFeeds }) + step.output = { matchCount: matches.length } + } else { + step.output = { matchCount: 0, skipped: true } + } + break + } + case 4: { // Report + step.output = { events: events.length, alerts: alerts.length } + break + } + } + step.status = "passed" + step.duration_ms = Date.now() - startTime + } catch (err) { + step.status = "failed" + step.error = err instanceof Error ? err.message : String(err) + step.duration_ms = Date.now() - startTime + success = false + } + onStepUpdate(i, step) + } + + return { + name: config.name, + steps, + started_at, + completed_at: new Date().toISOString(), + success, + } +} diff --git a/apps/terminal/src/hunt/types.ts b/apps/terminal/src/hunt/types.ts new file mode 100644 index 000000000..8cc5d5e6d --- /dev/null +++ b/apps/terminal/src/hunt/types.ts @@ -0,0 +1,249 @@ +// hunt/types.ts - TypeScript types mirroring Rust hunt CLI JSON output + +// --- Event Sources --- + +export type EventSource = "tetragon" | "hubble" | "receipt" | "spine" + +// --- Timeline --- + +export type TimelineEventKind = + | "process_exec" + | "process_exit" + | "file_open" + | "file_write" + | "file_delete" + | "network_connect" + | "network_accept" + | "network_dns" + | "policy_check" + | "policy_violation" + | "checkpoint" + | "attestation" + | "unknown" + +export type NormalizedVerdict = "allow" | "deny" | "audit" | "unknown" + +export interface TimelineEvent { + timestamp: string + source: EventSource + kind: TimelineEventKind + verdict: NormalizedVerdict + summary: string + details: Record<string, unknown> + raw?: unknown +} + +// --- Correlation Rules & Alerts --- + +export type RuleSeverity = "low" | "medium" | "high" | "critical" + +export interface RuleCondition { + source?: EventSource + kind?: TimelineEventKind + verdict?: NormalizedVerdict + pattern?: string + field?: string + value?: string +} + +export interface RuleOutput { + title: string + severity: RuleSeverity + description?: string + mitre_attack?: string[] + evidence_fields?: string[] +} + +export interface CorrelationRule { + name: string + description?: string + severity: RuleSeverity + window_seconds: number + conditions: RuleCondition[] + min_count?: number + output: RuleOutput +} + +export interface Alert { + rule: string + severity: RuleSeverity + timestamp: string + title: string + description?: string + matched_events: TimelineEvent[] + evidence: Record<string, unknown> + mitre_attack?: string[] +} + +// --- MCP Scan --- + +export interface Tool { + name: string + description?: string + input_schema?: Record<string, unknown> +} + +export interface ServerSignature { + name: string + version?: string + tools: Tool[] + prompts: string[] + resources: string[] +} + +export type IssueSeverity = "info" | "warning" | "error" | "critical" + +export interface Issue { + severity: IssueSeverity + code: string + message: string + detail?: string +} + +export interface PolicyViolation { + guard: string + action_type: string + target: string + decision: "deny" + reason?: string +} + +export interface ScanError { + path: string + error: string +} + +export interface ServerScanResult { + name: string + command: string + args?: string[] + env?: Record<string, string> + signature?: ServerSignature + issues: Issue[] + violations: PolicyViolation[] + error?: string +} + +export interface ScanPathResult { + path: string + client: string + servers: ServerScanResult[] + errors: ScanError[] +} + +// --- Scan Diff --- + +export type ChangeKind = "added" | "removed" | "modified" + +export interface ServerChange { + server_name: string + kind: ChangeKind + old?: ServerScanResult + new?: ServerScanResult + tool_changes?: { added: string[]; removed: string[] } +} + +export interface ScanDiff { + timestamp: string + previous_timestamp?: string + changes: ServerChange[] + summary: { added: number; removed: number; modified: number } +} + +// --- IOC (Indicators of Compromise) --- + +export type IocType = + | "ip" + | "domain" + | "hash" + | "url" + | "email" + | "file_path" + | "command" + +export interface IocEntry { + type: IocType + value: string + source?: string + severity?: RuleSeverity + tags?: string[] +} + +export interface IocMatch { + ioc: IocEntry + event: TimelineEvent + context?: string +} + +// --- Evidence & Reports --- + +export interface EvidenceItem { + index: number + event: TimelineEvent + relevance: string + merkle_proof?: string[] +} + +export interface HuntReport { + id: string + title: string + severity: RuleSeverity + created_at: string + alert: Alert + evidence: EvidenceItem[] + merkle_root?: string + summary: string + recommendations?: string[] +} + +// --- Watch Mode --- + +export interface WatchStats { + events_processed: number + alerts_fired: number + uptime_seconds: number + active_rules: number +} + +export type WatchJsonLine = + | { type: "event"; data: TimelineEvent } + | { type: "alert"; data: Alert } + | { type: "stats"; data: WatchStats } + +// --- MITRE ATT&CK --- + +export interface MitreTechnique { + id: string + name: string + tactic: string + description?: string +} + +// --- Playbook --- + +export type PlaybookStepStatus = + | "pending" + | "running" + | "passed" + | "failed" + | "skipped" + +export interface PlaybookStep { + name: string + description: string + command: string + args: string[] + status: PlaybookStepStatus + output?: unknown + error?: string + duration_ms?: number +} + +export interface PlaybookResult { + name: string + steps: PlaybookStep[] + started_at: string + completed_at?: string + success: boolean + report?: HuntReport +} diff --git a/apps/terminal/src/hushd/client.ts b/apps/terminal/src/hushd/client.ts new file mode 100644 index 000000000..365a7d716 --- /dev/null +++ b/apps/terminal/src/hushd/client.ts @@ -0,0 +1,259 @@ +/** + * Hushd Client - HTTP + SSE client for the hushd daemon + * + * Lightweight client using native fetch (no dependencies). + * All methods return null on connectivity errors (never throw). + * SSE uses manual fetch with streaming body (Bun lacks native EventSource). + */ + +import type { + CheckRequest, + CheckResponse, + PolicyResponse, + AuditQuery, + AuditResponse, + AuditStats, + DaemonEvent, +} from "./types" + +const DEFAULT_TIMEOUT = 5000 + +export class HushdClient { + private baseUrl: string + private token?: string + private sseController: AbortController | null = null + + constructor(baseUrl: string, token?: string) { + // Strip trailing slash + this.baseUrl = baseUrl.replace(/\/$/, "") + this.token = token + } + + // =========================================================================== + // HEADERS + // =========================================================================== + + private headers(): Record<string, string> { + const h: Record<string, string> = { + "Content-Type": "application/json", + } + if (this.token) { + h["Authorization"] = `Bearer ${this.token}` + } + return h + } + + // =========================================================================== + // HEALTH + // =========================================================================== + + /** + * Probe hushd health endpoint. Returns true if daemon is reachable. + */ + async probe(timeoutMs?: number): Promise<boolean> { + try { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), timeoutMs ?? DEFAULT_TIMEOUT) + const res = await fetch(`${this.baseUrl}/health`, { + signal: controller.signal, + }) + clearTimeout(timeout) + return res.ok + } catch { + return false + } + } + + // =========================================================================== + // CHECK API + // =========================================================================== + + /** + * Submit an action for policy check. Returns null on connectivity error. + */ + async check(req: CheckRequest): Promise<CheckResponse | null> { + try { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT) + const res = await fetch(`${this.baseUrl}/api/v1/check`, { + method: "POST", + headers: this.headers(), + body: JSON.stringify(req), + signal: controller.signal, + }) + clearTimeout(timeout) + if (!res.ok) return null + return (await res.json()) as CheckResponse + } catch { + return null + } + } + + // =========================================================================== + // POLICY API + // =========================================================================== + + /** + * Get active policy configuration. Returns null on connectivity error. + */ + async getPolicy(): Promise<PolicyResponse | null> { + try { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT) + const res = await fetch(`${this.baseUrl}/api/v1/policy`, { + headers: this.headers(), + signal: controller.signal, + }) + clearTimeout(timeout) + if (!res.ok) return null + return (await res.json()) as PolicyResponse + } catch { + return null + } + } + + // =========================================================================== + // AUDIT API + // =========================================================================== + + /** + * Query audit log. Returns null on connectivity error. + */ + async getAudit(query?: AuditQuery): Promise<AuditResponse | null> { + try { + const params = new URLSearchParams() + if (query) { + if (query.limit !== undefined) params.set("limit", String(query.limit)) + if (query.offset !== undefined) params.set("offset", String(query.offset)) + if (query.action_type) params.set("action_type", query.action_type) + if (query.decision) params.set("decision", query.decision) + if (query.guard) params.set("guard", query.guard) + if (query.since) params.set("since", query.since) + if (query.until) params.set("until", query.until) + } + + const qs = params.toString() + const url = `${this.baseUrl}/api/v1/audit${qs ? `?${qs}` : ""}` + + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT) + const res = await fetch(url, { + headers: this.headers(), + signal: controller.signal, + }) + clearTimeout(timeout) + if (!res.ok) return null + return (await res.json()) as AuditResponse + } catch { + return null + } + } + + /** + * Get audit statistics. Returns null on connectivity error. + */ + async getAuditStats(): Promise<AuditStats | null> { + try { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT) + const res = await fetch(`${this.baseUrl}/api/v1/audit/stats`, { + headers: this.headers(), + signal: controller.signal, + }) + clearTimeout(timeout) + if (!res.ok) return null + return (await res.json()) as AuditStats + } catch { + return null + } + } + + // =========================================================================== + // SSE (Server-Sent Events) + // =========================================================================== + + /** + * Connect to hushd SSE event stream. + * Uses manual fetch with streaming body (Bun lacks native EventSource). + */ + connectSSE( + onEvent: (e: DaemonEvent) => void, + onError?: (err: Error) => void + ): void { + // Disconnect existing connection first + this.disconnectSSE() + + this.sseController = new AbortController() + const signal = this.sseController.signal + + const connect = async () => { + try { + const res = await fetch(`${this.baseUrl}/api/v1/events`, { + headers: { + Accept: "text/event-stream", + ...(this.token ? { Authorization: `Bearer ${this.token}` } : {}), + }, + signal, + }) + + if (!res.ok || !res.body) { + onError?.(new Error(`SSE connection failed: ${res.status}`)) + return + } + + const reader = res.body.getReader() + const decoder = new TextDecoder() + let buffer = "" + + while (true) { + const { done, value } = await reader.read() + if (done) break + + buffer += decoder.decode(value, { stream: true }) + + // Parse SSE frames + const lines = buffer.split("\n") + buffer = lines.pop() ?? "" + + let eventData = "" + for (const line of lines) { + if (line.startsWith("data: ")) { + eventData += line.slice(6) + } else if (line === "" && eventData) { + // End of event + try { + const event = JSON.parse(eventData) as DaemonEvent + onEvent(event) + } catch { + // Skip malformed events + } + eventData = "" + } + } + } + } catch (err) { + if (signal.aborted) return // Expected disconnect + onError?.(err instanceof Error ? err : new Error(String(err))) + } + } + + connect() + } + + /** + * Disconnect from SSE event stream. + */ + disconnectSSE(): void { + if (this.sseController) { + this.sseController.abort() + this.sseController = null + } + } + + /** + * Check if SSE is currently connected. + */ + isSSEConnected(): boolean { + return this.sseController !== null && !this.sseController.signal.aborted + } +} diff --git a/apps/terminal/src/hushd/index.ts b/apps/terminal/src/hushd/index.ts new file mode 100644 index 000000000..6763ac30a --- /dev/null +++ b/apps/terminal/src/hushd/index.ts @@ -0,0 +1,76 @@ +/** + * Hushd - Client module for the hushd security daemon + * + * Provides init/getClient/reset lifecycle for the hushd HTTP client. + * Default URL: http://127.0.0.1:8080, override via CLAWDSTRIKE_HUSHD_URL. + */ + +import { HushdClient } from "./client" + +export type { HushdClient } from "./client" +export type { + CheckRequest, + CheckResponse, + GuardResult, + PostureInfo, + AuditQuery, + AuditEvent, + AuditResponse, + AuditStats, + PolicyResponse, + PolicyGuardConfig, + DaemonEvent, + CheckEventData, + PolicyReloadData, + ErrorData, + HushdConnectionState, +} from "./types" + +const DEFAULT_URL = "http://127.0.0.1:8080" + +let client: HushdClient | null = null + +/** + * Hushd namespace - Security daemon client lifecycle + */ +export namespace Hushd { + /** + * Initialize the hushd client. + * Uses CLAWDSTRIKE_HUSHD_URL env var or default (http://127.0.0.1:8080). + */ + export function init(options?: { url?: string; token?: string }): void { + const url = options?.url ?? process.env.CLAWDSTRIKE_HUSHD_URL ?? DEFAULT_URL + const token = options?.token ?? process.env.CLAWDSTRIKE_HUSHD_TOKEN + client = new HushdClient(url, token) + } + + /** + * Get the hushd client instance. + * Initializes with defaults if not already initialized. + */ + export function getClient(): HushdClient { + if (!client) { + init() + } + return client! + } + + /** + * Reset the hushd client (disconnect SSE, clear instance). + */ + export function reset(): void { + if (client) { + client.disconnectSSE() + client = null + } + } + + /** + * Check if hushd client is initialized. + */ + export function isInitialized(): boolean { + return client !== null + } +} + +export default Hushd diff --git a/apps/terminal/src/hushd/types.ts b/apps/terminal/src/hushd/types.ts new file mode 100644 index 000000000..f8de01040 --- /dev/null +++ b/apps/terminal/src/hushd/types.ts @@ -0,0 +1,143 @@ +/** + * Hushd Types - TypeScript interfaces for hushd API + * + * Mirrors the hushd daemon's HTTP API response shapes. + */ + +// ============================================================================= +// CHECK API +// ============================================================================= + +export interface CheckRequest { + action_type: "file" | "network" | "shell" | "patch" | "mcp_tool" + target: string + context?: Record<string, unknown> + metadata?: Record<string, string> +} + +export interface GuardResult { + guard: string + decision: "allow" | "deny" + reason?: string + severity?: "info" | "warning" | "error" | "critical" + evidence?: Record<string, unknown> +} + +export interface CheckResponse { + decision: "allow" | "deny" + policy: string + policy_version: string + guards: GuardResult[] + receipt_id?: string + timestamp: string +} + +// ============================================================================= +// POSTURE API +// ============================================================================= + +export interface PostureInfo { + policy: string + policy_version: string + policy_hash: string + guards: string[] + loaded_at: string +} + +// ============================================================================= +// AUDIT API +// ============================================================================= + +export interface AuditQuery { + limit?: number + offset?: number + action_type?: string + decision?: "allow" | "deny" + guard?: string + since?: string + until?: string +} + +export interface AuditEvent { + id: string + timestamp: string + action_type: string + target: string + decision: "allow" | "deny" + guard: string + severity: "info" | "warning" | "error" | "critical" + reason?: string + receipt_id?: string +} + +export interface AuditResponse { + events: AuditEvent[] + total: number + offset: number + limit: number +} + +export interface AuditStats { + total_checks: number + allowed: number + denied: number + by_guard: Record<string, { allowed: number; denied: number }> + by_action_type: Record<string, { allowed: number; denied: number }> + since: string +} + +// ============================================================================= +// POLICY API +// ============================================================================= + +export interface PolicyResponse { + name: string + version: string + hash: string + schema_version: string + guards: PolicyGuardConfig[] + extends?: string[] + loaded_at: string +} + +export interface PolicyGuardConfig { + id: string + enabled: boolean + config?: Record<string, unknown> +} + +// ============================================================================= +// SSE EVENTS +// ============================================================================= + +export interface DaemonEvent { + type: "check" | "policy_reload" | "error" + timestamp: string + data: CheckEventData | PolicyReloadData | ErrorData +} + +export interface CheckEventData { + action_type: string + target: string + decision: "allow" | "deny" + guard: string + severity: "info" | "warning" | "error" | "critical" + reason?: string +} + +export interface PolicyReloadData { + policy: string + version: string + guards: string[] +} + +export interface ErrorData { + message: string + code?: string +} + +// ============================================================================= +// CONNECTION STATE +// ============================================================================= + +export type HushdConnectionState = "disconnected" | "connecting" | "connected" | "error" diff --git a/apps/terminal/src/index.ts b/apps/terminal/src/index.ts new file mode 100644 index 000000000..6a163c45e --- /dev/null +++ b/apps/terminal/src/index.ts @@ -0,0 +1,165 @@ +/** + * clawdstrike - Security-Aware AI Coding Agent Orchestrator + * + * A security-aware orchestration engine that dispatches coding tasks to native AI CLIs + * (Codex, Claude Code, OpenCode) while preserving subscription authentication. + * + * Features: + * - Intelligent task routing based on risk, size, and task characteristics + * - Speculate+Vote: parallel multi-agent execution for high-stakes tasks + * - Quality gates (pytest, mypy, ruff) with fail-fast semantics + * - Beads work graph integration for issue tracking + * - Git worktree isolation for safe concurrent execution + * + * @module clawdstrike + */ + +// Core types +export * from "./types" + +// Namespace modules +export { Router, type RouterConfig, type RoutingRule } from "./router" +export { + Dispatcher, + type ExecutionRequest, + type Adapter, + type AdapterResult, +} from "./dispatcher" +export { + Workcell, + PoolConfig, + type PoolStatus, + type GCResult, +} from "./workcell" +export { Verifier, type Gate, type GateConfig, type VerifyOptions } from "./verifier" +export { Speculate, type SpeculateOptions, type VoteInput } from "./speculate" +export { + PatchLifecycle, + type CaptureOptions, + type MergeOptions, + type MergeResult, +} from "./patch" +export { + Beads, + JSONL, + type BeadsConfig, + type QueryOptions, + type ReadyIssue, +} from "./beads" +export { Telemetry, type TelemetryConfig, type AnalyticsEvent } from "./telemetry" +export { TUI } from "./tui" +export { Health, type HealthStatus, type HealthSummary, type HealthCheckOptions } from "./health" +export { MCP } from "./mcp" +export { Hushd, type HushdClient } from "./hushd" +export { Config, type ProjectConfig, type DetectionResult } from "./config" + +// Tools +export { + tools, + getTool, + registerTools, + executeTool, + dispatchTool, + speculateTool, + gateTool, + type ToolDefinition, + type ToolContext, + type DispatchParams, + type DispatchResult, + type SpeculateParams, + type SpeculateToolResult, + type GateParams, + type GateToolResult, +} from "./tools" + +/** + * clawdstrike version + */ +export const VERSION = "0.1.0" + +/** + * clawdstrike initialization options + */ +export interface InitOptions { + beadsPath?: string + telemetryDir?: string + telemetryEnabled?: boolean + poolConfig?: Partial<import("./workcell").PoolConfig> +} + +// Module state +let initialized = false + +/** + * Initialize clawdstrike with configuration + */ +export async function init(options: InitOptions = {}): Promise<void> { + if (initialized) { + return + } + + const { + beadsPath = ".beads", + telemetryDir = ".clawdstrike/runs", + telemetryEnabled = true, + } = options + + // Initialize Beads + const { Beads } = await import("./beads") + await Beads.init({ path: beadsPath }) + + // Initialize Telemetry + const { Telemetry } = await import("./telemetry") + Telemetry.init({ + outputDir: telemetryDir, + enabled: telemetryEnabled, + }) + + // Initialize Hushd client + const { Hushd } = await import("./hushd") + Hushd.init() + + initialized = true +} + +/** + * Shutdown clawdstrike cleanly + */ +export async function shutdown(): Promise<void> { + if (!initialized) { + return + } + + // Stop MCP server + const { MCP } = await import("./mcp") + await MCP.stop() + + // Reset Beads + const { Beads } = await import("./beads") + Beads.reset() + + // Reset Telemetry + const { Telemetry } = await import("./telemetry") + Telemetry.reset() + + // Destroy all workcells + const { Workcell } = await import("./workcell") + await Workcell.destroyAll() + + // Clear health cache + const { Health } = await import("./health") + Health.clearCache() + + // Reset Hushd + const { Hushd } = await import("./hushd") + Hushd.reset() + + initialized = false +} + +/** + * Check if clawdstrike is initialized + */ +export function isInitialized(): boolean { + return initialized +} diff --git a/apps/terminal/src/mcp/index.ts b/apps/terminal/src/mcp/index.ts new file mode 100644 index 000000000..7e541a3f8 --- /dev/null +++ b/apps/terminal/src/mcp/index.ts @@ -0,0 +1,558 @@ +/** + * MCP Server - Model Context Protocol server for ClawdStrike + * + * Exposes ClawdStrike tools (dispatch, speculate, gate) over MCP protocol. + * Also supports connecting to external MCP servers (bidirectional hub). + * + * Discovery: Writes port to `.clawdstrike/mcp.json` for other tools to find. + */ + +import { createServer, type Server, Socket } from "node:net" +import * as fs from "node:fs/promises" +import * as path from "node:path" +import { tools, type ToolContext } from "../tools" +import { Health } from "../health" + +// ============================================================================= +// TYPES +// ============================================================================= + +interface JsonRpcRequest { + jsonrpc: "2.0" + id: string | number + method: string + params?: unknown +} + +interface JsonRpcResponse { + jsonrpc: "2.0" + id: string | number + result?: unknown + error?: { + code: number + message: string + data?: unknown + } +} + +interface McpServerConfig { + port?: number + host?: string + cwd?: string + projectId?: string +} + +interface McpDiscovery { + port: number + host: string + pid: number + startedAt: string + tools: string[] +} + +interface ConnectedClient { + id: string + socket: Socket + connectedAt: number +} + +// ============================================================================= +// MCP SERVER +// ============================================================================= + +class McpServerImpl { + private server: Server | null = null + private clients: Map<string, ConnectedClient> = new Map() + private config: McpServerConfig = {} + private discoveryPath: string = "" + + /** + * Start the MCP server + */ + async start(config: McpServerConfig = {}): Promise<number> { + if (this.server) { + throw new Error("MCP server already running") + } + + this.config = config + const host = config.host ?? "127.0.0.1" + const preferredPort = config.port ?? 0 // 0 = auto-assign + + return new Promise((resolve, reject) => { + this.server = createServer((socket) => this.handleConnection(socket)) + + this.server.on("error", (err) => { + reject(err) + }) + + this.server.listen(preferredPort, host, async () => { + const address = this.server?.address() + if (!address || typeof address === "string") { + reject(new Error("Failed to get server address")) + return + } + + const port = address.port + + // Write discovery file + await this.writeDiscovery(port, host) + + // Update health status + Health.setMcpStatus(true, port) + + resolve(port) + }) + }) + } + + /** + * Stop the MCP server + */ + async stop(): Promise<void> { + if (!this.server) { + return + } + + // Close all client connections + for (const client of this.clients.values()) { + client.socket.destroy() + } + this.clients.clear() + + // Close server + return new Promise((resolve, reject) => { + this.server?.close((err) => { + if (err) { + reject(err) + } else { + this.server = null + Health.setMcpStatus(false) + this.removeDiscovery().then(resolve).catch(resolve) // ignore cleanup errors + } + }) + }) + } + + /** + * Check if server is running + */ + isRunning(): boolean { + return this.server !== null + } + + /** + * Get server port (or undefined if not running) + */ + getPort(): number | undefined { + const address = this.server?.address() + if (address && typeof address !== "string") { + return address.port + } + return undefined + } + + /** + * Get connected client count + */ + getClientCount(): number { + return this.clients.size + } + + /** + * Handle new client connection + */ + private handleConnection(socket: Socket): void { + const clientId = `${socket.remoteAddress}:${socket.remotePort}-${Date.now()}` + const client: ConnectedClient = { + id: clientId, + socket, + connectedAt: Date.now(), + } + + this.clients.set(clientId, client) + + let buffer = "" + + socket.on("data", async (data) => { + buffer += data.toString() + + // Process complete JSON-RPC messages (newline-delimited) + const lines = buffer.split("\n") + buffer = lines.pop() ?? "" // Keep incomplete line in buffer + + for (const line of lines) { + if (line.trim()) { + try { + const request = JSON.parse(line) as JsonRpcRequest + const response = await this.handleRequest(request) + socket.write(JSON.stringify(response) + "\n") + } catch (err) { + const errorResponse: JsonRpcResponse = { + jsonrpc: "2.0", + id: 0, + error: { + code: -32700, + message: "Parse error", + data: err instanceof Error ? err.message : String(err), + }, + } + socket.write(JSON.stringify(errorResponse) + "\n") + } + } + } + }) + + socket.on("close", () => { + this.clients.delete(clientId) + }) + + socket.on("error", () => { + this.clients.delete(clientId) + }) + } + + /** + * Handle JSON-RPC request + */ + private async handleRequest(request: JsonRpcRequest): Promise<JsonRpcResponse> { + const { id, method, params } = request + + try { + switch (method) { + case "initialize": + return { + jsonrpc: "2.0", + id, + result: { + protocolVersion: "2024-11-05", + serverInfo: { + name: "clawdstrike", + version: "0.1.0", + }, + capabilities: { + tools: {}, + }, + }, + } + + case "tools/list": + return { + jsonrpc: "2.0", + id, + result: { + tools: tools.map((t) => ({ + name: t.name, + description: t.description, + inputSchema: t.parameters, + })), + }, + } + + case "tools/call": { + const callParams = params as { name: string; arguments?: unknown } + const tool = tools.find((t) => t.name === callParams.name) + + if (!tool) { + return { + jsonrpc: "2.0", + id, + error: { + code: -32602, + message: `Unknown tool: ${callParams.name}`, + }, + } + } + + const context: ToolContext = { + cwd: this.config.cwd ?? process.cwd(), + projectId: this.config.projectId ?? "default", + } + + const result = await tool.handler(callParams.arguments ?? {}, context) + + return { + jsonrpc: "2.0", + id, + result: { + content: [ + { + type: "text", + text: JSON.stringify(result, null, 2), + }, + ], + }, + } + } + + case "ping": + return { + jsonrpc: "2.0", + id, + result: { pong: true }, + } + + default: + return { + jsonrpc: "2.0", + id, + error: { + code: -32601, + message: `Method not found: ${method}`, + }, + } + } + } catch (err) { + return { + jsonrpc: "2.0", + id, + error: { + code: -32603, + message: err instanceof Error ? err.message : String(err), + }, + } + } + } + + /** + * Write discovery file + */ + private async writeDiscovery(port: number, host: string): Promise<void> { + const cwd = this.config.cwd ?? process.cwd() + const dir = path.join(cwd, ".clawdstrike") + this.discoveryPath = path.join(dir, "mcp.json") + + await fs.mkdir(dir, { recursive: true }) + + const discovery: McpDiscovery = { + port, + host, + pid: process.pid, + startedAt: new Date().toISOString(), + tools: tools.map((t) => t.name), + } + + await fs.writeFile(this.discoveryPath, JSON.stringify(discovery, null, 2)) + } + + /** + * Remove discovery file + */ + private async removeDiscovery(): Promise<void> { + if (this.discoveryPath) { + try { + await fs.unlink(this.discoveryPath) + } catch { + // Ignore errors + } + } + } +} + +// ============================================================================= +// MCP CLIENT (for connecting to external servers) +// ============================================================================= + +interface ExternalMcpServer { + id: string + host: string + port: number + socket?: Socket + connected: boolean + tools: string[] +} + +class McpClientManager { + private servers: Map<string, ExternalMcpServer> = new Map() + + /** + * Connect to an external MCP server + */ + async connect(id: string, host: string, port: number): Promise<string[]> { + if (this.servers.has(id)) { + throw new Error(`Already connected to server: ${id}`) + } + + return new Promise((resolve, reject) => { + const socket = new Socket() + + socket.connect(port, host, async () => { + const server: ExternalMcpServer = { + id, + host, + port, + socket, + connected: true, + tools: [], + } + + this.servers.set(id, server) + + try { + // Initialize and get tools list + await this.sendRequest(socket, "initialize", { + protocolVersion: "2024-11-05", + clientInfo: { name: "clawdstrike", version: "0.1.0" }, + capabilities: {}, + }) + + const toolsResult = (await this.sendRequest(socket, "tools/list", {})) as { + tools: Array<{ name: string }> + } + server.tools = toolsResult.tools.map((t) => t.name) + + resolve(server.tools) + } catch (err) { + socket.destroy() + this.servers.delete(id) + reject(err) + } + }) + + socket.on("error", (err) => { + this.servers.delete(id) + reject(err) + }) + }) + } + + /** + * Disconnect from an external MCP server + */ + disconnect(id: string): void { + const server = this.servers.get(id) + if (server?.socket) { + server.socket.destroy() + } + this.servers.delete(id) + } + + /** + * Call a tool on an external server + */ + async callTool(serverId: string, toolName: string, args: unknown): Promise<unknown> { + const server = this.servers.get(serverId) + if (!server?.socket || !server.connected) { + throw new Error(`Not connected to server: ${serverId}`) + } + + const result = await this.sendRequest(server.socket, "tools/call", { + name: toolName, + arguments: args, + }) + + return result + } + + /** + * Get list of connected servers + */ + getConnectedServers(): Array<{ id: string; host: string; port: number; tools: string[] }> { + return Array.from(this.servers.values()).map((s) => ({ + id: s.id, + host: s.host, + port: s.port, + tools: s.tools, + })) + } + + /** + * Send JSON-RPC request and wait for response + */ + private sendRequest(socket: Socket, method: string, params: unknown): Promise<unknown> { + return new Promise((resolve, reject) => { + const id = Date.now() + const request: JsonRpcRequest = { + jsonrpc: "2.0", + id, + method, + params, + } + + let buffer = "" + + const onData = (data: Buffer) => { + buffer += data.toString() + const lines = buffer.split("\n") + buffer = lines.pop() ?? "" + + for (const line of lines) { + if (line.trim()) { + try { + const response = JSON.parse(line) as JsonRpcResponse + if (response.id === id) { + socket.off("data", onData) + if (response.error) { + reject(new Error(response.error.message)) + } else { + resolve(response.result) + } + } + } catch { + // Ignore parse errors for non-matching responses + } + } + } + } + + socket.on("data", onData) + socket.write(JSON.stringify(request) + "\n") + + // Timeout after 10 seconds + setTimeout(() => { + socket.off("data", onData) + reject(new Error("Request timeout")) + }, 10000) + }) + } +} + +// ============================================================================= +// SINGLETON INSTANCES +// ============================================================================= + +const serverInstance = new McpServerImpl() +const clientManager = new McpClientManager() + +// ============================================================================= +// MCP NAMESPACE +// ============================================================================= + +/** + * MCP namespace - Model Context Protocol server and client + */ +export namespace MCP { + // Server functions + export const start = serverInstance.start.bind(serverInstance) + export const stop = serverInstance.stop.bind(serverInstance) + export const isRunning = serverInstance.isRunning.bind(serverInstance) + export const getPort = serverInstance.getPort.bind(serverInstance) + export const getClientCount = serverInstance.getClientCount.bind(serverInstance) + + // Client functions (for connecting to external MCP servers) + export const connect = clientManager.connect.bind(clientManager) + export const disconnect = clientManager.disconnect.bind(clientManager) + export const callTool = clientManager.callTool.bind(clientManager) + export const getConnectedServers = clientManager.getConnectedServers.bind(clientManager) + + /** + * Get server status summary + */ + export function getStatus(): { + server: { running: boolean; port?: number; clients: number } + connectedServers: Array<{ id: string; tools: string[] }> + } { + return { + server: { + running: serverInstance.isRunning(), + port: serverInstance.getPort(), + clients: serverInstance.getClientCount(), + }, + connectedServers: clientManager.getConnectedServers().map((s) => ({ + id: s.id, + tools: s.tools, + })), + } + } +} + +export default MCP diff --git a/apps/terminal/src/patch/index.ts b/apps/terminal/src/patch/index.ts new file mode 100644 index 000000000..04070344f --- /dev/null +++ b/apps/terminal/src/patch/index.ts @@ -0,0 +1,106 @@ +/** + * PatchLifecycle - Patch capture and merge operations + * + * Manages the lifecycle of patches from workcell to main repository. + * Handles diff capture, validation, staging, and merging. + */ + +import type { Patch, WorkcellInfo, TaskId } from "../types" + +export interface CaptureOptions { + workcell: WorkcellInfo + taskId: TaskId + includeUntracked?: boolean +} + +export interface MergeOptions { + patch: Patch + targetBranch?: string + commitMessage?: string + dryRun?: boolean +} + +export interface MergeResult { + success: boolean + commitHash?: string + conflicts?: string[] + error?: string +} + +/** + * PatchLifecycle namespace - Patch operations + */ +export namespace PatchLifecycle { + /** + * Capture diff from workcell + */ + export async function capture(_options: CaptureOptions): Promise<Patch> { + // STUB: Implementation in Phase 3 + throw new Error("PatchLifecycle.capture not implemented") + } + + /** + * Stage patch for user review + */ + export async function stage(_patch: Patch): Promise<Patch> { + // STUB: Implementation in Phase 3 + throw new Error("PatchLifecycle.stage not implemented") + } + + /** + * Approve staged patch + */ + export async function approve(_patchId: string): Promise<Patch> { + // STUB: Implementation in Phase 3 + throw new Error("PatchLifecycle.approve not implemented") + } + + /** + * Reject patch + */ + export async function reject( + _patchId: string, + _reason?: string + ): Promise<Patch> { + // STUB: Implementation in Phase 3 + throw new Error("PatchLifecycle.reject not implemented") + } + + /** + * Merge approved patch to main repository + */ + export async function merge(_options: MergeOptions): Promise<MergeResult> { + // STUB: Implementation in Phase 3 + throw new Error("PatchLifecycle.merge not implemented") + } + + /** + * Get patch by ID + */ + export async function get(_patchId: string): Promise<Patch | undefined> { + // STUB: Implementation in Phase 3 + return undefined + } + + /** + * List patches for a task + */ + export async function list(_taskId?: TaskId): Promise<Patch[]> { + // STUB: Implementation in Phase 3 + return [] + } + + /** + * Parse unified diff to extract stats + */ + export function parseDiff(_diff: string): Patch["stats"] { + // STUB: Implementation in Phase 3 + return { + filesChanged: 0, + insertions: 0, + deletions: 0, + } + } +} + +export default PatchLifecycle diff --git a/apps/terminal/src/router/index.ts b/apps/terminal/src/router/index.ts new file mode 100644 index 000000000..9fd2fb995 --- /dev/null +++ b/apps/terminal/src/router/index.ts @@ -0,0 +1,201 @@ +/** + * Router - Task routing engine + * + * Determines how tasks are executed based on configurable rules. + * Routes tasks to appropriate toolchains with strategies (single/speculate). + */ + +import type { TaskInput, RoutingDecision, ExecutionResult, Toolchain } from "../types" +import * as rules from "./rules" + +// Re-export rules for external use +export { rules } +export { DEFAULT_RULES } from "./rules" + +export interface RouterConfig { + rules: RoutingRule[] + defaults: { + toolchain: Toolchain + gates: string[] + retries: number + } +} + +export interface RoutingRule { + name: string + priority: number + match: { + labels?: string[] + hint?: string + filePatterns?: string[] + promptPatterns?: string[] + contextSize?: { min?: number; max?: number } + } + action: { + toolchain?: string + strategy?: "single" | "speculate" + speculation?: { + count: number + toolchains: string[] + voteStrategy: "first_pass" | "best_score" | "consensus" + } + gates?: string[] + gatesAdd?: string[] + gatesRemove?: string[] + retries?: number + } +} + +/** + * Default router configuration + */ +export const DEFAULT_CONFIG: RouterConfig = { + rules: rules.DEFAULT_RULES, + defaults: { + toolchain: "claude", + gates: ["pytest", "mypy", "ruff"], + retries: 2, + }, +} + +/** + * Router namespace - Task routing operations + */ +export namespace Router { + /** + * Route a task to determine execution strategy + */ + export async function route( + task: TaskInput, + config: RouterConfig = DEFAULT_CONFIG + ): Promise<RoutingDecision> { + // Combine default rules with config rules (config rules take precedence via priority) + const allRules = [...rules.DEFAULT_RULES, ...config.rules] + + // Evaluate rules to get merged action + const action = rules.evaluateRules(task, allRules) + + // Determine toolchain (use hint, rule result, or default) + let toolchain: Toolchain = config.defaults.toolchain + if (action.toolchain && rules.isValidToolchain(action.toolchain)) { + toolchain = action.toolchain + } + + // Determine strategy + const strategy = action.strategy || "single" + + // Determine gates (use rule result or default) + const gates = action.gates || config.defaults.gates + + // Determine retries + const retries = action.retries ?? config.defaults.retries + + // Generate task ID if not provided + const taskId = task.id || crypto.randomUUID() + + // Build routing decision + const decision: RoutingDecision = { + taskId, + toolchain, + strategy, + gates, + retries, + priority: 50, // Default priority + } + + // Add speculation config if strategy is speculate + if (strategy === "speculate" && action.speculation) { + decision.speculation = { + count: action.speculation.count, + toolchains: action.speculation.toolchains.filter( + rules.isValidToolchain + ) as Toolchain[], + voteStrategy: action.speculation.voteStrategy, + timeout: 300000, // 5 minute default + } + } + + return decision + } + + /** + * Re-route after failure with adjusted parameters + * + * Uses escalation strategy: + * 1. Try different toolchain + * 2. Reduce gate strictness + * 3. Eventually return null (give up) + */ + export async function reroute( + task: TaskInput, + previousResult: ExecutionResult, + config: RouterConfig = DEFAULT_CONFIG + ): Promise<RoutingDecision | null> { + // Get the original routing decision + const originalDecision = await route(task, config) + + // Determine what went wrong + const wasToolchainError = + previousResult.error?.includes("not available") || + previousResult.error?.includes("timeout") + const wasGateFailure = !previousResult.success && !wasToolchainError + + // Toolchain fallback order + const toolchainFallback: Toolchain[] = ["codex", "claude", "opencode", "crush"] + const currentIndex = toolchainFallback.indexOf(previousResult.toolchain) + + if (wasToolchainError && currentIndex < toolchainFallback.length - 1) { + // Try next toolchain in fallback order + return { + ...originalDecision, + toolchain: toolchainFallback[currentIndex + 1], + retries: 1, // Reduce retries on fallback + } + } + + if (wasGateFailure) { + // If gates failed, try with reduced gates + // Remove non-critical gates on first reroute + const criticalGates = originalDecision.gates.filter( + (g) => g === "pytest" // Only pytest is always critical + ) + + if (criticalGates.length < originalDecision.gates.length) { + return { + ...originalDecision, + gates: criticalGates, + retries: 1, + } + } + } + + // Give up - no more rerouting options + return null + } + + /** + * Evaluate rules against task, return merged action + */ + export function evaluateRules( + task: TaskInput, + rulesList: RoutingRule[] + ): Partial<RoutingRule["action"]> { + return rules.evaluateRules(task, rulesList) + } + + /** + * Check if a rule matches a task + */ + export function matchesRule(task: TaskInput, rule: RoutingRule): boolean { + return rules.matchesRule(task, rule) + } + + /** + * Get default configuration + */ + export function getDefaultConfig(): RouterConfig { + return { ...DEFAULT_CONFIG } + } +} + +export default Router diff --git a/apps/terminal/src/router/rules.ts b/apps/terminal/src/router/rules.ts new file mode 100644 index 000000000..52e1e924e --- /dev/null +++ b/apps/terminal/src/router/rules.ts @@ -0,0 +1,279 @@ +/** + * Router Rules Engine + * + * Evaluates routing rules against tasks and merges actions. + */ + +import type { TaskInput, Toolchain } from "../types" +import type { RoutingRule } from "./index" + +/** + * Built-in default rules + */ +export const DEFAULT_RULES: RoutingRule[] = [ + { + name: "hint-override", + priority: 1000, // Highest priority - hints always win + match: { hint: "*" }, + action: { + // toolchain set dynamically from hint + }, + }, + { + name: "high-risk-speculate", + priority: 100, + match: { labels: ["dk_risk:high"] }, + action: { + toolchain: "codex", + strategy: "speculate", + speculation: { + count: 3, + toolchains: ["codex", "claude", "opencode"], + voteStrategy: "first_pass", + }, + gates: ["pytest", "mypy", "ruff"], + retries: 2, + }, + }, + { + name: "medium-risk", + priority: 80, + match: { labels: ["dk_risk:medium"] }, + action: { + toolchain: "claude", + strategy: "single", + gates: ["pytest", "mypy"], + retries: 2, + }, + }, + { + name: "small-fast-path", + priority: 90, + match: { labels: ["dk_size:xs"] }, + action: { + toolchain: "opencode", + strategy: "single", + gates: ["ruff"], + retries: 1, + }, + }, + { + name: "python-files", + priority: 50, + match: { filePatterns: ["**/*.py"] }, + action: { + gatesAdd: ["mypy", "pytest"], + }, + }, + { + name: "typescript-files", + priority: 50, + match: { filePatterns: ["**/*.ts", "**/*.tsx"] }, + action: { + gatesAdd: ["tsc"], + }, + }, +] + +/** + * Check if a rule matches the given task + */ +export function matchesRule(task: TaskInput, rule: RoutingRule): boolean { + const { match } = rule + + // Check hint match (special case: "*" matches any hint) + if (match.hint !== undefined) { + if (match.hint === "*") { + if (!task.hint) return false + } else if (task.hint !== match.hint) { + return false + } + } + + // Check label matches (all specified labels must be present) + if (match.labels && match.labels.length > 0) { + const taskLabels = task.labels || [] + const hasAllLabels = match.labels.every((label) => + taskLabels.includes(label) + ) + if (!hasAllLabels) return false + } + + // Check file pattern matches (any pattern match = true) + if (match.filePatterns && match.filePatterns.length > 0) { + const files = task.context.files || [] + if (files.length === 0) return false + + const hasMatch = files.some((file) => + match.filePatterns!.some((pattern) => matchGlob(file, pattern)) + ) + if (!hasMatch) return false + } + + // Check prompt pattern matches (any pattern match = true) + if (match.promptPatterns && match.promptPatterns.length > 0) { + const hasMatch = match.promptPatterns.some((pattern) => { + try { + const regex = new RegExp(pattern, "i") + return regex.test(task.prompt) + } catch { + return task.prompt.includes(pattern) + } + }) + if (!hasMatch) return false + } + + // Check context size + if (match.contextSize) { + const promptLength = task.prompt.length + if (match.contextSize.min !== undefined && promptLength < match.contextSize.min) { + return false + } + if (match.contextSize.max !== undefined && promptLength > match.contextSize.max) { + return false + } + } + + return true +} + +/** + * Simple glob matching (supports * and ** patterns) + */ +function matchGlob(file: string, pattern: string): boolean { + // Convert glob to regex using placeholders to avoid interference + let regexPattern = pattern + .replace(/\./g, "\\.") // Escape dots + + // Use placeholders for complex replacements + const ANYDIR_OPT = "\x00ANYDIR_OPT\x00" // (?:.*\/)? + const ANYDIR_MID = "\x00ANYDIR_MID\x00" // (?:\/.*\/|\/) + const ANYCHAR = "\x00ANYCHAR\x00" // .* + + // Handle **/ at the start specially - it can match zero or more directories + regexPattern = regexPattern.replace(/^\*\*\//, ANYDIR_OPT) + + // Handle /**/ in the middle - matches any directories + regexPattern = regexPattern.replace(/\/\*\*\//g, ANYDIR_MID) + + // Handle remaining ** (matches anything) + regexPattern = regexPattern.replace(/\*\*/g, ANYCHAR) + + // Handle * (matches anything except /) + regexPattern = regexPattern.replace(/\*/g, "[^/]*") + + // Replace placeholders with actual regex patterns + regexPattern = regexPattern + .replace(new RegExp(ANYDIR_OPT.replace(/\x00/g, "\\x00"), "g"), "(?:.*\\/)?") + .replace(new RegExp(ANYDIR_MID.replace(/\x00/g, "\\x00"), "g"), "(?:\\/.*\\/|\\/)") + .replace(new RegExp(ANYCHAR.replace(/\x00/g, "\\x00"), "g"), ".*") + + try { + const regex = new RegExp(`^${regexPattern}$`) + return regex.test(file) + } catch { + return false + } +} + +/** + * Merge actions from multiple rules, respecting priority order + */ +export function mergeActions( + actions: Array<{ priority: number; action: RoutingRule["action"] }> +): RoutingRule["action"] { + // Sort by priority (highest first) + const sorted = [...actions].sort((a, b) => b.priority - a.priority) + + const merged: RoutingRule["action"] = {} + const allGates = new Set<string>() + const gatesToAdd = new Set<string>() + const gatesToRemove = new Set<string>() + + for (const { action } of sorted) { + // Lower priority values only fill in missing fields + if (action.toolchain !== undefined && merged.toolchain === undefined) { + merged.toolchain = action.toolchain + } + if (action.strategy !== undefined && merged.strategy === undefined) { + merged.strategy = action.strategy + } + if (action.speculation !== undefined && merged.speculation === undefined) { + merged.speculation = action.speculation + } + if (action.retries !== undefined && merged.retries === undefined) { + merged.retries = action.retries + } + + // Gates accumulate + if (action.gates) { + for (const gate of action.gates) { + allGates.add(gate) + } + } + if (action.gatesAdd) { + for (const gate of action.gatesAdd) { + gatesToAdd.add(gate) + } + } + if (action.gatesRemove) { + for (const gate of action.gatesRemove) { + gatesToRemove.add(gate) + } + } + } + + // Compute final gates: (explicit gates OR accumulated adds) - removes + const finalGates = new Set<string>() + for (const gate of allGates) { + finalGates.add(gate) + } + for (const gate of gatesToAdd) { + finalGates.add(gate) + } + for (const gate of gatesToRemove) { + finalGates.delete(gate) + } + + if (finalGates.size > 0) { + merged.gates = Array.from(finalGates) + } + + return merged +} + +/** + * Evaluate all rules against a task and return merged action + */ +export function evaluateRules( + task: TaskInput, + rules: RoutingRule[] +): RoutingRule["action"] { + const matchingActions: Array<{ priority: number; action: RoutingRule["action"] }> = [] + + for (const rule of rules) { + if (matchesRule(task, rule)) { + // Handle hint-override rule specially + if (rule.name === "hint-override" && task.hint) { + matchingActions.push({ + priority: rule.priority, + action: { ...rule.action, toolchain: task.hint }, + }) + } else { + matchingActions.push({ + priority: rule.priority, + action: rule.action, + }) + } + } + } + + return mergeActions(matchingActions) +} + +/** + * Validate a toolchain value + */ +export function isValidToolchain(value: string): value is Toolchain { + return ["codex", "claude", "opencode", "crush"].includes(value) +} diff --git a/apps/terminal/src/speculate/index.ts b/apps/terminal/src/speculate/index.ts new file mode 100644 index 000000000..d12402f01 --- /dev/null +++ b/apps/terminal/src/speculate/index.ts @@ -0,0 +1,162 @@ +/** + * Speculate - Parallel multi-agent execution (Speculate+Vote) + * + * Runs multiple agents in parallel and selects the best result. + * Implements voting strategies: first_pass, best_score, consensus. + */ + +import type { + TaskInput, + SpeculationConfig, + SpeculationResult, + ExecutionResult, + GateResults, + Toolchain, + VoteStrategy, + WorkcellId, +} from "../types" +import { Orchestrator, type ProgressCallback } from "./orchestrator" +import { Voter, type CandidateResult } from "./voter" + +// Re-export submodules +export { Orchestrator } from "./orchestrator" +export { Voter, type CandidateResult } from "./voter" + +export interface SpeculateOptions { + task: TaskInput + config: SpeculationConfig + gates?: string[] + onProgress?: ProgressCallback +} + +export interface VoteInput { + workcellId: WorkcellId + toolchain: Toolchain + result: ExecutionResult + gateResults: GateResults +} + +/** + * Speculate namespace - Parallel execution operations + */ +export namespace Speculate { + /** + * Run speculation with multiple toolchains + * + * Acquires workcells in parallel, executes tasks, runs gates, + * votes for winner, and cleans up non-winners. + */ + export async function run( + options: SpeculateOptions + ): Promise<SpeculationResult> { + return Orchestrator.run({ + task: options.task, + config: options.config, + gates: options.gates, + onProgress: options.onProgress, + }) + } + + /** + * Vote on results using specified strategy + */ + export function vote( + results: VoteInput[], + strategy: VoteStrategy + ): VoteInput | undefined { + // Convert VoteInput to CandidateResult for Voter + const candidates: CandidateResult[] = results.map((r) => ({ + workcellId: r.workcellId, + toolchain: r.toolchain, + result: r.result, + gateResults: r.gateResults, + })) + + const winner = Voter.select(candidates, strategy) + if (!winner) return undefined + + // Convert back to VoteInput + return results.find((r) => r.workcellId === winner.workcellId) + } + + /** + * First-pass voting: first passing result wins + */ + export function voteFirstPass(results: VoteInput[]): VoteInput | undefined { + const passing = results.filter((r) => r.gateResults.allPassed) + if (passing.length === 0) return undefined + + // Convert to CandidateResult + const candidates: CandidateResult[] = passing.map((r) => ({ + workcellId: r.workcellId, + toolchain: r.toolchain, + result: r.result, + gateResults: r.gateResults, + })) + + const winner = Voter.selectFirstPass(candidates) + return results.find((r) => r.workcellId === winner.workcellId) + } + + /** + * Best-score voting: highest gate score wins + */ + export function voteBestScore(results: VoteInput[]): VoteInput | undefined { + const passing = results.filter((r) => r.gateResults.allPassed) + if (passing.length === 0) return undefined + + // Convert to CandidateResult + const candidates: CandidateResult[] = passing.map((r) => ({ + workcellId: r.workcellId, + toolchain: r.toolchain, + result: r.result, + gateResults: r.gateResults, + })) + + const winner = Voter.selectBestScore(candidates) + return results.find((r) => r.workcellId === winner.workcellId) + } + + /** + * Consensus voting: most similar patch wins + */ + export function voteConsensus(results: VoteInput[]): VoteInput | undefined { + const passing = results.filter((r) => r.gateResults.allPassed) + if (passing.length === 0) return undefined + + // Convert to CandidateResult + const candidates: CandidateResult[] = passing.map((r) => ({ + workcellId: r.workcellId, + toolchain: r.toolchain, + result: r.result, + gateResults: r.gateResults, + })) + + const winner = Voter.selectConsensus(candidates) + return results.find((r) => r.workcellId === winner.workcellId) + } + + /** + * Cancel a running speculation by task ID + */ + export function cancel(taskId: string): boolean { + return Orchestrator.cancel(taskId) + } + + /** + * Get list of active speculation task IDs + */ + export function getActive(): string[] { + return Orchestrator.getActive() + } + + /** + * Calculate patch similarity between two patches + * Useful for consensus voting debugging + */ + export function calculateSimilarity(a: string, b: string): number { + return Voter.calculateSimilarity(a, b) + } +} + +export default Speculate diff --git a/apps/terminal/src/speculate/orchestrator.ts b/apps/terminal/src/speculate/orchestrator.ts new file mode 100644 index 000000000..a1c03d538 --- /dev/null +++ b/apps/terminal/src/speculate/orchestrator.ts @@ -0,0 +1,325 @@ +/** + * Orchestrator - Parallel multi-agent execution + * + * Manages parallel workcell acquisition, execution, and cleanup. + * Coordinates with Dispatcher and Verifier for the full speculation flow. + */ + +import type { + TaskInput, + SpeculationConfig, + SpeculationResult, + ExecutionResult, + GateResults, + Toolchain, + WorkcellId, +} from "../types" +import { Workcell } from "../workcell" +import { Dispatcher } from "../dispatcher" +import { Verifier } from "../verifier" +import { Voter, type CandidateResult } from "./voter" + +/** + * Progress callback for speculation status updates + */ +export type ProgressCallback = ( + toolchain: Toolchain, + status: "acquiring" | "executing" | "verifying" | "completed" | "failed", + workcellId?: WorkcellId +) => void + +/** + * Orchestrator options + */ +export interface OrchestratorOptions { + task: TaskInput + config: SpeculationConfig + gates?: string[] + onProgress?: ProgressCallback +} + +/** + * Internal execution result for each workcell + */ +interface ExecutionEntry { + workcellId: WorkcellId + toolchain: Toolchain + result?: CandidateResult["result"] + gateResults?: GateResults + error?: string +} + +/** + * Orchestrator namespace - Parallel execution management + */ +export namespace Orchestrator { + // Track active speculations for cancellation + const activeSpeculations = new Map< + string, + { controller: AbortController; workcellIds: WorkcellId[] } + >() + + /** + * Run speculation across multiple toolchains in parallel + */ + export async function run( + options: OrchestratorOptions + ): Promise<SpeculationResult> { + const { task, config, gates = [], onProgress } = options + const startedAt = Date.now() + const taskId = task.id || crypto.randomUUID() + + // Create abort controller for timeout and cancellation + const controller = new AbortController() + const workcellIds: WorkcellId[] = [] + + // Register for cancellation + activeSpeculations.set(taskId, { controller, workcellIds }) + + // Set up timeout + const timeoutId = setTimeout(() => { + controller.abort() + }, config.timeout) + + try { + // Phase 1: Acquire workcells in parallel + const workcells = await Promise.all( + config.toolchains.map(async (toolchain) => { + onProgress?.(toolchain, "acquiring") + try { + const wc = await Workcell.acquire(task.context.projectId, toolchain, { + cwd: task.context.cwd, + }) + workcellIds.push(wc.id) + return { toolchain, workcell: wc, error: undefined } + } catch (err) { + return { + toolchain, + workcell: undefined, + error: err instanceof Error ? err.message : String(err), + } + } + }) + ) + + // Check if aborted during acquisition + if (controller.signal.aborted) { + return createAbortedResult(startedAt, workcells) + } + + // Phase 2: Execute all in parallel + const executions = await Promise.allSettled( + workcells.map(async ({ toolchain, workcell, error }) => { + // Skip if workcell acquisition failed + if (!workcell || error) { + return { + workcellId: "" as WorkcellId, + toolchain, + error: error || "Failed to acquire workcell", + } satisfies ExecutionEntry + } + + onProgress?.(toolchain, "executing", workcell.id) + + try { + // Execute the task + const result = await Dispatcher.execute({ + task, + workcell, + toolchain, + timeout: Math.floor(config.timeout / 2), // Half timeout for execution + }) + + // Check for abort + if (controller.signal.aborted) { + return { + workcellId: workcell.id, + toolchain, + error: "Aborted", + } satisfies ExecutionEntry + } + + // Run gates if execution succeeded + let gateResults: GateResults | undefined + if (result.success && gates.length > 0) { + onProgress?.(toolchain, "verifying", workcell.id) + gateResults = await Verifier.run(workcell, { + gates, + failFast: true, + timeout: Math.floor(config.timeout / 2), // Half timeout for gates + }) + } + + onProgress?.(toolchain, "completed", workcell.id) + + return { + workcellId: workcell.id, + toolchain, + result, + gateResults, + } satisfies ExecutionEntry + } catch (err) { + onProgress?.(toolchain, "failed", workcell.id) + return { + workcellId: workcell.id, + toolchain, + error: err instanceof Error ? err.message : String(err), + } satisfies ExecutionEntry + } + }) + ) + + clearTimeout(timeoutId) + const completedAt = Date.now() + + // Collect results + const allResults: CandidateResult[] = executions.map((exec, i) => { + const toolchain = config.toolchains[i] + const workcellId = workcells[i]?.workcell?.id || ("" as WorkcellId) + + if (exec.status === "fulfilled") { + const entry = exec.value + const fallbackResult: ExecutionResult = { + taskId, + workcellId: entry.workcellId || workcellId, + toolchain: entry.toolchain, + success: false, + output: "", + error: entry.error, + telemetry: { startedAt, completedAt }, + } + return { + workcellId: entry.workcellId || workcellId, + toolchain: entry.toolchain, + result: entry.result || fallbackResult, + gateResults: entry.gateResults, + error: entry.error, + } + } else { + const fallbackResult: ExecutionResult = { + taskId, + workcellId, + toolchain, + success: false, + output: "", + error: exec.reason?.message ?? "Unknown error", + telemetry: { startedAt, completedAt }, + } + return { + workcellId, + toolchain, + result: fallbackResult, + error: exec.reason?.message ?? "Unknown error", + } + } + }) + + // Phase 3: Vote for winner + const passingCandidates = allResults.filter( + (r) => r.result.success && r.gateResults?.allPassed + ) + const winner = + passingCandidates.length > 0 + ? Voter.select(passingCandidates, config.voteStrategy) + : undefined + + // Phase 4: Cleanup non-winners + for (const result of allResults) { + if (!result.workcellId) continue + + if (result.workcellId === winner?.workcellId) { + // Winner: keep workcell with patch intact + // Patch will be extracted in PatchLifecycle.capture() + continue + } + + // Non-winners: release workcell + try { + await Workcell.release(result.workcellId, { reset: true }) + } catch { + // Ignore cleanup errors + } + } + + // Build vote tally for telemetry + const votes: Record<string, number> = {} + if (passingCandidates.length > 0) { + const tally = Voter.getVoteTally(passingCandidates, config.voteStrategy) + for (const [wcId, { score }] of tally) { + votes[wcId] = score + } + } + + return { + winner: winner + ? { + workcellId: winner.workcellId, + toolchain: winner.toolchain, + result: winner.result, + gateResults: winner.gateResults!, + } + : undefined, + allResults: allResults.map((r) => ({ + workcellId: r.workcellId, + toolchain: r.toolchain, + result: r.result, + gateResults: r.gateResults, + error: r.error, + })), + votes, + timing: { + startedAt, + completedAt, + }, + } + } finally { + clearTimeout(timeoutId) + activeSpeculations.delete(taskId) + } + } + + /** + * Cancel an active speculation + */ + export function cancel(taskId: string): boolean { + const speculation = activeSpeculations.get(taskId) + if (!speculation) return false + + speculation.controller.abort() + return true + } + + /** + * Get active speculation IDs + */ + export function getActive(): string[] { + return Array.from(activeSpeculations.keys()) + } + + /** + * Create result for aborted speculation + */ + function createAbortedResult( + startedAt: number, + workcells: Array<{ + toolchain: Toolchain + workcell?: { id: WorkcellId } + error?: string + }> + ): SpeculationResult { + const completedAt = Date.now() + return { + winner: undefined, + allResults: workcells.map((w) => ({ + workcellId: w.workcell?.id || ("" as WorkcellId), + toolchain: w.toolchain, + success: false, + gatesPassed: false, + error: "Speculation aborted", + })), + timing: { startedAt, completedAt }, + } + } +} + +export default Orchestrator diff --git a/apps/terminal/src/speculate/voter.ts b/apps/terminal/src/speculate/voter.ts new file mode 100644 index 000000000..85fff2de6 --- /dev/null +++ b/apps/terminal/src/speculate/voter.ts @@ -0,0 +1,191 @@ +/** + * Voter - Voting strategies for Speculate+Vote + * + * Implements three strategies for selecting the best result: + * - first_pass: First candidate that completes and passes all gates + * - best_score: Candidate with highest gate score + * - consensus: Candidate whose patch is most similar to others + */ + +import type { + ExecutionResult, + GateResults, + Toolchain, + VoteStrategy, + WorkcellId, +} from "../types" + +/** + * Candidate result from speculation + */ +export interface CandidateResult { + workcellId: WorkcellId + toolchain: Toolchain + result: ExecutionResult + gateResults?: GateResults + error?: string +} + +/** + * Voter namespace - Voting strategy implementations + */ +export namespace Voter { + /** + * Select winner from candidates using specified strategy + */ + export function select( + candidates: CandidateResult[], + strategy: VoteStrategy + ): CandidateResult | undefined { + // Filter to only passing candidates + const passing = candidates.filter( + (c) => c.result.success && c.gateResults?.allPassed + ) + + if (passing.length === 0) return undefined + if (passing.length === 1) return passing[0] + + switch (strategy) { + case "first_pass": + return selectFirstPass(passing) + case "best_score": + return selectBestScore(passing) + case "consensus": + return selectConsensus(passing) + default: + return passing[0] + } + } + + /** + * First one that passes all gates (fastest completion time) + * Good for straightforward tasks where speed matters + */ + export function selectFirstPass( + candidates: CandidateResult[] + ): CandidateResult { + // Return the one that completed first + return candidates.reduce((earliest, curr) => { + const earliestTime = earliest.result.telemetry.completedAt + const currTime = curr.result.telemetry.completedAt + return currTime < earliestTime ? curr : earliest + }) + } + + /** + * Highest gate score wins + * Best for quality-critical tasks + */ + export function selectBestScore( + candidates: CandidateResult[] + ): CandidateResult { + return candidates.reduce((best, curr) => { + const bestScore = best.gateResults?.score ?? 0 + const currScore = curr.gateResults?.score ?? 0 + return currScore > bestScore ? curr : best + }) + } + + /** + * Most similar patch wins (for consistency) + * Best for determinism-critical tasks where multiple agents + * should converge on the same solution + */ + export function selectConsensus( + candidates: CandidateResult[] + ): CandidateResult { + // Calculate pairwise patch similarity + const patches = candidates.map((c) => c.result.patch ?? "") + const similarities = new Map<WorkcellId, number>() + + for (let i = 0; i < patches.length; i++) { + let totalSimilarity = 0 + for (let j = 0; j < patches.length; j++) { + if (i !== j) { + totalSimilarity += calculateSimilarity(patches[i], patches[j]) + } + } + similarities.set(candidates[i].workcellId, totalSimilarity) + } + + // Return candidate with highest total similarity + return candidates.reduce((best, curr) => { + const bestSim = similarities.get(best.workcellId) ?? 0 + const currSim = similarities.get(curr.workcellId) ?? 0 + return currSim > bestSim ? curr : best + }) + } + + /** + * Calculate similarity between two patches using Jaccard index + * Returns value between 0 (completely different) and 1 (identical) + */ + export function calculateSimilarity(a: string, b: string): number { + if (a === b) return 1 + if (!a || !b) return 0 + + const linesA = new Set(a.split("\n").filter((l) => l.trim())) + const linesB = new Set(b.split("\n").filter((l) => l.trim())) + + if (linesA.size === 0 && linesB.size === 0) return 1 + if (linesA.size === 0 || linesB.size === 0) return 0 + + const intersection = new Set([...linesA].filter((x) => linesB.has(x))) + const union = new Set([...linesA, ...linesB]) + + return intersection.size / union.size + } + + /** + * Get votes tally for each candidate + * Useful for debugging and telemetry + */ + export function getVoteTally( + candidates: CandidateResult[], + strategy: VoteStrategy + ): Map<WorkcellId, { score: number; reason: string }> { + const tally = new Map<WorkcellId, { score: number; reason: string }>() + + for (const candidate of candidates) { + let score = 0 + let reason = "" + + if (!candidate.result.success) { + reason = "execution failed" + } else if (!candidate.gateResults?.allPassed) { + reason = "gates failed" + } else { + switch (strategy) { + case "first_pass": + score = -candidate.result.telemetry.completedAt // Lower is better + reason = `completed at ${candidate.result.telemetry.completedAt}` + break + case "best_score": + score = candidate.gateResults?.score ?? 0 + reason = `gate score ${score}` + break + case "consensus": + // Calculate total similarity to others + const patches = candidates.map((c) => c.result.patch ?? "") + const idx = candidates.indexOf(candidate) + for (let j = 0; j < patches.length; j++) { + if (idx !== j) { + score += calculateSimilarity( + candidate.result.patch ?? "", + patches[j] + ) + } + } + reason = `similarity score ${score.toFixed(2)}` + break + } + } + + tally.set(candidate.workcellId, { score, reason }) + } + + return tally + } +} + +export default Voter diff --git a/apps/terminal/src/telemetry/index.ts b/apps/terminal/src/telemetry/index.ts new file mode 100644 index 000000000..c92bb4df1 --- /dev/null +++ b/apps/terminal/src/telemetry/index.ts @@ -0,0 +1,286 @@ +/** + * Telemetry - Execution tracking and rollout generation + * + * Generates comprehensive telemetry for analysis and improvement. + * Creates rollout.json files for each execution. + */ + +import * as fs from "node:fs/promises" +import * as path from "node:path" +import type { + Rollout, + TelemetryEvent, + TaskId, + TaskStatus, + RoutingDecision, + ExecutionResult, + GateResults, +} from "../types" + +export interface TelemetryConfig { + outputDir: string + enabled: boolean + verbose?: boolean +} + +export interface AnalyticsEvent { + event: string + timestamp: string + properties: Record<string, unknown> +} + +// Module state +let config: TelemetryConfig | null = null +const activeRollouts = new Map<string, Rollout>() + +/** + * Telemetry namespace - Execution tracking operations + */ +export namespace Telemetry { + /** + * Initialize telemetry system + */ + export function init(cfg: TelemetryConfig): void { + config = cfg + } + + /** + * Get current config (returns default if not initialized) + */ + function getConfig(): TelemetryConfig { + return config ?? { outputDir: ".clawdstrike/runs", enabled: true } + } + + /** + * Start a new rollout + */ + export function startRollout(taskId: TaskId): Rollout { + const rollout: Rollout = { + id: crypto.randomUUID(), + taskId, + startedAt: Date.now(), + status: "pending", + events: [], + } + + activeRollouts.set(rollout.id, rollout) + return rollout + } + + /** + * Record an event + */ + export function recordEvent( + rolloutId: string, + event: Omit<TelemetryEvent, "timestamp"> + ): void { + const rollout = activeRollouts.get(rolloutId) + if (!rollout) { + return // Silently ignore if rollout not found + } + + rollout.events.push({ + ...event, + timestamp: Date.now(), + }) + } + + /** + * Update rollout status + */ + export function updateStatus(rolloutId: string, status: TaskStatus): void { + const rollout = activeRollouts.get(rolloutId) + if (rollout) { + rollout.status = status + } + } + + /** + * Set routing decision on rollout + */ + export function setRouting( + rolloutId: string, + routing: RoutingDecision + ): void { + const rollout = activeRollouts.get(rolloutId) + if (rollout) { + rollout.routing = routing + } + } + + /** + * Set execution result on rollout + */ + export function setExecution( + rolloutId: string, + execution: ExecutionResult + ): void { + const rollout = activeRollouts.get(rolloutId) + if (rollout) { + rollout.execution = execution + } + } + + /** + * Set verification results on rollout + */ + export function setVerification( + rolloutId: string, + verification: GateResults + ): void { + const rollout = activeRollouts.get(rolloutId) + if (rollout) { + rollout.verification = verification + } + } + + /** + * Complete and save rollout + */ + export async function completeRollout(rolloutId: string): Promise<string> { + const rollout = activeRollouts.get(rolloutId) + if (!rollout) { + throw new Error(`Rollout not found: ${rolloutId}`) + } + + rollout.completedAt = Date.now() + activeRollouts.delete(rolloutId) + + const cfg = getConfig() + if (!cfg.enabled) { + return "" + } + + // Write rollout to file + const filePath = await write(rollout) + return filePath + } + + /** + * Write rollout to .clawdstrike/runs/ + */ + async function write(rollout: Rollout): Promise<string> { + const cfg = getConfig() + const runDir = path.join(cfg.outputDir, rollout.id) + await fs.mkdir(runDir, { recursive: true }) + + const filePath = path.join(runDir, "rollout.json") + await Bun.write(filePath, JSON.stringify(rollout, null, 2)) + + return filePath + } + + /** + * Get rollout by ID (from active or disk) + */ + export async function getRollout(id: string): Promise<Rollout | undefined> { + // Check active rollouts first + const active = activeRollouts.get(id) + if (active) { + return active + } + + // Try to read from disk + const cfg = getConfig() + const filePath = path.join(cfg.outputDir, id, "rollout.json") + + try { + const content = await Bun.file(filePath).text() + return JSON.parse(content) as Rollout + } catch { + return undefined + } + } + + /** + * List rollouts for a task + */ + export async function listRollouts(taskId?: TaskId): Promise<Rollout[]> { + const cfg = getConfig() + const rollouts: Rollout[] = [] + + try { + const entries = await fs.readdir(cfg.outputDir, { withFileTypes: true }) + + for (const entry of entries) { + if (!entry.isDirectory()) continue + + const filePath = path.join(cfg.outputDir, entry.name, "rollout.json") + try { + const content = await Bun.file(filePath).text() + const rollout = JSON.parse(content) as Rollout + + if (!taskId || rollout.taskId === taskId) { + rollouts.push(rollout) + } + } catch { + // Skip invalid files + } + } + } catch { + // Directory doesn't exist, return empty + } + + // Sort by startedAt descending + return rollouts.sort((a, b) => b.startedAt - a.startedAt) + } + + /** + * Export rollouts to analytics format + */ + export function toAnalytics(rollout: Rollout): AnalyticsEvent { + return { + event: "clawdstrike_execution", + timestamp: new Date(rollout.startedAt).toISOString(), + properties: { + rolloutId: rollout.id, + taskId: rollout.taskId, + toolchain: rollout.routing?.toolchain, + strategy: rollout.routing?.strategy, + outcome: rollout.status, + duration: rollout.completedAt + ? rollout.completedAt - rollout.startedAt + : undefined, + gateScore: rollout.verification?.score, + gatesPassed: rollout.verification?.allPassed, + tokensUsed: rollout.execution?.telemetry?.tokens + ? (rollout.execution.telemetry.tokens.input ?? 0) + + (rollout.execution.telemetry.tokens.output ?? 0) + : undefined, + cost: rollout.execution?.telemetry?.cost, + model: rollout.execution?.telemetry?.model, + }, + } + } + + /** + * Export multiple rollouts to analytics format + */ + export function exportAnalytics(rollouts: Rollout[]): AnalyticsEvent[] { + return rollouts.map(toAnalytics) + } + + /** + * Get active rollout IDs + */ + export function getActive(): string[] { + return Array.from(activeRollouts.keys()) + } + + /** + * Check if telemetry is initialized + */ + export function isInitialized(): boolean { + return config !== null + } + + /** + * Reset telemetry state (mainly for testing) + */ + export function reset(): void { + config = null + activeRollouts.clear() + } +} + +export default Telemetry diff --git a/apps/terminal/src/tools/index.ts b/apps/terminal/src/tools/index.ts new file mode 100644 index 000000000..7b77f1f98 --- /dev/null +++ b/apps/terminal/src/tools/index.ts @@ -0,0 +1,516 @@ +/** + * Tools - Agent tool definitions for ClawdStrike operations + * + * Exposes dispatch, speculate, and gate tools for use by OpenCode agents. + * These tools allow agents to orchestrate other agents and run quality checks. + */ + +import type { + RoutingDecision, + ExecutionResult, + GateResults, + SpeculationResult, + Toolchain, + VoteStrategy, + TaskInput, + WorkcellInfo, +} from "../types" +import { Router } from "../router" +import { Dispatcher } from "../dispatcher" +import { Verifier } from "../verifier" +import { Speculate } from "../speculate" +import { Workcell } from "../workcell" +import { Telemetry } from "../telemetry" + +/** + * Tool definition interface (compatible with OpenCode/MCP) + */ +export interface ToolDefinition { + name: string + description: string + parameters: { + type: "object" + properties: Record<string, unknown> + required: string[] + } + handler: (params: unknown, context?: ToolContext) => Promise<unknown> +} + +/** + * Context passed to tool handlers + */ +export interface ToolContext { + cwd: string + projectId: string + taskId?: string +} + +// ============================================================================= +// DISPATCH TOOL +// ============================================================================= + +export interface DispatchParams { + prompt: string + toolchain?: Toolchain + gates?: string[] + timeout?: number +} + +export interface DispatchResult { + success: boolean + taskId: string + routing: RoutingDecision + result?: ExecutionResult + verification?: GateResults + error?: string +} + +/** + * Dispatch tool - Submit task for execution + */ +export const dispatchTool: ToolDefinition = { + name: "dispatch", + description: `Submit a coding task for execution by a specialized agent. + +Available toolchains: +- codex: OpenAI Codex CLI (GPT-5.2) - best for complex reasoning +- claude: Anthropic Claude Code (Opus) - fast, reliable general purpose +- opencode: Local OpenCode - quick, no network dependency +- crush: Multi-provider fallback - retries across providers + +The task runs in an isolated workcell with quality gates.`, + parameters: { + type: "object", + properties: { + prompt: { + type: "string", + description: "The task prompt to execute", + }, + toolchain: { + type: "string", + enum: ["codex", "claude", "opencode", "crush"], + description: + "Specific toolchain to use (optional, auto-routed if not specified)", + }, + gates: { + type: "array", + items: { type: "string" }, + description: "Quality gates to run (default: pytest, mypy, ruff)", + }, + timeout: { + type: "number", + description: "Timeout in milliseconds (default: 300000)", + }, + }, + required: ["prompt"], + }, + handler: async ( + params: unknown, + context?: ToolContext + ): Promise<DispatchResult> => { + const p = params as DispatchParams + const ctx = context ?? { cwd: process.cwd(), projectId: "default" } + const taskId = ctx.taskId ?? crypto.randomUUID() + + // Start telemetry + const rollout = Telemetry.startRollout(taskId) + Telemetry.updateStatus(rollout.id, "routing") + + try { + // Create task input + const task: TaskInput = { + id: taskId, + prompt: p.prompt, + context: { + cwd: ctx.cwd, + projectId: ctx.projectId, + }, + hint: p.toolchain, + gates: p.gates, + timeout: p.timeout, + } + + // Optional pre-dispatch security check (fail-open) + try { + const { Hushd } = await import("../hushd") + const hushdClient = Hushd.getClient() + const hushdAvailable = await hushdClient.probe(500) + if (hushdAvailable) { + const preCheck = await hushdClient.check({ + action_type: "shell", + target: p.prompt.slice(0, 200), + metadata: { source: "dispatch", toolchain: p.toolchain ?? "auto" }, + }) + if (preCheck?.decision === "deny") { + Telemetry.updateStatus(rollout.id, "failed") + await Telemetry.completeRollout(rollout.id) + return { + success: false, + taskId, + routing: { taskId, toolchain: p.toolchain ?? "codex", strategy: "single", gates: [], retries: 0, priority: 50 } as RoutingDecision, + error: `Security policy denied: ${preCheck.guards.map(g => g.reason ?? g.guard).join(", ")}`, + } + } + } + } catch { + // Fail-open: skip security check if hushd unavailable + } + + // Route the task + const routing = await Router.route(task) + Telemetry.setRouting(rollout.id, routing) + Telemetry.updateStatus(rollout.id, "executing") + + // Load config for sandbox mode + const { Config } = await import("../config") + const projectConfig = await Config.load(ctx.cwd) + const sandboxMode = projectConfig?.sandbox ?? "inplace" + + // Acquire workcell + let workcell: WorkcellInfo + try { + workcell = await Workcell.acquire(ctx.projectId, routing.toolchain, { + cwd: ctx.cwd, + sandboxMode, + }) + } catch (err) { + const error = err instanceof Error ? err.message : String(err) + Telemetry.updateStatus(rollout.id, "failed") + await Telemetry.completeRollout(rollout.id) + return { + success: false, + taskId, + routing, + error: error.includes("Not a git repository") + ? "Not a git repository. Run 'clawdstrike init' or launch the TUI for guided setup." + : `Failed to acquire workcell: ${error}`, + } + } + + // Execute the task + const result = await Dispatcher.execute({ + task, + workcell, + toolchain: routing.toolchain, + timeout: p.timeout ?? 300000, + }) + Telemetry.setExecution(rollout.id, result) + + // Run gates if execution succeeded + let verification: GateResults | undefined + if (result.success) { + Telemetry.updateStatus(rollout.id, "verifying") + verification = await Verifier.run(workcell, { + gates: routing.gates, + failFast: true, + }) + Telemetry.setVerification(rollout.id, verification) + } + + // Release workcell + await Workcell.release(workcell.id, { reset: true }) + + // Clean up tmpdir workcells + if (workcell.directory.includes(".clawdstrike/tmp/")) { + const { rm } = await import("fs/promises") + await rm(workcell.directory, { recursive: true, force: true }).catch( + () => {} + ) + } + + // Complete telemetry + Telemetry.updateStatus( + rollout.id, + result.success && verification?.allPassed ? "completed" : "failed" + ) + await Telemetry.completeRollout(rollout.id) + + return { + success: result.success && (verification?.allPassed ?? true), + taskId, + routing, + result, + verification, + } + } catch (err) { + Telemetry.updateStatus(rollout.id, "failed") + await Telemetry.completeRollout(rollout.id) + throw err + } + }, +} + +// ============================================================================= +// SPECULATE TOOL +// ============================================================================= + +export interface SpeculateParams { + prompt: string + toolchains?: Toolchain[] + voteStrategy?: VoteStrategy + gates?: string[] + timeout?: number +} + +export interface SpeculateToolResult { + success: boolean + winner?: { + toolchain: Toolchain + score: number + } + allResults: Array<{ + toolchain: Toolchain + passed: boolean + score: number + error?: string + }> + result?: SpeculationResult +} + +/** + * Speculate tool - Run task with multiple agents in parallel + */ +export const speculateTool: ToolDefinition = { + name: "speculate", + description: `Run a task with multiple agents in parallel and select the best result. + +Vote strategies: +- first_pass: First result that passes all gates wins (fastest) +- best_score: Highest gate score wins (best quality) +- consensus: Most similar patch wins (most deterministic) + +Use for high-risk tasks where reliability is critical.`, + parameters: { + type: "object", + properties: { + prompt: { + type: "string", + description: "The task prompt to execute", + }, + toolchains: { + type: "array", + items: { type: "string", enum: ["codex", "claude", "opencode"] }, + description: "Toolchains to use (default: codex, claude, opencode)", + }, + voteStrategy: { + type: "string", + enum: ["first_pass", "best_score", "consensus"], + description: "How to select the winner (default: first_pass)", + }, + gates: { + type: "array", + items: { type: "string" }, + description: "Quality gates to run (default: pytest, mypy, ruff)", + }, + timeout: { + type: "number", + description: "Timeout in milliseconds (default: 300000)", + }, + }, + required: ["prompt"], + }, + handler: async ( + params: unknown, + context?: ToolContext + ): Promise<SpeculateToolResult> => { + const p = params as SpeculateParams + const ctx = context ?? { cwd: process.cwd(), projectId: "default" } + const taskId = ctx.taskId ?? crypto.randomUUID() + + const toolchains: Toolchain[] = p.toolchains ?? ["codex", "claude", "opencode"] + const voteStrategy: VoteStrategy = p.voteStrategy ?? "first_pass" + const gates = p.gates ?? ["pytest", "mypy", "ruff"] + const timeout = p.timeout ?? 300000 + + // Create task input + const task: TaskInput = { + id: taskId, + prompt: p.prompt, + context: { + cwd: ctx.cwd, + projectId: ctx.projectId, + }, + gates, + timeout, + } + + // Run speculation + const result = await Speculate.run({ + task, + config: { + count: toolchains.length, + toolchains, + voteStrategy, + timeout, + }, + gates, + }) + + // Format results + const allResults = result.allResults.map((r) => ({ + toolchain: r.toolchain, + passed: r.result?.success ?? false, + score: r.gateResults?.score ?? 0, + error: r.error, + })) + + return { + success: result.winner !== undefined, + winner: result.winner + ? { + toolchain: result.winner.toolchain, + score: result.winner.gateResults.score, + } + : undefined, + allResults, + result, + } + }, +} + +// ============================================================================= +// GATE TOOL +// ============================================================================= + +export interface GateParams { + gates?: string[] + failFast?: boolean + directory?: string +} + +export interface GateToolResult { + success: boolean + allPassed: boolean + score: number + summary: string + results: Array<{ + gate: string + passed: boolean + critical: boolean + errorCount: number + warningCount: number + }> +} + +/** + * Gate tool - Run quality gates on current workspace + */ +export const gateTool: ToolDefinition = { + name: "gate", + description: `Run quality gates on the current workspace. + +Available gates: +- pytest: Run Python tests +- mypy: Type check Python code +- ruff: Lint and format Python code + +Use before committing or after making changes to verify quality.`, + parameters: { + type: "object", + properties: { + gates: { + type: "array", + items: { type: "string" }, + description: "Specific gates to run (default: pytest, mypy, ruff)", + }, + failFast: { + type: "boolean", + description: "Stop on first critical failure (default: true)", + }, + directory: { + type: "string", + description: "Directory to run gates in (default: current directory)", + }, + }, + required: [], + }, + handler: async ( + params: unknown, + context?: ToolContext + ): Promise<GateToolResult> => { + const p = params as GateParams + const ctx = context ?? { cwd: process.cwd(), projectId: "default" } + + const gates = p.gates ?? ["pytest", "mypy", "ruff"] + const failFast = p.failFast ?? true + const directory = p.directory ?? ctx.cwd + + // Create a mock workcell for the directory + const workcell: WorkcellInfo = { + id: crypto.randomUUID(), + name: "gate-check", + directory, + branch: "main", + status: "in_use", + projectId: ctx.projectId, + createdAt: Date.now(), + useCount: 0, + } + + // Run gates + const results = await Verifier.run(workcell, { + gates, + failFast, + }) + + // Format results + const formattedResults = results.results.map((r) => ({ + gate: r.gate, + passed: r.passed, + critical: r.critical, + errorCount: r.diagnostics?.filter((d) => d.severity === "error").length ?? 0, + warningCount: + r.diagnostics?.filter((d) => d.severity === "warning").length ?? 0, + })) + + return { + success: results.allPassed, + allPassed: results.allPassed, + score: results.score, + summary: results.summary, + results: formattedResults, + } + }, +} + +// ============================================================================= +// TOOL REGISTRY +// ============================================================================= + +/** + * All ClawdStrike tools + */ +export const tools: ToolDefinition[] = [dispatchTool, speculateTool, gateTool] + +/** + * Get tool by name + */ +export function getTool(name: string): ToolDefinition | undefined { + return tools.find((t) => t.name === name) +} + +/** + * Register tools with an agent system + */ +export function registerTools(register: (tool: ToolDefinition) => void): void { + for (const tool of tools) { + register(tool) + } +} + +/** + * Execute a tool by name + */ +export async function executeTool( + name: string, + params: unknown, + context?: ToolContext +): Promise<unknown> { + const tool = getTool(name) + if (!tool) { + throw new Error(`Unknown tool: ${name}`) + } + return tool.handler(params, context) +} + +export default tools diff --git a/apps/terminal/src/tui/app.ts b/apps/terminal/src/tui/app.ts new file mode 100644 index 000000000..1ffcb466b --- /dev/null +++ b/apps/terminal/src/tui/app.ts @@ -0,0 +1,695 @@ +/** + * TUI App - Interactive Terminal User Interface for ClawdStrike + * + * Thin coordinator: lifecycle, input routing, screen registry. + * All screen rendering/input is delegated to screen modules. + */ + +import { TUI } from "./index" +import { VERSION, init, shutdown, isInitialized } from "../index" +import { Beads } from "../beads" +import { Telemetry } from "../telemetry" +import { Health } from "../health" +import { MCP } from "../mcp" +import { Hushd } from "../hushd" +import { Config } from "../config" +import { THEME, ESC, AGENTS } from "./theme" +import { renderStatusBar } from "./components/status-bar" +import type { Screen, ScreenContext, AppState, InputMode, Command, AppController } from "./types" +import { createInitialHuntState } from "./types" + +// Screen imports +import { createMainScreen } from "./screens/main" +import { setupScreen } from "./screens/setup" +import { integrationsScreen } from "./screens/integrations" +import { securityScreen } from "./screens/security" +import { auditScreen } from "./screens/audit" +import { policyScreen } from "./screens/policy" +import { resultScreen } from "./screens/result" + +// Hunt screen imports +import { huntWatchScreen } from "./screens/hunt-watch" +import { huntScanScreen } from "./screens/hunt-scan" +import { huntTimelineScreen } from "./screens/hunt-timeline" +import { huntRuleBuilderScreen } from "./screens/hunt-rule-builder" +import { huntQueryScreen } from "./screens/hunt-query" +import { huntDiffScreen } from "./screens/hunt-diff" +import { huntReportScreen } from "./screens/hunt-report" +import { huntMitreScreen } from "./screens/hunt-mitre" +import { huntPlaybookScreen } from "./screens/hunt-playbook" + +// ============================================================================= +// TUI APP +// ============================================================================= + +export class TUIApp implements AppController { + private state: AppState + private refreshTimer: ReturnType<typeof setInterval> | null = null + private animationTimer: ReturnType<typeof setInterval> | null = null + private width: number = 80 + private height: number = 24 + private cwd: string + + private commands: Command[] + private screens: Map<string, Screen> + + constructor(cwd: string = process.cwd()) { + this.cwd = cwd + this.state = { + promptBuffer: "", + agentIndex: 0, + inputMode: "main", + commandIndex: 0, + statusMessage: "", + isRunning: false, + activeRuns: 0, + openBeads: 0, + lastRefresh: new Date(), + health: null, + healthChecking: false, + animationFrame: 0, + hushdConnected: false, + recentEvents: [], + auditStats: null, + activePolicy: null, + securityError: null, + lastResult: null, + setupDetection: null, + setupStep: "detecting", + setupSandboxIndex: 0, + hunt: createInitialHuntState(), + } + + // Build commands list (including hunt commands) + this.commands = [ + { key: "d", label: "dispatch", description: "send task to agent", action: () => this.submitPrompt("dispatch") }, + { key: "s", label: "speculate", description: "parallel multi-agent", action: () => this.submitPrompt("speculate") }, + { key: "g", label: "gates", description: "run quality gates", action: () => this.runGates() }, + { key: "S", label: "security", description: "security overview", action: () => this.setScreen("security") }, + { key: "a", label: "audit", description: "audit log", action: () => this.setScreen("audit") }, + { key: "p", label: "policy", description: "active policy", action: () => this.setScreen("policy") }, + { key: "W", label: "watch", description: "live hunt stream", action: () => this.setScreen("hunt-watch") }, + { key: "X", label: "scan", description: "MCP scan explorer", action: () => this.setScreen("hunt-scan") }, + { key: "T", label: "timeline", description: "timeline replay", action: () => this.setScreen("hunt-timeline") }, + { key: "R", label: "rules", description: "correlation rule builder", action: () => this.setScreen("hunt-rule-builder") }, + { key: "Q", label: "query", description: "hunt query REPL", action: () => this.setScreen("hunt-query") }, + { key: "D", label: "diff", description: "scan change detection", action: () => this.setScreen("hunt-diff") }, + { key: "E", label: "evidence", description: "evidence report", action: () => this.setScreen("hunt-report") }, + { key: "M", label: "mitre", description: "MITRE ATT&CK heatmap", action: () => this.setScreen("hunt-mitre") }, + { key: "P", label: "playbook", description: "playbook runner", action: () => this.setScreen("hunt-playbook") }, + { key: "b", label: "beads", description: "view work graph", action: () => this.showBeads() }, + { key: "r", label: "runs", description: "active rollouts", action: () => this.showRuns() }, + { key: "i", label: "integrations", description: "system status", action: () => this.setScreen("integrations") }, + { key: "?", label: "help", description: "keyboard shortcuts", action: () => this.showHelp() }, + { key: "q", label: "quit", description: "exit clawdstrike", action: () => this.quit() }, + ] + + // Build screen registry + const mainScreen = createMainScreen(this.commands) + this.screens = new Map<string, Screen>([ + ["main", mainScreen], + ["commands", mainScreen], // commands overlay shares the main screen + ["setup", setupScreen], + ["integrations", integrationsScreen], + ["security", securityScreen], + ["audit", auditScreen], + ["policy", policyScreen], + ["result", resultScreen], + ["hunt-watch", huntWatchScreen], + ["hunt-scan", huntScanScreen], + ["hunt-timeline", huntTimelineScreen], + ["hunt-rule-builder", huntRuleBuilderScreen], + ["hunt-query", huntQueryScreen], + ["hunt-diff", huntDiffScreen], + ["hunt-report", huntReportScreen], + ["hunt-mitre", huntMitreScreen], + ["hunt-playbook", huntPlaybookScreen], + ]) + } + + // =========================================================================== + // LIFECYCLE + // =========================================================================== + + async start(): Promise<void> { + if (!isInitialized()) { + await init({ + beadsPath: `${this.cwd}/.beads`, + telemetryDir: `${this.cwd}/.clawdstrike/runs`, + }) + } + + this.updateTerminalSize() + this.setupInput() + + process.stdout.write(ESC.altScreen + ESC.hideCursor) + + await this.checkFirstRun() + + this.animationTimer = setInterval(() => { + this.state.animationFrame++ + if (this.state.inputMode === "main" || this.state.inputMode === "setup") { + this.render() + } + }, 80) + + if (this.state.inputMode === "setup") { + this.render() + return + } + + this.startBackgroundServices() + await this.refresh() + this.render() + } + + private startBackgroundServices(): void { + this.startMcpServer() + this.connectHushd() + this.runHealthcheck() + this.refreshTimer = setInterval(() => this.refresh(), 2000) + } + + private async startMcpServer(): Promise<void> { + try { + await MCP.start({ cwd: this.cwd, projectId: "default" }) + this.render() + } catch { + // MCP server failed to start - not critical + } + } + + runHealthcheck(): void { + this.state.healthChecking = true + this.render() + + Health.checkAll({ timeout: 2000 }) + .then((health) => { + this.state.health = health + }) + .catch(() => { + // Healthcheck failed + }) + .finally(() => { + this.state.healthChecking = false + this.render() + }) + } + + connectHushd(): void { + Hushd.init() + const client = Hushd.getClient() + + client.probe() + .then(async (connected) => { + this.state.hushdConnected = connected + + if (connected) { + const [policy, stats] = await Promise.all([ + client.getPolicy(), + client.getAuditStats(), + ]) + this.state.activePolicy = policy + this.state.auditStats = stats + + client.connectSSE( + (event) => { + this.state.recentEvents.unshift(event) + if (this.state.recentEvents.length > 50) { + this.state.recentEvents.length = 50 + } + this.render() + }, + () => { + this.state.hushdConnected = false + this.render() + } + ) + } + }) + .catch(() => { + this.state.hushdConnected = false + }) + .finally(() => { + this.render() + }) + } + + private async checkFirstRun(): Promise<void> { + if (await Config.exists(this.cwd)) return + + this.state.inputMode = "setup" + this.state.setupStep = "detecting" + this.render() + + const detection = await Config.detect(this.cwd) + this.state.setupDetection = detection + this.state.setupStep = "review" + this.state.setupSandboxIndex = 0 + this.render() + } + + private async cleanup(): Promise<void> { + if (this.refreshTimer) { + clearInterval(this.refreshTimer) + this.refreshTimer = null + } + + if (this.animationTimer) { + clearInterval(this.animationTimer) + this.animationTimer = null + } + + try { + await MCP.stop() + } catch { + // Ignore MCP shutdown errors + } + + Hushd.reset() + + process.stdout.write(ESC.showCursor + ESC.mainScreen) + + if (isInitialized()) { + await shutdown() + } + } + + private updateTerminalSize(): void { + this.width = process.stdout.columns || 80 + this.height = process.stdout.rows || 24 + } + + private setupInput(): void { + if (process.stdin.isTTY) { + process.stdin.setRawMode(true) + } + process.stdin.resume() + process.stdin.setEncoding("utf8") + + process.stdin.on("data", (key: string) => { + this.handleInput(key) + }) + + process.stdout.on("resize", () => { + this.updateTerminalSize() + this.render() + }) + } + + // =========================================================================== + // INPUT HANDLING + // =========================================================================== + + private handleInput(key: string): void { + // Ctrl+C always quits + if (key === "\x03") { + this.quit() + return + } + + const screen = this.screens.get(this.state.inputMode) + if (screen) { + const ctx = this.createContext() + screen.handleInput(key, ctx) + } + } + + // =========================================================================== + // RENDERING + // =========================================================================== + + render(): void { + let output = ESC.moveTo(1, 1) + + const ctx = this.createContext() + const screen = this.screens.get(this.state.inputMode) + let screenContent = screen ? screen.render(ctx) : "" + + // Apply background + status bar + const clearToEol = "\x1b[K" + const lines = screenContent.split("\n") + + // Inject status bar at the end if the screen doesn't have one + // (Hunt screens manage their own, existing screens had it in renderStatusBar) + const statusBar = this.buildStatusBar() + + const paddedLines = lines.map((line) => { + return THEME.bg + line + clearToEol + }) + + // Add status bar as last 2 lines + if (paddedLines.length < this.height) { + // Pad to fill screen minus status bar + while (paddedLines.length < this.height - 2) { + paddedLines.push(THEME.bg + clearToEol) + } + paddedLines.push(THEME.bg + statusBar + clearToEol) + } + + output += paddedLines.join("\n") + output += THEME.bg + ESC.clearToEndOfScreen + process.stdout.write(output) + } + + private buildStatusBar(): string { + return renderStatusBar( + { + version: VERSION, + cwd: this.cwd, + healthChecking: this.state.healthChecking, + health: this.state.health, + hushdConnected: this.state.hushdConnected, + deniedCount: this.state.recentEvents.filter(e => + e.type === "check" && (e.data as { decision?: string }).decision === "deny" + ).length, + activeRuns: this.state.activeRuns, + openBeads: this.state.openBeads, + agentId: AGENTS[this.state.agentIndex].id, + huntWatch: this.state.hunt.watch.running ? { + events: this.state.hunt.watch.stats?.events_processed ?? 0, + alerts: this.state.hunt.watch.stats?.alerts_fired ?? 0, + } : null, + huntScan: this.state.hunt.scan.loading ? { status: "scanning" } : null, + }, + this.width, + THEME, + ) + } + + private createContext(): ScreenContext { + return { + state: this.state, + width: this.width, + height: this.height - 2, // Reserve 2 lines for status bar + theme: THEME, + app: this, + } + } + + // =========================================================================== + // APP CONTROLLER INTERFACE + // =========================================================================== + + setScreen(mode: InputMode): void { + const oldScreen = this.screens.get(this.state.inputMode) + const ctx = this.createContext() + + if (oldScreen?.onExit) { + oldScreen.onExit(ctx) + } + + this.state.inputMode = mode + + const newScreen = this.screens.get(mode) + if (newScreen?.onEnter) { + newScreen.onEnter(ctx) + } + + this.render() + } + + getCwd(): string { + return this.cwd + } + + // =========================================================================== + // DATA REFRESH + // =========================================================================== + + private async refresh(): Promise<void> { + try { + const active = Telemetry.getActive() + this.state.activeRuns = active.length + + const beads = await Beads.query({ status: "open", limit: 100 }) + this.state.openBeads = beads.length + + this.state.lastRefresh = new Date() + + if (this.state.inputMode === "main" && !this.state.isRunning) { + this.render() + } + } catch { + // Ignore refresh errors + } + } + + // =========================================================================== + // ACTIONS + // =========================================================================== + + async submitPrompt(action: "dispatch" | "speculate"): Promise<void> { + const prompt = this.state.promptBuffer.trim() + if (!prompt) return + + const agent = AGENTS[this.state.agentIndex] + this.state.statusMessage = `${THEME.accent}⠋${THEME.reset} ${action === "dispatch" ? "Dispatching" : "Speculating"} via ${agent.name}...` + this.state.isRunning = true + this.render() + + const startTime = Date.now() + + try { + const { executeTool } = await import("../tools") + const context = { cwd: this.cwd, projectId: "default" } + + if (action === "dispatch") { + const raw = await executeTool("dispatch", { prompt, toolchain: agent.id }, context) as Record<string, unknown> + const duration = Date.now() - startTime + const routing = raw.routing as Record<string, unknown> | undefined + const result = raw.result as Record<string, unknown> | undefined + const verification = raw.verification as Record<string, unknown> | undefined + const telemetry = result?.telemetry as Record<string, unknown> | undefined + this.state.lastResult = { + success: raw.success as boolean, + taskId: (raw.taskId as string) ?? "", + agent: agent.name, + action, + routing: routing ? { + toolchain: routing.toolchain as string, + strategy: routing.strategy as string, + gates: (routing.gates as string[]) ?? [], + } : undefined, + execution: result ? { + success: result.success as boolean, + error: result.error as string | undefined, + model: telemetry?.model as string | undefined, + tokens: telemetry?.tokens as { input: number; output: number } | undefined, + cost: telemetry?.cost as number | undefined, + } : undefined, + verification: verification ? { + allPassed: verification.allPassed as boolean, + score: verification.score as number, + summary: verification.summary as string, + results: ((verification.results as Array<Record<string, unknown>>) ?? []).map(r => ({ + gate: r.gate as string, + passed: r.passed as boolean, + })), + } : undefined, + error: raw.error as string | undefined, + duration, + } + } else { + const raw = await executeTool("speculate", { prompt }, context) as Record<string, unknown> + const duration = Date.now() - startTime + this.state.lastResult = { + success: raw.success as boolean, + taskId: "", + agent: "multi", + action, + error: raw.success ? undefined : "No passing result from speculation", + duration, + } + } + } catch (err) { + this.state.lastResult = { + success: false, + taskId: "", + agent: agent.name, + action, + error: err instanceof Error ? err.message : String(err), + duration: Date.now() - startTime, + } + } + + this.state.isRunning = false + this.state.promptBuffer = "" + this.state.statusMessage = "" + this.state.inputMode = "result" + this.render() + } + + async runGates(): Promise<void> { + this.state.statusMessage = `${THEME.accent}⠋${THEME.reset} Running quality gates...` + this.render() + + try { + const { executeTool } = await import("../tools") + const context = { cwd: this.cwd, projectId: "default" } + const result = (await executeTool("gate", { directory: this.cwd }, context)) as { + success: boolean + score: number + } + + if (result.success) { + this.state.statusMessage = `${THEME.success}✓${THEME.reset} All gates passed (${result.score}/100)` + } else { + this.state.statusMessage = `${THEME.error}✗${THEME.reset} Gates failed (${result.score}/100)` + } + } catch (err) { + this.state.statusMessage = `${THEME.error}✗${THEME.reset} Error: ${err}` + } + + this.render() + + setTimeout(() => { + this.state.statusMessage = "" + this.render() + }, 5000) + } + + async showBeads(): Promise<void> { + await this.cleanup() + + console.log("") + console.log(THEME.secondary + THEME.bold + " ⟨ Beads ◇ Work Graph ⟩" + THEME.reset) + console.log(THEME.dim + " " + "═".repeat(40) + THEME.reset) + console.log("") + + try { + const beads = await Beads.query({ limit: 20 }) + + if (beads.length === 0) { + console.log(THEME.muted + " No tasks inscribed" + THEME.reset) + } else { + for (const bead of beads) { + const statusColor = + bead.status === "open" ? THEME.secondary : + bead.status === "in_progress" ? THEME.accent : + bead.status === "completed" ? THEME.success : + THEME.muted + const statusIcon = + bead.status === "open" ? "◇" : + bead.status === "in_progress" ? "◈" : + bead.status === "completed" ? "◆" : + "◇" + console.log(` ${statusColor}${statusIcon}${THEME.reset} ${THEME.dim}${bead.id}${THEME.reset} ${bead.title}`) + } + } + } catch (err) { + console.log(THEME.error + ` Error: ${err}` + THEME.reset) + } + + console.log("") + console.log(THEME.dim + " Press any key to return..." + THEME.reset) + + await this.waitForKey() + await this.start() + } + + async showRuns(): Promise<void> { + await this.cleanup() + + console.log("") + console.log(THEME.secondary + THEME.bold + " ⟨ Active Rollouts ⟩" + THEME.reset) + console.log(THEME.dim + " " + "═".repeat(40) + THEME.reset) + console.log("") + + try { + const active = Telemetry.getActive() + + if (active.length === 0) { + console.log(THEME.muted + " No active incantations" + THEME.reset) + } else { + for (const id of active) { + const rollout = await Telemetry.getRollout(id) + if (rollout) { + console.log(TUI.formatRollout(rollout)) + console.log("") + } + } + } + } catch (err) { + console.log(THEME.error + ` Error: ${err}` + THEME.reset) + } + + console.log("") + console.log(THEME.dim + " Press any key to return..." + THEME.reset) + + await this.waitForKey() + await this.start() + } + + async showHelp(): Promise<void> { + await this.cleanup() + + console.log("") + console.log(THEME.secondary + THEME.bold + " ⟨ ClawdStrike Grimoire ⟩" + THEME.reset) + console.log(THEME.dim + " " + "═".repeat(40) + THEME.reset) + console.log("") + console.log(THEME.white + THEME.bold + " Invocations" + THEME.reset) + console.log("") + console.log(` ${THEME.secondary}↑/↓${THEME.reset} ${THEME.muted}or${THEME.reset} ${THEME.secondary}j/k${THEME.reset} Navigate`) + console.log(` ${THEME.secondary}Enter${THEME.reset} ${THEME.muted}or${THEME.reset} ${THEME.secondary}Space${THEME.reset} Select`) + console.log(` ${THEME.secondary}d${THEME.reset} Dispatch`) + console.log(` ${THEME.secondary}s${THEME.reset} Speculate`) + console.log(` ${THEME.secondary}g${THEME.reset} Gates`) + console.log(` ${THEME.secondary}b${THEME.reset} Beads`) + console.log(` ${THEME.secondary}r${THEME.reset} Runs`) + console.log(` ${THEME.secondary}i${THEME.reset} Integrations`) + console.log(` ${THEME.secondary}Ctrl+S${THEME.reset} Security overview`) + console.log(` ${THEME.secondary}Ctrl+P${THEME.reset} Command palette`) + console.log("") + console.log(THEME.white + THEME.bold + " Hunt Commands" + THEME.reset) + console.log("") + console.log(` ${THEME.secondary}W${THEME.reset} Watch (live stream)`) + console.log(` ${THEME.secondary}X${THEME.reset} Scan (MCP explorer)`) + console.log(` ${THEME.secondary}T${THEME.reset} Timeline replay`) + console.log(` ${THEME.secondary}R${THEME.reset} Rule builder`) + console.log(` ${THEME.secondary}Q${THEME.reset} Query REPL`) + console.log(` ${THEME.secondary}D${THEME.reset} Diff (scan changes)`) + console.log(` ${THEME.secondary}E${THEME.reset} Evidence report`) + console.log(` ${THEME.secondary}M${THEME.reset} MITRE ATT&CK map`) + console.log(` ${THEME.secondary}P${THEME.reset} Playbook runner`) + console.log("") + console.log(THEME.dim + " Press any key to return..." + THEME.reset) + + await this.waitForKey() + await this.start() + } + + async quit(): Promise<void> { + // Call onExit on current screen + const screen = this.screens.get(this.state.inputMode) + if (screen?.onExit) { + screen.onExit(this.createContext()) + } + + await this.cleanup() + process.exit(0) + } + + private waitForKey(): Promise<void> { + return new Promise((resolve) => { + if (process.stdin.isTTY) { + process.stdin.setRawMode(true) + } + process.stdin.resume() + process.stdin.once("data", () => { + if (process.stdin.isTTY) { + process.stdin.setRawMode(false) + } + resolve() + }) + }) + } +} + +/** + * Launch the TUI app + */ +export async function launchTUI(cwd?: string): Promise<void> { + const app = new TUIApp(cwd) + await app.start() +} diff --git a/apps/terminal/src/tui/components/box.ts b/apps/terminal/src/tui/components/box.ts new file mode 100644 index 000000000..6af6b3b66 --- /dev/null +++ b/apps/terminal/src/tui/components/box.ts @@ -0,0 +1,84 @@ +/** + * Box component - renders a bordered box with an optional title. + */ + +import type { ThemeColors } from "./types" +import { fitString } from "./types" + +export interface BoxOptions { + style?: "single" | "double" | "rounded" + titleAlign?: "left" | "center" | "right" + padding?: number +} + +const BORDERS = { + single: { tl: "┌", tr: "┐", bl: "└", br: "┘", h: "─", v: "│" }, + double: { tl: "╔", tr: "╗", bl: "╚", br: "╝", h: "═", v: "║" }, + rounded: { tl: "╭", tr: "╮", bl: "╰", br: "╯", h: "─", v: "│" }, +} as const + +export function renderBox( + title: string, + contentLines: string[], + width: number, + theme: ThemeColors, + opts?: BoxOptions, +): string[] { + if (width < 4) return [] + const style = opts?.style ?? "single" + const titleAlign = opts?.titleAlign ?? "center" + const padding = opts?.padding ?? 0 + const b = BORDERS[style] + + const innerWidth = width - 2 + const paddedInnerWidth = innerWidth - padding * 2 + const padStr = " ".repeat(padding) + + // Build top border + let topBar: string + if (title) { + const decorated = ` \u27E8 ${title} \u27E9 ` + const titleLen = decorated.length + if (titleLen >= innerWidth) { + topBar = `${theme.dim}${b.tl}${b.h.repeat(innerWidth)}${b.tr}${theme.reset}` + } else { + const remaining = innerWidth - titleLen + let leftFill: number + let rightFill: number + if (titleAlign === "left") { + leftFill = 1 + rightFill = remaining - 1 + } else if (titleAlign === "right") { + rightFill = 1 + leftFill = remaining - 1 + } else { + leftFill = Math.floor(remaining / 2) + rightFill = remaining - leftFill + } + topBar = + `${theme.dim}${b.tl}${b.h.repeat(leftFill)}${theme.reset}` + + `${theme.secondary}${decorated}${theme.reset}` + + `${theme.dim}${b.h.repeat(rightFill)}${b.tr}${theme.reset}` + } + } else { + topBar = `${theme.dim}${b.tl}${b.h.repeat(innerWidth)}${b.tr}${theme.reset}` + } + + // Build bottom border + const bottomBar = `${theme.dim}${b.bl}${b.h.repeat(innerWidth)}${b.br}${theme.reset}` + + // Build content lines + const lines: string[] = [topBar] + if (contentLines.length === 0) { + lines.push(`${theme.dim}${b.v}${theme.reset}${" ".repeat(innerWidth)}${theme.dim}${b.v}${theme.reset}`) + } else { + for (const line of contentLines) { + const fitted = fitString(line, paddedInnerWidth) + lines.push( + `${theme.dim}${b.v}${theme.reset}${padStr}${fitted}${padStr}${theme.dim}${b.v}${theme.reset}`, + ) + } + } + lines.push(bottomBar) + return lines +} diff --git a/apps/terminal/src/tui/components/form.ts b/apps/terminal/src/tui/components/form.ts new file mode 100644 index 000000000..b0eec2be0 --- /dev/null +++ b/apps/terminal/src/tui/components/form.ts @@ -0,0 +1,157 @@ +/** + * Form component - renders a vertical form with labeled fields. + */ + +import type { ThemeColors } from "./types" +import { fitString } from "./types" + +export type FormFieldType = "text" | "select" | "toggle" + +export interface TextField { + type: "text" + label: string + value: string + placeholder?: string +} + +export interface SelectField { + type: "select" + label: string + options: string[] + selectedIndex: number +} + +export interface ToggleField { + type: "toggle" + label: string + value: boolean +} + +export type FormField = TextField | SelectField | ToggleField + +export interface FormState { + fields: FormField[] + focusedIndex: number +} + +export function renderForm( + state: FormState, + width: number, + theme: ThemeColors, +): string[] { + if (width <= 0) return [] + const lines: string[] = [] + + for (let i = 0; i < state.fields.length; i++) { + const field = state.fields[i] + const isFocused = i === state.focusedIndex + const focusIndicator = isFocused + ? `${theme.accent}${theme.bold}\u25B8${theme.reset} ` + : " " + + // Label line + const labelColor = isFocused ? theme.secondary : theme.muted + lines.push(fitString(`${focusIndicator}${labelColor}${field.label}${theme.reset}`, width)) + + // Value line + switch (field.type) { + case "text": { + const displayValue = field.value || field.placeholder || "" + const valueColor = field.value + ? theme.white + : `${theme.dim}${theme.italic}` + const cursor = isFocused ? `${theme.accent}\u2588${theme.reset}` : "" + const bracket = isFocused ? theme.accent : theme.dim + lines.push( + fitString( + ` ${bracket}[${theme.reset} ${valueColor}${displayValue}${theme.reset}${cursor} ${bracket}]${theme.reset}`, + width, + ), + ) + break + } + case "select": { + const selected = field.options[field.selectedIndex] ?? "" + const leftArrow = isFocused ? `${theme.accent}\u25C0${theme.reset}` : " " + const rightArrow = isFocused ? `${theme.accent}\u25B6${theme.reset}` : " " + lines.push( + fitString( + ` ${leftArrow} ${theme.white}${selected}${theme.reset} ${rightArrow}`, + width, + ), + ) + break + } + case "toggle": { + const indicator = field.value + ? `${theme.success}[\u2713]${theme.reset}` + : `${theme.dim}[ ]${theme.reset}` + const label = field.value ? "On" : "Off" + lines.push(fitString(` ${indicator} ${theme.white}${label}${theme.reset}`, width)) + break + } + } + + // Spacing between fields + if (i < state.fields.length - 1) { + lines.push(" ".repeat(width)) + } + } + + return lines +} + +export function focusNext(state: FormState): FormState { + if (state.fields.length === 0) return state + return { + ...state, + focusedIndex: (state.focusedIndex + 1) % state.fields.length, + } +} + +export function focusPrev(state: FormState): FormState { + if (state.fields.length === 0) return state + return { + ...state, + focusedIndex: (state.focusedIndex - 1 + state.fields.length) % state.fields.length, + } +} + +export function handleFieldInput(state: FormState, key: string): FormState { + if (state.fields.length === 0) return state + const field = state.fields[state.focusedIndex] + const newFields = [...state.fields] + + switch (field.type) { + case "text": { + let newValue = field.value + if (key === "backspace" || key === "\x7f" || key === "\b") { + newValue = newValue.slice(0, -1) + } else if (key.length === 1 && key >= " ") { + newValue = newValue + key + } else { + return state // no change for unrecognized keys + } + newFields[state.focusedIndex] = { ...field, value: newValue } + break + } + case "select": { + if (key === "left" || key === "h") { + const newIdx = (field.selectedIndex - 1 + field.options.length) % field.options.length + newFields[state.focusedIndex] = { ...field, selectedIndex: newIdx } + } else if (key === "right" || key === "l") { + const newIdx = (field.selectedIndex + 1) % field.options.length + newFields[state.focusedIndex] = { ...field, selectedIndex: newIdx } + } + break + } + case "toggle": { + if (key === " " || key === "enter") { + newFields[state.focusedIndex] = { ...field, value: !field.value } + } + break + } + } + + return { ...state, fields: newFields } +} diff --git a/apps/terminal/src/tui/components/grid.ts b/apps/terminal/src/tui/components/grid.ts new file mode 100644 index 000000000..9473c27b2 --- /dev/null +++ b/apps/terminal/src/tui/components/grid.ts @@ -0,0 +1,130 @@ +/** + * Grid / heatmap component - renders a grid with color-intensity cells. + */ + +import type { ThemeColors } from "./types" +import { fitString } from "./types" + +export interface GridCell { + value: number + label?: string +} + +export interface GridSelection { + row: number + col: number +} + +const BLOCKS = [" ", "\u2591", "\u2592", "\u2593", "\u2588"] + +function valueToBlock(value: number, maxValue: number): string { + if (maxValue <= 0 || value <= 0) return BLOCKS[0] + const normalized = Math.min(value / maxValue, 1) + const idx = Math.min(Math.floor(normalized * (BLOCKS.length - 1)) + 1, BLOCKS.length - 1) + return BLOCKS[idx] +} + +function valueToColor(value: number, maxValue: number, theme: ThemeColors): string { + if (value <= 0) return theme.dim + const ratio = maxValue > 0 ? Math.min(value / maxValue, 1) : 0 + if (ratio < 0.25) return theme.dim + if (ratio < 0.5) return theme.muted + if (ratio < 0.75) return theme.warning + return theme.accent +} + +export function renderGrid( + columns: string[], + rows: string[], + cells: GridCell[][], + selected: GridSelection | null, + width: number, + height: number, + theme: ThemeColors, +): string[] { + if (width <= 0 || height <= 0 || columns.length === 0 || rows.length === 0) return [] + + // Find max value for color scaling + let maxValue = 0 + for (const row of cells) { + for (const cell of row) { + if (cell.value > maxValue) maxValue = cell.value + } + } + + // Calculate column widths + const rowHeaderWidth = Math.min( + Math.max(...rows.map((r) => r.length), 4) + 1, + Math.floor(width * 0.3), + ) + const remainingWidth = width - rowHeaderWidth + const cellWidth = Math.max(3, Math.floor(remainingWidth / columns.length)) + + const lines: string[] = [] + + // Column headers + let headerLine = " ".repeat(rowHeaderWidth) + for (const col of columns) { + const truncCol = col.length > cellWidth - 1 ? col.slice(0, cellWidth - 1) : col + headerLine += fitString(`${theme.muted}${truncCol}${theme.reset}`, cellWidth) + } + lines.push(fitString(headerLine, width)) + + // Separator + lines.push(fitString(`${theme.dim}${"\u2500".repeat(width)}${theme.reset}`, width)) + + // Rows + for (let r = 0; r < rows.length && lines.length < height; r++) { + const rowLabel = rows[r].length > rowHeaderWidth - 1 + ? rows[r].slice(0, rowHeaderWidth - 2) + "\u2026" + : rows[r] + let line = fitString(`${theme.muted}${rowLabel}${theme.reset}`, rowHeaderWidth) + + for (let c = 0; c < columns.length; c++) { + const cell = cells[r]?.[c] ?? { value: 0 } + const block = valueToBlock(cell.value, maxValue) + const color = valueToColor(cell.value, maxValue, theme) + const isSelected = selected !== null && selected.row === r && selected.col === c + + let cellStr: string + if (cell.label) { + cellStr = `${color}${block}${cell.label}${theme.reset}` + } else { + cellStr = `${color}${block.repeat(Math.max(1, cellWidth - 1))}${theme.reset}` + } + + if (isSelected) { + cellStr = `${theme.bold}${theme.accent}[${theme.reset}${cellStr}${theme.bold}${theme.accent}]${theme.reset}` + } + + line += fitString(cellStr, cellWidth) + } + + lines.push(fitString(line, width)) + } + + // Pad to height + while (lines.length < height) { + lines.push(" ".repeat(width)) + } + + return lines +} + +export function moveSelection( + sel: GridSelection, + direction: "up" | "down" | "left" | "right", + rowCount: number, + colCount: number, +): GridSelection { + switch (direction) { + case "up": + return { ...sel, row: Math.max(0, sel.row - 1) } + case "down": + return { ...sel, row: Math.min(rowCount - 1, sel.row + 1) } + case "left": + return { ...sel, col: Math.max(0, sel.col - 1) } + case "right": + return { ...sel, col: Math.min(colCount - 1, sel.col + 1) } + } +} diff --git a/apps/terminal/src/tui/components/index.ts b/apps/terminal/src/tui/components/index.ts new file mode 100644 index 000000000..c615ce0fc --- /dev/null +++ b/apps/terminal/src/tui/components/index.ts @@ -0,0 +1,53 @@ +/** + * TUI Components - Pure functional rendering components. + * + * Each component is a pure function that takes state, dimensions, and theme + * configuration, and returns string[] (rendered lines). + */ + +export type { ThemeColors } from "./types" +export { stripAnsi, visibleLength, fitString, truncateAnsi } from "./types" + +export type { BoxOptions } from "./box" +export { renderBox } from "./box" + +export type { ListItem, ListViewport } from "./scrollable-list" +export { renderList, scrollUp, scrollDown } from "./scrollable-list" + +export type { TreeNode, TreeViewport } from "./tree-view" +export { + renderTree, + flattenTree, + toggleExpand, + moveUp as treeMoveUp, + moveDown as treeMoveDown, +} from "./tree-view" + +export type { + FormFieldType, + TextField, + SelectField, + ToggleField, + FormField, + FormState, +} from "./form" +export { renderForm, focusNext, focusPrev, handleFieldInput } from "./form" + +export type { GridCell, GridSelection } from "./grid" +export { renderGrid, moveSelection } from "./grid" + +export type { LogLine, LogState } from "./streaming-log" +export { + renderLog, + appendLine, + togglePause, + scrollLogUp, + scrollLogDown, + clearLog, + createLogState, +} from "./streaming-log" + +export { renderSplit } from "./split-pane" + +export type { StatusBarData } from "./status-bar" +export { renderStatusBar } from "./status-bar" diff --git a/apps/terminal/src/tui/components/scrollable-list.ts b/apps/terminal/src/tui/components/scrollable-list.ts new file mode 100644 index 000000000..e670077e9 --- /dev/null +++ b/apps/terminal/src/tui/components/scrollable-list.ts @@ -0,0 +1,93 @@ +/** + * Scrollable list component with selection highlight and scroll indicators. + */ + +import type { ThemeColors } from "./types" +import { fitString } from "./types" + +export interface ListItem { + label: string + plainLength: number + key?: string +} + +export interface ListViewport { + offset: number + selected: number +} + +export function renderList( + items: ListItem[], + viewport: ListViewport, + height: number, + width: number, + theme: ThemeColors, +): string[] { + if (height <= 0 || width <= 0) return [] + if (items.length === 0) { + const empty = fitString(`${theme.muted} (empty)${theme.reset}`, width) + const lines: string[] = [empty] + for (let i = 1; i < height; i++) lines.push(" ".repeat(width)) + return lines + } + + const hasMoreAbove = viewport.offset > 0 + const hasMoreBelow = viewport.offset + height < items.length + + // Reserve lines for scroll indicators + const indicatorLines = (hasMoreAbove ? 1 : 0) + (hasMoreBelow ? 1 : 0) + const visibleHeight = height - indicatorLines + + const lines: string[] = [] + + if (hasMoreAbove) { + lines.push(fitString(`${theme.dim} \u25B2 more${theme.reset}`, width)) + } + + for (let i = 0; i < visibleHeight; i++) { + const idx = viewport.offset + i + if (idx >= items.length) { + lines.push(" ".repeat(width)) + continue + } + const item = items[idx] + const isSelected = idx === viewport.selected + if (isSelected) { + const marker = `${theme.accent}${theme.bold} \u25B8 ${theme.reset}` + const label = `${theme.white}${theme.bold}${item.label}${theme.reset}` + lines.push(fitString(`${marker}${label}`, width)) + } else { + lines.push(fitString(` ${item.label}`, width)) + } + } + + if (hasMoreBelow) { + lines.push(fitString(`${theme.dim} \u25BC more${theme.reset}`, width)) + } + + // Pad to fill height + while (lines.length < height) { + lines.push(" ".repeat(width)) + } + + return lines +} + +export function scrollUp(viewport: ListViewport): ListViewport { + const newSelected = Math.max(0, viewport.selected - 1) + const newOffset = newSelected < viewport.offset ? newSelected : viewport.offset + return { offset: newOffset, selected: newSelected } +} + +export function scrollDown( + viewport: ListViewport, + itemCount: number, + viewportHeight: number, +): ListViewport { + const newSelected = Math.min(itemCount - 1, viewport.selected + 1) + // Account for scroll indicator taking a line + const effectiveHeight = viewportHeight - (viewport.offset > 0 ? 1 : 0) - 1 // reserve for bottom indicator + const maxVisible = viewport.offset + Math.max(1, effectiveHeight) - 1 + const newOffset = newSelected > maxVisible ? viewport.offset + 1 : viewport.offset + return { offset: newOffset, selected: newSelected } +} diff --git a/apps/terminal/src/tui/components/split-pane.ts b/apps/terminal/src/tui/components/split-pane.ts new file mode 100644 index 000000000..f5f75958a --- /dev/null +++ b/apps/terminal/src/tui/components/split-pane.ts @@ -0,0 +1,36 @@ +/** + * Split pane component - renders two panes side by side with a divider. + */ + +import type { ThemeColors } from "./types" +import { fitString } from "./types" + +export function renderSplit( + leftLines: string[], + rightLines: string[], + width: number, + height: number, + theme: ThemeColors, + ratio = 0.5, +): string[] { + if (width < 3 || height <= 0) return [] + + const dividerWidth = 1 + const leftWidth = Math.max(1, Math.floor((width - dividerWidth) * ratio)) + const rightWidth = Math.max(1, width - leftWidth - dividerWidth) + + const lines: string[] = [] + + for (let i = 0; i < height; i++) { + const leftContent = i < leftLines.length ? leftLines[i] : "" + const rightContent = i < rightLines.length ? rightLines[i] : "" + + const left = fitString(leftContent, leftWidth) + const right = fitString(rightContent, rightWidth) + const divider = `${theme.dim}\u2502${theme.reset}` + + lines.push(`${left}${divider}${right}`) + } + + return lines +} diff --git a/apps/terminal/src/tui/components/status-bar.ts b/apps/terminal/src/tui/components/status-bar.ts new file mode 100644 index 000000000..fca07458b --- /dev/null +++ b/apps/terminal/src/tui/components/status-bar.ts @@ -0,0 +1,105 @@ +/** + * Status bar component - renders the bottom status bar. + */ + +import type { ThemeColors } from "./types" +import { fitString, stripAnsi } from "./types" + +export interface StatusBarData { + version: string + cwd: string + healthChecking: boolean + health: { + security: Array<{ available: boolean }> + ai: Array<{ available: boolean }> + infra: Array<{ available: boolean }> + mcp: Array<{ available: boolean }> + } | null + hushdConnected: boolean + deniedCount: number + activeRuns: number + openBeads: number + agentId: string + huntWatch?: { events: number; alerts: number } | null + huntScan?: { status: string } | null +} + +function healthDot(items: Array<{ available: boolean }> | undefined, theme: ThemeColors): string { + if (!items || items.length === 0) return `${theme.dim}\u25CB${theme.reset}` + const allUp = items.every((i) => i.available) + const anyUp = items.some((i) => i.available) + if (allUp) return `${theme.success}\u25CF${theme.reset}` + if (anyUp) return `${theme.warning}\u25CF${theme.reset}` + return `${theme.error}\u25CF${theme.reset}` +} + +export function renderStatusBar( + data: StatusBarData, + width: number, + theme: ThemeColors, +): string { + if (width <= 0) return "" + + const segments: string[] = [] + + // Version + segments.push(`${theme.dim}v${data.version}${theme.reset}`) + + // Health dots + if (data.healthChecking) { + segments.push(`${theme.dim}\u2026${theme.reset}`) + } else if (data.health) { + const sec = healthDot(data.health.security, theme) + const ai = healthDot(data.health.ai, theme) + const infra = healthDot(data.health.infra, theme) + const mcp = healthDot(data.health.mcp, theme) + segments.push(`${sec}${ai}${infra}${mcp}`) + } + + // Hushd connection + if (data.hushdConnected) { + segments.push(`${theme.success}\u25CF${theme.reset}${theme.dim} hushd${theme.reset}`) + } + + // Denied count + if (data.deniedCount > 0) { + segments.push(`${theme.error}\u2716 ${data.deniedCount}${theme.reset}`) + } + + // Active runs + if (data.activeRuns > 0) { + segments.push(`${theme.secondary}\u25B6 ${data.activeRuns}${theme.reset}`) + } + + // Open beads + if (data.openBeads > 0) { + segments.push(`${theme.tertiary}\u25C8 ${data.openBeads}${theme.reset}`) + } + + // Hunt watch + if (data.huntWatch) { + const evtColor = data.huntWatch.alerts > 0 ? theme.warning : theme.muted + segments.push( + `${evtColor}\u2302 ${data.huntWatch.events}e/${data.huntWatch.alerts}a${theme.reset}`, + ) + } + + // Hunt scan + if (data.huntScan) { + segments.push(`${theme.muted}\u2261 ${data.huntScan.status}${theme.reset}`) + } + + const left = segments.join(` ${theme.dim}\u2502${theme.reset} `) + + // Right side: cwd + agent + const cwdShort = + data.cwd.length > 30 ? "\u2026" + data.cwd.slice(-29) : data.cwd + const right = `${theme.dim}${cwdShort}${theme.reset} ${theme.dim}${data.agentId}${theme.reset}` + + // Calculate spacing + const leftVisible = stripAnsi(left).length + const rightVisible = stripAnsi(right).length + const gap = Math.max(1, width - leftVisible - rightVisible) + + return fitString(`${left}${" ".repeat(gap)}${right}`, width) +} diff --git a/apps/terminal/src/tui/components/streaming-log.ts b/apps/terminal/src/tui/components/streaming-log.ts new file mode 100644 index 000000000..5d65cc141 --- /dev/null +++ b/apps/terminal/src/tui/components/streaming-log.ts @@ -0,0 +1,139 @@ +/** + * Streaming log component - auto-scrolling log view with pause support. + */ + +import type { ThemeColors } from "./types" +import { fitString } from "./types" + +export interface LogLine { + text: string + plainLength: number + timestamp?: string +} + +export interface LogState { + lines: LogLine[] + maxLines: number + viewport: number + paused: boolean +} + +export function createLogState(maxLines = 1000): LogState { + return { + lines: [], + maxLines, + viewport: 0, + paused: false, + } +} + +export function renderLog( + state: LogState, + height: number, + width: number, + theme: ThemeColors, +): string[] { + if (height <= 0 || width <= 0) return [] + + const total = state.lines.length + const statusHeight = 1 + const viewHeight = height - statusHeight + + if (viewHeight <= 0) { + // Only room for status + return [renderLogStatus(state, width, theme)] + } + + const lines: string[] = [] + + if (total === 0) { + for (let i = 0; i < viewHeight; i++) { + if (i === Math.floor(viewHeight / 2)) { + lines.push(fitString(`${theme.dim} Waiting for log output...${theme.reset}`, width)) + } else { + lines.push(" ".repeat(width)) + } + } + } else { + // Calculate visible window + let startIdx: number + if (state.paused && state.viewport > 0) { + // Scroll offset from bottom + startIdx = Math.max(0, total - state.viewport - viewHeight) + } else { + // Auto-scroll: show most recent + startIdx = Math.max(0, total - viewHeight) + } + + for (let i = 0; i < viewHeight; i++) { + const idx = startIdx + i + if (idx >= total) { + lines.push(" ".repeat(width)) + continue + } + const logLine = state.lines[idx] + const ts = logLine.timestamp + ? `${theme.dim}${logLine.timestamp} ${theme.reset}` + : "" + lines.push(fitString(`${ts}${logLine.text}`, width)) + } + } + + // Status bar + lines.push(renderLogStatus(state, width, theme)) + + return lines +} + +function renderLogStatus(state: LogState, width: number, theme: ThemeColors): string { + const pauseIndicator = state.paused + ? `${theme.warning} PAUSED${theme.reset}` + : `${theme.success} LIVE${theme.reset}` + const lineCount = `${theme.dim}${state.lines.length} lines${theme.reset}` + const status = `${pauseIndicator} ${theme.dim}\u2502${theme.reset} ${lineCount}` + return fitString(status, width) +} + +export function appendLine(state: LogState, line: LogLine): LogState { + const newLines = [...state.lines, line] + // Ring buffer: trim from front if over capacity + if (newLines.length > state.maxLines) { + const excess = newLines.length - state.maxLines + newLines.splice(0, excess) + } + return { ...state, lines: newLines } +} + +export function togglePause(state: LogState): LogState { + return { + ...state, + paused: !state.paused, + // Reset viewport when unpausing (return to auto-scroll) + viewport: state.paused ? 0 : state.viewport, + } +} + +export function scrollLogUp(state: LogState, amount = 1): LogState { + if (!state.paused) return state + const maxScroll = Math.max(0, state.lines.length - 1) + return { + ...state, + viewport: Math.min(maxScroll, state.viewport + amount), + } +} + +export function scrollLogDown(state: LogState, amount = 1): LogState { + if (!state.paused) return state + return { + ...state, + viewport: Math.max(0, state.viewport - amount), + } +} + +export function clearLog(state: LogState): LogState { + return { + ...state, + lines: [], + viewport: 0, + } +} diff --git a/apps/terminal/src/tui/components/tree-view.ts b/apps/terminal/src/tui/components/tree-view.ts new file mode 100644 index 000000000..2be49570a --- /dev/null +++ b/apps/terminal/src/tui/components/tree-view.ts @@ -0,0 +1,143 @@ +/** + * Tree view component with expandable nodes and tree-drawing characters. + */ + +import type { ThemeColors } from "./types" +import { fitString } from "./types" + +export interface TreeNode { + label: string + plainLength: number + key: string + children?: TreeNode[] + expanded?: boolean + icon?: string + color?: string +} + +export interface TreeViewport { + offset: number + selected: number + expandedKeys: Set<string> +} + +interface FlatEntry { + node: TreeNode + depth: number +} + +export function flattenTree( + nodes: TreeNode[], + expandedKeys: Set<string>, +): FlatEntry[] { + const result: FlatEntry[] = [] + function walk(list: TreeNode[], depth: number) { + for (const node of list) { + result.push({ node, depth }) + if (node.children && node.children.length > 0 && expandedKeys.has(node.key)) { + walk(node.children, depth + 1) + } + } + } + walk(nodes, 0) + return result +} + +export function renderTree( + nodes: TreeNode[], + viewport: TreeViewport, + height: number, + width: number, + theme: ThemeColors, +): string[] { + if (height <= 0 || width <= 0) return [] + const flat = flattenTree(nodes, viewport.expandedKeys) + if (flat.length === 0) { + const lines = [fitString(`${theme.muted} (empty)${theme.reset}`, width)] + for (let i = 1; i < height; i++) lines.push(" ".repeat(width)) + return lines + } + + const hasMoreAbove = viewport.offset > 0 + const hasMoreBelow = viewport.offset + height < flat.length + const indicatorLines = (hasMoreAbove ? 1 : 0) + (hasMoreBelow ? 1 : 0) + const visibleHeight = height - indicatorLines + + const lines: string[] = [] + + if (hasMoreAbove) { + lines.push(fitString(`${theme.dim} \u25B2 more${theme.reset}`, width)) + } + + for (let i = 0; i < visibleHeight; i++) { + const idx = viewport.offset + i + if (idx >= flat.length) { + lines.push(" ".repeat(width)) + continue + } + const { node, depth } = flat[idx] + const isSelected = idx === viewport.selected + const hasChildren = node.children && node.children.length > 0 + const isExpanded = viewport.expandedKeys.has(node.key) + + // Build indent with tree characters + const indent = depth > 0 ? " ".repeat(depth - 1) + "\u251C\u2500\u2500 " : "" + + // Expand/collapse indicator + let indicator = " " + if (hasChildren) { + indicator = isExpanded ? `${theme.secondary}\u25BE ${theme.reset}` : `${theme.muted}\u25B8 ${theme.reset}` + } + + // Icon + const icon = node.icon ? `${node.icon} ` : "" + + // Color + const labelColor = node.color ?? theme.white + + // Selection highlight + const prefix = isSelected + ? `${theme.accent}${theme.bold}\u25B8${theme.reset} ` + : " " + + const line = `${prefix}${theme.dim}${indent}${theme.reset}${indicator}${labelColor}${icon}${node.label}${theme.reset}` + lines.push(fitString(line, width)) + } + + if (hasMoreBelow) { + lines.push(fitString(`${theme.dim} \u25BC more${theme.reset}`, width)) + } + + while (lines.length < height) { + lines.push(" ".repeat(width)) + } + + return lines +} + +export function toggleExpand(viewport: TreeViewport, key: string): TreeViewport { + const newExpanded = new Set(viewport.expandedKeys) + if (newExpanded.has(key)) { + newExpanded.delete(key) + } else { + newExpanded.add(key) + } + return { ...viewport, expandedKeys: newExpanded } +} + +export function moveUp(viewport: TreeViewport): TreeViewport { + const newSelected = Math.max(0, viewport.selected - 1) + const newOffset = newSelected < viewport.offset ? newSelected : viewport.offset + return { ...viewport, offset: newOffset, selected: newSelected } +} + +export function moveDown( + viewport: TreeViewport, + visibleCount: number, + viewportHeight: number, +): TreeViewport { + const newSelected = Math.min(visibleCount - 1, viewport.selected + 1) + const maxVisible = viewport.offset + viewportHeight - 1 + const newOffset = newSelected > maxVisible ? viewport.offset + 1 : viewport.offset + return { ...viewport, offset: newOffset, selected: newSelected } +} diff --git a/apps/terminal/src/tui/components/types.ts b/apps/terminal/src/tui/components/types.ts new file mode 100644 index 000000000..68c87cf77 --- /dev/null +++ b/apps/terminal/src/tui/components/types.ts @@ -0,0 +1,66 @@ +/** + * Shared types for TUI components. + */ + +export interface ThemeColors { + accent: string + secondary: string + tertiary: string + success: string + warning: string + error: string + muted: string + dim: string + white: string + bg: string + reset: string + bold: string + dimAttr: string + italic: string +} + +// ---- ANSI Utilities ---- + +/** Strip ANSI escape sequences from a string */ +export function stripAnsi(s: string): string { + return s.replace(/\x1b\[[0-9;]*m/g, "") +} + +/** Get the visible (non-ANSI) length of a string */ +export function visibleLength(s: string): number { + return stripAnsi(s).length +} + +/** Pad or truncate an ANSI string to exactly `width` visible characters */ +export function fitString(s: string, width: number, padChar = " "): string { + if (width <= 0) return "" + const vis = stripAnsi(s) + if (vis.length === width) return s + if (vis.length > width) return truncateAnsi(s, width) + return s + padChar.repeat(width - vis.length) +} + +/** Truncate an ANSI string to `maxWidth` visible characters */ +export function truncateAnsi(s: string, maxWidth: number): string { + if (maxWidth <= 0) return "" + let visible = 0 + let result = "" + let inEscape = false + for (let i = 0; i < s.length; i++) { + const ch = s[i] + if (ch === "\x1b") { + inEscape = true + result += ch + continue + } + if (inEscape) { + result += ch + if (ch === "m") inEscape = false + continue + } + if (visible >= maxWidth) break + result += ch + visible++ + } + return result +} diff --git a/apps/terminal/src/tui/index.ts b/apps/terminal/src/tui/index.ts new file mode 100644 index 000000000..d47fc8721 --- /dev/null +++ b/apps/terminal/src/tui/index.ts @@ -0,0 +1,466 @@ +/** + * TUI - Terminal User Interface for ClawdStrike + * + * Provides formatted output for terminal display including: + * - Status indicators + * - Progress display + * - Result formatting + * - Color support + */ + +import type { + Toolchain, + TaskStatus, + GateResult, + GateResults, + ExecutionResult, + RoutingDecision, + SpeculationResult, + Rollout, +} from "../types" + +// ============================================================================= +// COLORS AND STYLING +// ============================================================================= + +const COLORS = { + reset: "\x1b[0m", + bold: "\x1b[1m", + dim: "\x1b[2m", + + // Foreground + red: "\x1b[31m", + green: "\x1b[32m", + yellow: "\x1b[33m", + blue: "\x1b[34m", + magenta: "\x1b[35m", + cyan: "\x1b[36m", + white: "\x1b[37m", + gray: "\x1b[90m", +} as const + +const ICONS = { + check: "✓", + cross: "✗", + warning: "⚠", + info: "ℹ", + spinner: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"], + arrow: "→", + bullet: "•", + box: "□", + boxChecked: "■", +} as const + +// Module state +let useColors = true +let spinnerFrame = 0 + +/** + * TUI namespace - Terminal output formatting + */ +export namespace TUI { + /** + * Enable or disable color output + */ + export function setColors(enabled: boolean): void { + useColors = enabled + } + + /** + * Check if colors are enabled + */ + export function colorsEnabled(): boolean { + return useColors + } + + // =========================================================================== + // COLOR HELPERS + // =========================================================================== + + function c(color: keyof typeof COLORS, text: string): string { + if (!useColors) return text + return `${COLORS[color]}${text}${COLORS.reset}` + } + + function bold(text: string): string { + return c("bold", text) + } + + function dim(text: string): string { + return c("dim", text) + } + + // =========================================================================== + // STATUS FORMATTING + // =========================================================================== + + /** + * Format task status with color + */ + export function formatStatus(status: TaskStatus): string { + switch (status) { + case "pending": + return c("gray", `${ICONS.box} pending`) + case "routing": + return c("cyan", `${ICONS.arrow} routing`) + case "executing": + return c("blue", `${ICONS.spinner[0]} executing`) + case "verifying": + return c("yellow", `${ICONS.spinner[0]} verifying`) + case "completed": + return c("green", `${ICONS.check} completed`) + case "failed": + return c("red", `${ICONS.cross} failed`) + case "cancelled": + return c("gray", `${ICONS.cross} cancelled`) + default: + return status + } + } + + /** + * Format toolchain name with color + */ + export function formatToolchain(toolchain: Toolchain): string { + switch (toolchain) { + case "codex": + return c("magenta", "codex") + case "claude": + return c("cyan", "claude") + case "opencode": + return c("green", "opencode") + case "crush": + return c("yellow", "crush") + default: + return toolchain + } + } + + /** + * Format gate result + */ + export function formatGateResult(result: GateResult): string { + const icon = result.passed ? c("green", ICONS.check) : c("red", ICONS.cross) + const name = result.critical ? bold(result.gate) : result.gate + const timing = dim( + `${result.timing.completedAt - result.timing.startedAt}ms` + ) + + const diagnostics = result.diagnostics ?? [] + const errors = diagnostics.filter((d) => d.severity === "error").length + const warnings = diagnostics.filter((d) => d.severity === "warning").length + + let suffix = "" + if (errors > 0) { + suffix += c("red", ` ${errors} error${errors > 1 ? "s" : ""}`) + } + if (warnings > 0) { + suffix += c("yellow", ` ${warnings} warning${warnings > 1 ? "s" : ""}`) + } + + return `${icon} ${name} ${timing}${suffix}` + } + + /** + * Format gate results summary + */ + export function formatGateResults(results: GateResults): string { + const lines: string[] = [] + + // Header + const icon = results.allPassed + ? c("green", ICONS.check) + : c("red", ICONS.cross) + const score = results.allPassed + ? c("green", `${results.score}/100`) + : c("red", `${results.score}/100`) + lines.push(`${icon} Gates: ${score}`) + + // Individual results + for (const result of results.results) { + lines.push(` ${formatGateResult(result)}`) + } + + // Summary + lines.push(dim(` ${results.summary}`)) + + return lines.join("\n") + } + + // =========================================================================== + // EXECUTION FORMATTING + // =========================================================================== + + /** + * Format execution result + */ + export function formatExecutionResult(result: ExecutionResult): string { + const lines: string[] = [] + + // Header + const icon = result.success + ? c("green", ICONS.check) + : c("red", ICONS.cross) + const toolchain = formatToolchain(result.toolchain) + const duration = result.telemetry.completedAt - result.telemetry.startedAt + lines.push( + `${icon} Execution: ${toolchain} ${dim(`(${formatDuration(duration)})`)}` + ) + + // Model info + if (result.telemetry.model) { + lines.push(dim(` Model: ${result.telemetry.model}`)) + } + + // Token usage + if (result.telemetry.tokens) { + const { input, output } = result.telemetry.tokens + lines.push(dim(` Tokens: ${input} in / ${output} out`)) + } + + // Cost + if (result.telemetry.cost) { + lines.push(dim(` Cost: $${result.telemetry.cost.toFixed(4)}`)) + } + + // Error + if (result.error) { + lines.push(c("red", ` Error: ${result.error}`)) + } + + // Patch + if (result.patch) { + const patchLines = result.patch.split("\n").length + lines.push(dim(` Patch: ${patchLines} lines`)) + } + + return lines.join("\n") + } + + /** + * Format routing decision + */ + export function formatRouting(decision: RoutingDecision): string { + const lines: string[] = [] + + const toolchain = formatToolchain(decision.toolchain) + const strategy = + decision.strategy === "speculate" + ? c("yellow", "speculate") + : c("cyan", "single") + + lines.push(`${ICONS.arrow} Routing: ${toolchain} (${strategy})`) + lines.push(dim(` Gates: ${decision.gates.join(", ")}`)) + lines.push(dim(` Retries: ${decision.retries}, Priority: ${decision.priority}`)) + + if (decision.speculation) { + const toolchains = decision.speculation.toolchains + .map(formatToolchain) + .join(", ") + lines.push(dim(` Speculation: ${toolchains}`)) + lines.push(dim(` Vote: ${decision.speculation.voteStrategy}`)) + } + + if (decision.reasoning) { + lines.push(dim(` Reason: ${decision.reasoning}`)) + } + + return lines.join("\n") + } + + // =========================================================================== + // SPECULATION FORMATTING + // =========================================================================== + + /** + * Format speculation result + */ + export function formatSpeculationResult(result: SpeculationResult): string { + const lines: string[] = [] + + // Header + const hasWinner = result.winner !== undefined + const icon = hasWinner ? c("green", ICONS.check) : c("red", ICONS.cross) + lines.push(`${icon} Speculation Result`) + + // Winner + if (result.winner) { + const toolchain = formatToolchain(result.winner.toolchain) + const score = c("green", `${result.winner.gateResults.score}/100`) + lines.push(` ${ICONS.arrow} Winner: ${toolchain} (${score})`) + } else { + lines.push(c("red", ` ${ICONS.cross} No passing result`)) + } + + // All results + lines.push(dim(` Attempts: ${result.allResults.length}`)) + for (const r of result.allResults) { + const toolchain = formatToolchain(r.toolchain) + const passed = r.result?.success && r.gateResults?.allPassed + const icon = passed ? c("green", ICONS.check) : c("red", ICONS.cross) + const score = r.gateResults?.score ?? 0 + const isWinner = r.workcellId === result.winner?.workcellId + const suffix = isWinner ? c("green", " ← winner") : "" + lines.push(` ${icon} ${toolchain}: ${score}/100${suffix}`) + } + + // Timing + const duration = result.timing.completedAt - result.timing.startedAt + lines.push(dim(` Duration: ${formatDuration(duration)}`)) + + return lines.join("\n") + } + + // =========================================================================== + // ROLLOUT FORMATTING + // =========================================================================== + + /** + * Format rollout summary + */ + export function formatRollout(rollout: Rollout): string { + const lines: string[] = [] + + // Header + const status = formatStatus(rollout.status) + lines.push(`${bold("Rollout")} ${dim(rollout.id.slice(0, 8))} ${status}`) + + // Task + lines.push(dim(` Task: ${rollout.taskId.slice(0, 8)}`)) + + // Routing + if (rollout.routing) { + const toolchain = formatToolchain(rollout.routing.toolchain) + lines.push(` Toolchain: ${toolchain}`) + } + + // Execution + if (rollout.execution) { + const success = rollout.execution.success + ? c("green", "success") + : c("red", "failed") + lines.push(` Execution: ${success}`) + } + + // Verification + if (rollout.verification) { + const passed = rollout.verification.allPassed + ? c("green", "passed") + : c("red", "failed") + const score = rollout.verification.score + lines.push(` Gates: ${passed} (${score}/100)`) + } + + // Timing + if (rollout.completedAt) { + const duration = rollout.completedAt - rollout.startedAt + lines.push(dim(` Duration: ${formatDuration(duration)}`)) + } + + // Events + if (rollout.events.length > 0) { + lines.push(dim(` Events: ${rollout.events.length}`)) + } + + return lines.join("\n") + } + + // =========================================================================== + // PROGRESS DISPLAY + // =========================================================================== + + /** + * Get spinner character (call repeatedly for animation) + */ + export function spinner(): string { + spinnerFrame = (spinnerFrame + 1) % ICONS.spinner.length + return c("cyan", ICONS.spinner[spinnerFrame]) + } + + /** + * Format progress message + */ + export function progress(message: string): string { + return `${spinner()} ${message}` + } + + /** + * Format success message + */ + export function success(message: string): string { + return `${c("green", ICONS.check)} ${message}` + } + + /** + * Format error message + */ + export function error(message: string): string { + return `${c("red", ICONS.cross)} ${message}` + } + + /** + * Format warning message + */ + export function warning(message: string): string { + return `${c("yellow", ICONS.warning)} ${message}` + } + + /** + * Format info message + */ + export function info(message: string): string { + return `${c("blue", ICONS.info)} ${message}` + } + + // =========================================================================== + // HELPERS + // =========================================================================== + + /** + * Format duration in human-readable format + */ + export function formatDuration(ms: number): string { + if (ms < 1000) return `${ms}ms` + if (ms < 60000) return `${(ms / 1000).toFixed(1)}s` + const minutes = Math.floor(ms / 60000) + const seconds = Math.floor((ms % 60000) / 1000) + return `${minutes}m ${seconds}s` + } + + /** + * Format a table of key-value pairs + */ + export function formatTable( + rows: Array<[string, string]>, + options: { indent?: number; separator?: string } = {} + ): string { + const { indent = 0, separator = ": " } = options + const maxKeyLen = Math.max(...rows.map(([k]) => k.length)) + const prefix = " ".repeat(indent) + + return rows + .map(([key, value]) => { + const paddedKey = key.padEnd(maxKeyLen) + return `${prefix}${dim(paddedKey)}${separator}${value}` + }) + .join("\n") + } + + /** + * Create a horizontal divider + */ + export function divider(width: number = 40): string { + return dim("─".repeat(width)) + } + + /** + * Create a header with title + */ + export function header(title: string): string { + return `${bold(title)}\n${divider(title.length)}` + } +} + +export { launchTUI, TUIApp } from "./app" + +export default TUI diff --git a/apps/terminal/src/tui/screens/audit.ts b/apps/terminal/src/tui/screens/audit.ts new file mode 100644 index 000000000..774ea5e86 --- /dev/null +++ b/apps/terminal/src/tui/screens/audit.ts @@ -0,0 +1,82 @@ +/** + * Audit Screen - Audit log table + */ + +import { THEME } from "../theme" +import type { Screen, ScreenContext } from "../types" + +export const auditScreen: Screen = { + render(ctx: ScreenContext): string { + return renderAuditScreen(ctx) + }, + + handleInput(key: string, ctx: ScreenContext): boolean { + const { app } = ctx + + if (key === "\x1b" || key === "\x1b\x1b" || key === "q") { + app.setScreen("main") + return true + } + + if (key === "r") { + app.connectHushd() + return true + } + + return false + }, +} + +function renderAuditScreen(ctx: ScreenContext): string { + const { state, width, height } = ctx + const lines: string[] = [] + const boxWidth = Math.min(75, width - 6) + const boxPad = Math.max(0, Math.floor((width - boxWidth) / 2)) + + lines.push("") + + const title = "⟨ Audit Log ⟩" + const titlePadLeft = Math.floor((boxWidth - title.length - 4) / 2) + const titlePadRight = boxWidth - title.length - titlePadLeft - 4 + lines.push(" ".repeat(boxPad) + THEME.dim + "╔═" + "═".repeat(titlePadLeft) + title + "═".repeat(titlePadRight) + "═╗" + THEME.reset) + lines.push(" ".repeat(boxPad) + THEME.dim + "║" + " ".repeat(boxWidth - 2) + "║" + THEME.reset) + + if (!state.hushdConnected) { + const msg = ` ${THEME.muted}hushd not connected${THEME.reset}` + lines.push(" ".repeat(boxPad) + THEME.dim + "║" + THEME.reset + msg + " ".repeat(Math.max(0, boxWidth - 23)) + THEME.dim + "║" + THEME.reset) + } else { + // Column headers + const header = ` ${THEME.white}${THEME.bold}${"time".padEnd(9)}${"action".padEnd(8)}${"target".padEnd(22)}${"guard".padEnd(20)}${"decision".padEnd(8)}${THEME.reset}` + const hLen = ` ${"time".padEnd(9)}${"action".padEnd(8)}${"target".padEnd(22)}${"guard".padEnd(20)}${"decision".padEnd(8)}`.length + lines.push(" ".repeat(boxPad) + THEME.dim + "║" + THEME.reset + header + " ".repeat(Math.max(0, boxWidth - hLen - 2)) + THEME.dim + "║" + THEME.reset) + + const maxRows = Math.min(state.recentEvents.length, height - 10) + for (let i = 0; i < maxRows; i++) { + const evt = state.recentEvents[i] + if (evt.type === "check") { + const d = evt.data as { action_type?: string; target?: string; guard?: string; decision?: string } + const time = new Date(evt.timestamp).toLocaleTimeString().slice(0, 8) + const target = (d.target ?? "").length > 20 ? "…" + (d.target ?? "").slice(-19) : (d.target ?? "") + const guard = (d.guard ?? "").length > 18 ? (d.guard ?? "").slice(0, 17) + "…" : (d.guard ?? "") + const decColor = d.decision === "deny" ? THEME.error : THEME.success + const row = ` ${THEME.dim}${time.padEnd(9)}${THEME.reset}${(d.action_type ?? "").padEnd(8)}${THEME.muted}${target.padEnd(22)}${THEME.reset}${THEME.dim}${guard.padEnd(20)}${THEME.reset}${decColor}${(d.decision ?? "").padEnd(8)}${THEME.reset}` + const rLen = ` ${time.padEnd(9)}${(d.action_type ?? "").padEnd(8)}${target.padEnd(22)}${guard.padEnd(20)}${(d.decision ?? "").padEnd(8)}`.length + lines.push(" ".repeat(boxPad) + THEME.dim + "║" + THEME.reset + row + " ".repeat(Math.max(0, boxWidth - rLen - 2)) + THEME.dim + "║" + THEME.reset) + } + } + + if (state.recentEvents.length === 0) { + const msg = ` ${THEME.muted}No audit events yet${THEME.reset}` + lines.push(" ".repeat(boxPad) + THEME.dim + "║" + THEME.reset + msg + " ".repeat(Math.max(0, boxWidth - 23)) + THEME.dim + "║" + THEME.reset) + } + } + + lines.push(" ".repeat(boxPad) + THEME.dim + "║" + " ".repeat(boxWidth - 2) + "║" + THEME.reset) + const helpText = "r refresh ◆ esc back" + const helpPad = Math.max(0, Math.floor((boxWidth - helpText.length) / 2)) + lines.push(" ".repeat(boxPad) + THEME.dim + "║" + " ".repeat(helpPad) + helpText + " ".repeat(boxWidth - helpPad - helpText.length - 2) + "║" + THEME.reset) + lines.push(" ".repeat(boxPad) + THEME.dim + "╚" + "═".repeat(boxWidth - 2) + "╝" + THEME.reset) + + for (let i = lines.length; i < height - 1; i++) lines.push("") + return lines.join("\n") +} diff --git a/apps/terminal/src/tui/screens/hunt-diff.ts b/apps/terminal/src/tui/screens/hunt-diff.ts new file mode 100644 index 000000000..611259bdc --- /dev/null +++ b/apps/terminal/src/tui/screens/hunt-diff.ts @@ -0,0 +1,412 @@ +/** + * Hunt Diff Screen - Scan change detection between current and previous scans. + */ + +import { THEME } from "../theme" +import type { Screen, ScreenContext } from "../types" +import type { ScanPathResult, ServerScanResult, ServerChange, ScanDiff, ChangeKind } from "../../hunt/types" +import { runScan } from "../../hunt/bridge-scan" +import { renderList, scrollUp, scrollDown, type ListItem } from "../components/scrollable-list" +import { renderSplit } from "../components/split-pane" +import { renderBox } from "../components/box" +import { fitString } from "../components/types" +import { readFile, writeFile, mkdir } from "node:fs/promises" +import { homedir } from "node:os" +import { join } from "node:path" + +const HISTORY_PATH = join(homedir(), ".clawdstrike", "scan_history.json") + +const CHANGE_COLORS: Record<ChangeKind, string> = { + added: THEME.success, + removed: THEME.error, + modified: THEME.warning, +} + +const CHANGE_ICONS: Record<ChangeKind, string> = { + added: "+", + removed: "-", + modified: "~", +} + +async function loadPreviousScan(): Promise<ScanPathResult[]> { + try { + const raw = await readFile(HISTORY_PATH, "utf-8") + const data = JSON.parse(raw) + return Array.isArray(data) ? data : [] + } catch { + return [] + } +} + +async function saveScanHistory(results: ScanPathResult[]): Promise<void> { + try { + const dir = join(homedir(), ".clawdstrike") + await mkdir(dir, { recursive: true }) + await writeFile(HISTORY_PATH, JSON.stringify(results, null, 2), "utf-8") + } catch { + // Best-effort save + } +} + +function computeDiff(previous: ScanPathResult[], current: ScanPathResult[]): ScanDiff { + const prevServers = new Map<string, ServerScanResult>() + const currServers = new Map<string, ServerScanResult>() + + for (const p of previous) { + for (const s of p.servers) prevServers.set(s.name, s) + } + for (const c of current) { + for (const s of c.servers) currServers.set(s.name, s) + } + + const changes: ServerChange[] = [] + let added = 0 + let removed = 0 + let modified = 0 + + // Find added and modified + for (const [name, curr] of currServers) { + const prev = prevServers.get(name) + if (!prev) { + changes.push({ server_name: name, kind: "added", new: curr }) + added++ + } else { + const prevTools = new Set((prev.signature?.tools ?? []).map((t: { name: string }) => t.name)) + const currTools = new Set((curr.signature?.tools ?? []).map((t: { name: string }) => t.name)) + const addedTools = [...currTools].filter((t): t is string => !prevTools.has(t as string)) as string[] + const removedTools = [...prevTools].filter((t): t is string => !currTools.has(t as string)) as string[] + if (addedTools.length > 0 || removedTools.length > 0) { + changes.push({ + server_name: name, + kind: "modified", + old: prev, + new: curr, + tool_changes: { added: addedTools, removed: removedTools }, + }) + modified++ + } + } + } + + // Find removed + for (const [name, prev] of prevServers) { + if (!currServers.has(name)) { + changes.push({ server_name: name, kind: "removed", old: prev }) + removed++ + } + } + + return { + timestamp: new Date().toISOString(), + changes, + summary: { added, removed, modified }, + } +} + +function buildChangeItems(diff: ScanDiff): ListItem[] { + return diff.changes.map((c) => { + const color = CHANGE_COLORS[c.kind] + const icon = CHANGE_ICONS[c.kind] + const label = `${color}[${icon}]${THEME.reset} ${THEME.white}${c.server_name}${THEME.reset} ${THEME.dim}(${c.kind})${THEME.reset}` + const plainLength = `[${icon}] ${c.server_name} (${c.kind})`.length + return { label, plainLength } + }) +} + +function renderScanSummary(label: string, results: ScanPathResult[], theme: typeof THEME): string[] { + const lines: string[] = [] + lines.push(`${theme.secondary}${theme.bold}${label}${theme.reset}`) + lines.push("") + + if (results.length === 0) { + lines.push(`${theme.muted} No scan data${theme.reset}`) + return lines + } + + let totalServers = 0 + let totalTools = 0 + let totalIssues = 0 + + for (const r of results) { + for (const s of r.servers) { + totalServers++ + totalTools += s.signature?.tools.length ?? 0 + totalIssues += s.issues.length + } + } + + lines.push(`${theme.muted}Servers:${theme.reset} ${theme.white}${totalServers}${theme.reset}`) + lines.push(`${theme.muted}Tools:${theme.reset} ${theme.white}${totalTools}${theme.reset}`) + lines.push(`${theme.muted}Issues:${theme.reset} ${theme.white}${totalIssues}${theme.reset}`) + lines.push("") + + for (const r of results) { + for (const s of r.servers) { + const toolCount = s.signature?.tools.length ?? 0 + lines.push(` ${theme.white}${s.name}${theme.reset} ${theme.dim}(${toolCount} tools)${theme.reset}`) + } + } + + return lines +} + +async function performScan(ctx: ScreenContext): Promise<void> { + const d = ctx.state.hunt.diff + ctx.state.hunt.diff = { ...d, loading: true, error: null } + ctx.app.render() + + try { + const previous = await loadPreviousScan() + const current = await runScan() + const diff = computeDiff(previous, current) + + ctx.state.hunt.diff = { + ...ctx.state.hunt.diff, + current, + previous, + diff, + loading: false, + list: { offset: 0, selected: 0 }, + expandedServer: null, + } + + // Save current for next diff + await saveScanHistory(current) + ctx.app.render() + } catch (err) { + ctx.state.hunt.diff = { + ...ctx.state.hunt.diff, + loading: false, + error: err instanceof Error ? err.message : String(err), + } + ctx.app.render() + } +} + +export const huntDiffScreen: Screen = { + onEnter(ctx: ScreenContext): void { + const d = ctx.state.hunt.diff + if (d.loading) return + performScan(ctx) + }, + + render(ctx: ScreenContext): string { + const { state, width, height } = ctx + const d = state.hunt.diff + const lines: string[] = [] + + // Title bar + const title = `${THEME.accent}${THEME.bold} HUNT ${THEME.reset}${THEME.dim} // ${THEME.reset}${THEME.secondary}Scan Diff${THEME.reset}` + lines.push(fitString(title, width)) + lines.push(fitString(`${THEME.dim}${"─".repeat(width)}${THEME.reset}`, width)) + + // Loading state + if (d.loading) { + const spinChars = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] + const spinner = spinChars[state.animationFrame % spinChars.length] + const msgY = Math.floor(height / 2) - 2 + for (let i = lines.length; i < msgY; i++) lines.push(" ".repeat(width)) + lines.push(fitString(`${THEME.secondary} ${spinner} Scanning MCP servers...${THEME.reset}`, width)) + for (let i = lines.length; i < height - 1; i++) lines.push(" ".repeat(width)) + lines.push(renderHelpBar(width)) + return lines.join("\n") + } + + // Error state + if (d.error) { + const msgY = Math.floor(height / 2) - 2 + for (let i = lines.length; i < msgY; i++) lines.push(" ".repeat(width)) + lines.push(fitString(`${THEME.error} Error: ${d.error}${THEME.reset}`, width)) + lines.push(fitString(`${THEME.dim} Press r to retry.${THEME.reset}`, width)) + for (let i = lines.length; i < height - 1; i++) lines.push(" ".repeat(width)) + lines.push(renderHelpBar(width)) + return lines.join("\n") + } + + // No previous scan + if (d.previous.length === 0 && d.diff) { + lines.push(fitString(`${THEME.muted} First scan recorded. No previous data to compare.${THEME.reset}`, width)) + lines.push(fitString(`${THEME.dim} Run again later to detect changes.${THEME.reset}`, width)) + lines.push("") + + // Show current scan summary + const summaryLines = renderScanSummary("Current Scan", d.current, THEME) + for (const sl of summaryLines) { + lines.push(fitString(` ${sl}`, width)) + } + + for (let i = lines.length; i < height - 1; i++) lines.push(" ".repeat(width)) + lines.push(renderHelpBar(width)) + return lines.join("\n") + } + + if (!d.diff) { + for (let i = lines.length; i < height - 1; i++) lines.push(" ".repeat(width)) + lines.push(renderHelpBar(width)) + return lines.join("\n") + } + + // Summary line + const s = d.diff.summary + const summaryText = + `${THEME.muted}Changes:${THEME.reset} ` + + `${THEME.success}+${s.added}${THEME.reset} ` + + `${THEME.error}-${s.removed}${THEME.reset} ` + + `${THEME.warning}~${s.modified}${THEME.reset}` + lines.push(fitString(` ${summaryText}`, width)) + lines.push(fitString(`${THEME.dim}${"─".repeat(width)}${THEME.reset}`, width)) + + const helpLines = 1 + const headerLines = lines.length + const availableHeight = height - headerLines - helpLines + + if (d.expandedServer !== null) { + // Show expanded server detail + const change = d.diff.changes.find((c) => c.server_name === d.expandedServer) + if (change) { + // Split: left=previous, right=current summaries, bottom=tool diff + const splitHeight = Math.min(8, Math.floor(availableHeight * 0.35)) + const leftLines: string[] = [] + const rightLines: string[] = [] + + leftLines.push(`${THEME.muted}Previous${THEME.reset}`) + rightLines.push(`${THEME.muted}Current${THEME.reset}`) + + if (change.old) { + const toolCount = change.old.signature?.tools.length ?? 0 + leftLines.push(`${THEME.white}${change.old.name}${THEME.reset}`) + leftLines.push(`${THEME.dim}Tools: ${toolCount}${THEME.reset}`) + leftLines.push(`${THEME.dim}Command: ${change.old.command}${THEME.reset}`) + } else { + leftLines.push(`${THEME.dim}(not present)${THEME.reset}`) + } + + if (change.new) { + const toolCount = change.new.signature?.tools.length ?? 0 + rightLines.push(`${THEME.white}${change.new.name}${THEME.reset}`) + rightLines.push(`${THEME.dim}Tools: ${toolCount}${THEME.reset}`) + rightLines.push(`${THEME.dim}Command: ${change.new.command}${THEME.reset}`) + } else { + rightLines.push(`${THEME.dim}(not present)${THEME.reset}`) + } + + const splitLines = renderSplit(leftLines, rightLines, width, splitHeight, THEME) + for (const sl of splitLines) lines.push(sl) + + // Tool changes detail + if (change.tool_changes) { + const detailContent: string[] = [] + if (change.tool_changes.added.length > 0) { + detailContent.push(`${THEME.success}Added tools:${THEME.reset}`) + for (const t of change.tool_changes.added) { + detailContent.push(` ${THEME.success}+ ${t}${THEME.reset}`) + } + } + if (change.tool_changes.removed.length > 0) { + detailContent.push(`${THEME.error}Removed tools:${THEME.reset}`) + for (const t of change.tool_changes.removed) { + detailContent.push(` ${THEME.error}- ${t}${THEME.reset}`) + } + } + const boxLines = renderBox("Tool Changes", detailContent, width, THEME, { style: "rounded" }) + for (const bl of boxLines) lines.push(bl) + } + } + } else { + // Change list + if (d.diff.changes.length === 0) { + const msgY = Math.floor(availableHeight / 2) + for (let i = 0; i < msgY; i++) lines.push(" ".repeat(width)) + lines.push(fitString(`${THEME.muted} No changes detected between scans.${THEME.reset}`, width)) + } else { + const items = buildChangeItems(d.diff) + const listLines = renderList(items, d.list, availableHeight, width, THEME) + for (const l of listLines) lines.push(l) + } + } + + // Help bar + lines.push(renderHelpBar(width)) + + // Pad to fill + while (lines.length < height) lines.push(" ".repeat(width)) + + return lines.join("\n") + }, + + handleInput(key: string, ctx: ScreenContext): boolean { + const d = ctx.state.hunt.diff + if (d.loading) { + if (key === "\x1b" || key === "\x1b\x1b" || key === "q") { + ctx.app.setScreen("main") + return true + } + return false + } + + // Navigation + if (key === "\x1b" || key === "\x1b\x1b" || key === "q") { + if (d.expandedServer !== null) { + ctx.state.hunt.diff = { ...d, expandedServer: null } + return true + } + ctx.app.setScreen("main") + return true + } + + if (!d.diff) return false + const changeCount = d.diff.changes.length + + // Scroll + if (key === "j" || key === "down") { + if (changeCount > 0 && d.expandedServer === null) { + ctx.state.hunt.diff = { + ...d, + list: scrollDown(d.list, changeCount, ctx.height - 8), + } + } + return true + } + if (key === "k" || key === "up") { + if (changeCount > 0 && d.expandedServer === null) { + ctx.state.hunt.diff = { + ...d, + list: scrollUp(d.list), + } + } + return true + } + + // Expand/collapse server + if (key === "\r" || key === "return") { + if (changeCount > 0) { + if (d.expandedServer !== null) { + ctx.state.hunt.diff = { ...d, expandedServer: null } + } else { + const change = d.diff.changes[d.list.selected] + if (change) { + ctx.state.hunt.diff = { ...d, expandedServer: change.server_name } + } + } + } + return true + } + + // Rescan + if (key === "r") { + performScan(ctx) + return true + } + + return false + }, +} + +function renderHelpBar(width: number): string { + const help = + `${THEME.dim}esc${THEME.reset}${THEME.muted} back${THEME.reset} ` + + `${THEME.dim}j/k${THEME.reset}${THEME.muted} navigate${THEME.reset} ` + + `${THEME.dim}enter${THEME.reset}${THEME.muted} expand${THEME.reset} ` + + `${THEME.dim}r${THEME.reset}${THEME.muted} rescan${THEME.reset}` + return fitString(help, width) +} diff --git a/apps/terminal/src/tui/screens/hunt-mitre.ts b/apps/terminal/src/tui/screens/hunt-mitre.ts new file mode 100644 index 000000000..47e784f82 --- /dev/null +++ b/apps/terminal/src/tui/screens/hunt-mitre.ts @@ -0,0 +1,301 @@ +/** + * Hunt MITRE Screen - MITRE ATT&CK Heatmap + * + * Grid of techniques x tactics with hit counts, plus drill-down + * into matched events for a selected technique. + */ + +import { THEME } from "../theme" +import type { Screen, ScreenContext } from "../types" +import type { GridCell } from "../components/grid" +import { renderGrid, moveSelection } from "../components/grid" +import type { ListItem } from "../components/scrollable-list" +import { renderList, scrollUp, scrollDown } from "../components/scrollable-list" +import { fitString } from "../components/types" +import { runTimeline } from "../../hunt/bridge-query" +import { buildCoverageMatrix, TACTICS } from "../../hunt/mitre" +import type { CoverageMatrix } from "../../hunt/mitre" +import type { TimelineEvent } from "../../hunt/types" + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Short tactic labels for column headers */ +function shortTactic(tactic: string): string { + const map: Record<string, string> = { + "Initial Access": "InitAcc", + "Execution": "Exec", + "Persistence": "Persist", + "Privilege Escalation": "PrivEsc", + "Defense Evasion": "DefEvas", + "Credential Access": "Cred", + "Discovery": "Discov", + "Lateral Movement": "LatMov", + "Collection": "Collect", + "Exfiltration": "Exfil", + "Command and Control": "C2", + "Impact": "Impact", + } + return map[tactic] ?? tactic.slice(0, 7) +} + +function verdictColor(verdict: string): string { + switch (verdict) { + case "allow": return THEME.success + case "deny": return THEME.error + case "audit": return THEME.warning + default: return THEME.muted + } +} + +function formatDrilldownEvent(evt: TimelineEvent): string { + const ts = evt.timestamp.length > 19 ? evt.timestamp.slice(11, 19) : evt.timestamp + const vc = verdictColor(evt.verdict) + return `${THEME.dim}${ts}${THEME.reset} ${vc}${evt.verdict.padEnd(5)}${THEME.reset} ${THEME.muted}${evt.source}${THEME.reset} ${THEME.white}${evt.summary}${THEME.reset}` +} + +// --------------------------------------------------------------------------- +// State management +// --------------------------------------------------------------------------- + +let cachedMatrix: CoverageMatrix | null = null + +function getSubMode(ctx: ScreenContext): "grid" | "drilldown" { + return ctx.state.hunt.mitre.drilldownEvents.length > 0 ? "drilldown" : "grid" +} + +// --------------------------------------------------------------------------- +// Screen +// --------------------------------------------------------------------------- + +export const huntMitreScreen: Screen = { + onEnter(ctx: ScreenContext) { + const mitre = ctx.state.hunt.mitre + if (mitre.events.length === 0 && !mitre.loading) { + loadEvents(ctx) + } + }, + + onExit(_ctx: ScreenContext) { + cachedMatrix = null + }, + + render(ctx: ScreenContext): string { + const { state, width, height } = ctx + const mitre = state.hunt.mitre + const lines: string[] = [] + + // Header + const title = `${THEME.secondary}${THEME.bold} MITRE ATT&CK Heatmap ${THEME.reset}` + lines.push(fitString(title, width)) + lines.push(fitString(`${THEME.dim}${"─".repeat(width)}${THEME.reset}`, width)) + + if (mitre.loading) { + const spinChars = ["\u2847", "\u2846", "\u2834", "\u2831", "\u2839", "\u283B", "\u283F", "\u2857"] + const frame = ctx.state.animationFrame % spinChars.length + lines.push(fitString(`${THEME.accent} ${spinChars[frame]} Loading timeline events...${THEME.reset}`, width)) + while (lines.length < height - 1) lines.push(" ".repeat(width)) + lines.push(fitString(`${THEME.dim} ESC back${THEME.reset}`, width)) + return lines.join("\n") + } + + if (mitre.error) { + lines.push(fitString(`${THEME.error} Error: ${mitre.error}${THEME.reset}`, width)) + lines.push(fitString("", width)) + lines.push(fitString(`${THEME.muted} r reload ESC back${THEME.reset}`, width)) + while (lines.length < height - 1) lines.push(" ".repeat(width)) + return lines.join("\n") + } + + if (!cachedMatrix || mitre.techniques.length === 0) { + lines.push(fitString(`${THEME.muted} No events or techniques detected.${THEME.reset}`, width)) + lines.push(fitString(`${THEME.dim} Run hunt watch or load timeline data first.${THEME.reset}`, width)) + lines.push(fitString("", width)) + lines.push(fitString(`${THEME.muted} r reload ESC back${THEME.reset}`, width)) + while (lines.length < height - 1) lines.push(" ".repeat(width)) + return lines.join("\n") + } + + const subMode = getSubMode(ctx) + + if (subMode === "drilldown") { + // Drilldown: show selected technique's events + const techIdx = mitre.grid.row + const tech = cachedMatrix.techniques[techIdx] + const techLabel = tech ? `${tech.id} ${tech.name}` : "Unknown" + lines.push(fitString(`${THEME.secondary} ${techLabel}${THEME.reset} ${THEME.dim}(${mitre.drilldownEvents.length} events)${THEME.reset}`, width)) + lines.push(fitString(`${THEME.dim}${"─".repeat(width)}${THEME.reset}`, width)) + + const listHeight = Math.max(1, height - lines.length - 2) + const items: ListItem[] = mitre.drilldownEvents.map((evt, i) => ({ + label: formatDrilldownEvent(evt), + plainLength: `${evt.timestamp.slice(11, 19)} ${evt.verdict.padEnd(5)} ${evt.source} ${evt.summary}`.length, + key: `drill-${i}`, + })) + + const listLines = renderList(items, mitre.drilldownList, listHeight, width, THEME) + lines.push(...listLines) + + // Footer + while (lines.length < height - 1) lines.push(" ".repeat(width)) + lines.push(fitString(`${THEME.dim} j/k navigate ESC back to grid${THEME.reset}`, width)) + } else { + // Grid view + const columns = cachedMatrix.tactics.map(shortTactic) + const rows = cachedMatrix.techniques.map((t) => t.id) + + // Build GridCell[][] from matrix + const cells: GridCell[][] = cachedMatrix.matrix.map((row) => + row.map((val) => ({ value: val })), + ) + + const gridHeight = Math.min(cachedMatrix.techniques.length + 3, Math.floor(height * 0.65)) + const gridLines = renderGrid(columns, rows, cells, mitre.grid, width, gridHeight, THEME) + lines.push(...gridLines) + + // Selected technique info + lines.push(fitString(`${THEME.dim}${"─".repeat(width)}${THEME.reset}`, width)) + const techIdx = mitre.grid.row + const tech = cachedMatrix.techniques[techIdx] + if (tech) { + const tacticLabel = cachedMatrix.tactics[mitre.grid.col] ?? "" + const cellVal = cachedMatrix.matrix[techIdx]?.[mitre.grid.col] ?? 0 + lines.push(fitString( + `${THEME.secondary} ${tech.id}${THEME.reset} ${THEME.white}${tech.name}${THEME.reset} ${THEME.dim}[${tacticLabel}]${THEME.reset} ${THEME.accent}${cellVal} hits${THEME.reset}`, + width, + )) + } else { + lines.push(fitString(`${THEME.muted} No technique selected${THEME.reset}`, width)) + } + + // Legend + lines.push(fitString("", width)) + lines.push(fitString( + `${THEME.dim} \u2591 low ${THEME.muted}\u2592 med ${THEME.warning}\u2593 high ${THEME.accent}\u2588 critical${THEME.reset}`, + width, + )) + + // Footer + while (lines.length < height - 1) lines.push(" ".repeat(width)) + lines.push(fitString(`${THEME.dim} h/j/k/l navigate Enter drilldown r reload ESC back${THEME.reset}`, width)) + } + + while (lines.length < height) lines.push(" ".repeat(width)) + return lines.join("\n") + }, + + handleInput(key: string, ctx: ScreenContext): boolean { + const mitre = ctx.state.hunt.mitre + const subMode = getSubMode(ctx) + + if (mitre.loading) return false + + // ESC handling + if (key === "\x1b" || key === "\x1b\x1b") { + if (subMode === "drilldown") { + // Back to grid + ctx.state.hunt.mitre.drilldownEvents = [] + ctx.state.hunt.mitre.drilldownList = { offset: 0, selected: 0 } + ctx.app.render() + return true + } + ctx.app.setScreen("main") + return true + } + + if (key === "q" && subMode !== "drilldown") { + ctx.app.setScreen("main") + return true + } + + if (key === "r") { + loadEvents(ctx) + return true + } + + if (subMode === "drilldown") { + if (key === "j" || key === "down") { + ctx.state.hunt.mitre.drilldownList = scrollDown( + mitre.drilldownList, + mitre.drilldownEvents.length, + ctx.height - 8, + ) + ctx.app.render() + return true + } + if (key === "k" || key === "up") { + ctx.state.hunt.mitre.drilldownList = scrollUp(mitre.drilldownList) + ctx.app.render() + return true + } + return false + } + + // Grid navigation + if (cachedMatrix && cachedMatrix.techniques.length > 0) { + if (key === "h" || key === "left") { + ctx.state.hunt.mitre.grid = moveSelection(mitre.grid, "left", cachedMatrix.techniques.length, TACTICS.length) + ctx.app.render() + return true + } + if (key === "l" || key === "right") { + ctx.state.hunt.mitre.grid = moveSelection(mitre.grid, "right", cachedMatrix.techniques.length, TACTICS.length) + ctx.app.render() + return true + } + if (key === "j" || key === "down") { + ctx.state.hunt.mitre.grid = moveSelection(mitre.grid, "down", cachedMatrix.techniques.length, TACTICS.length) + ctx.app.render() + return true + } + if (key === "k" || key === "up") { + ctx.state.hunt.mitre.grid = moveSelection(mitre.grid, "up", cachedMatrix.techniques.length, TACTICS.length) + ctx.app.render() + return true + } + + // Enter: drilldown into technique + if (key === "\r" || key === "enter") { + const techIdx = mitre.grid.row + const tech = cachedMatrix.techniques[techIdx] + if (tech) { + const events = cachedMatrix.eventsByTechnique.get(tech.id) ?? [] + ctx.state.hunt.mitre.drilldownEvents = events + ctx.state.hunt.mitre.drilldownList = { offset: 0, selected: 0 } + ctx.app.render() + } + return true + } + } + + return false + }, +} + +async function loadEvents(ctx: ScreenContext) { + ctx.state.hunt.mitre.loading = true + ctx.state.hunt.mitre.error = null + cachedMatrix = null + ctx.app.render() + try { + const events = await runTimeline({ limit: 500 }) + ctx.state.hunt.mitre.events = events + + const matrix = buildCoverageMatrix(events) + cachedMatrix = matrix + + // Store technique IDs and tactic labels in state for other screens + ctx.state.hunt.mitre.tactics = matrix.tactics + ctx.state.hunt.mitre.techniques = matrix.techniques.map((t) => t.id) + ctx.state.hunt.mitre.matrix = matrix.matrix + ctx.state.hunt.mitre.drilldownEvents = [] + ctx.state.hunt.mitre.drilldownList = { offset: 0, selected: 0 } + ctx.state.hunt.mitre.grid = { row: 0, col: 0 } + ctx.state.hunt.mitre.loading = false + } catch (err) { + ctx.state.hunt.mitre.error = err instanceof Error ? err.message : String(err) + ctx.state.hunt.mitre.loading = false + } + ctx.app.render() +} diff --git a/apps/terminal/src/tui/screens/hunt-playbook.ts b/apps/terminal/src/tui/screens/hunt-playbook.ts new file mode 100644 index 000000000..139cc09f7 --- /dev/null +++ b/apps/terminal/src/tui/screens/hunt-playbook.ts @@ -0,0 +1,284 @@ +/** + * Hunt Playbook Screen - Automated playbook runner with step-by-step execution. + */ + +import { THEME } from "../theme" +import type { Screen, ScreenContext } from "../types" +import type { PlaybookStep, PlaybookStepStatus } from "../../hunt/types" +import { buildDefaultPlaybook, executePlaybook, type PlaybookConfig } from "../../hunt/playbook" +import { renderList, type ListItem } from "../components/scrollable-list" +import { renderSplit } from "../components/split-pane" +import { renderBox } from "../components/box" +import { fitString } from "../components/types" + +const STATUS_ICONS: Record<PlaybookStepStatus, string> = { + pending: "\u25C7", // ◇ + running: "\u25C8", // ◈ + passed: "\u25C6", // ◆ + failed: "\u2717", // ✗ + skipped: "\u25CB", // ○ +} + +function getStatusColor(status: PlaybookStepStatus): string { + switch (status) { + case "pending": return THEME.dim + case "running": return THEME.secondary + case "passed": return THEME.success + case "failed": return THEME.error + case "skipped": return THEME.dim + } +} + +function buildStepItems(steps: PlaybookStep[], animationFrame: number): ListItem[] { + return steps.map((step) => { + const color = getStatusColor(step.status) + let icon = STATUS_ICONS[step.status] + // Animate running icon + if (step.status === "running") { + const spinChars = ["\u25C8", "\u25C9", "\u25CE", "\u25C9"] + icon = spinChars[animationFrame % spinChars.length] + } + const duration = step.duration_ms != null ? ` ${THEME.dim}(${step.duration_ms}ms)${THEME.reset}` : "" + const label = `${color}${icon}${THEME.reset} ${THEME.white}${step.name}${THEME.reset}${duration}` + const plainLength = `${icon} ${step.name}${step.duration_ms != null ? ` (${step.duration_ms}ms)` : ""}`.length + return { label, plainLength } + }) +} + +function renderStepDetail(step: PlaybookStep, _width: number): string[] { + const content: string[] = [] + + content.push(`${THEME.muted}Name:${THEME.reset} ${THEME.white}${step.name}${THEME.reset}`) + content.push(`${THEME.muted}Description:${THEME.reset} ${THEME.white}${step.description}${THEME.reset}`) + content.push(`${THEME.muted}Command:${THEME.reset} ${THEME.dim}${step.command} ${step.args.join(" ")}${THEME.reset}`) + + const statusColor = getStatusColor(step.status) + content.push(`${THEME.muted}Status:${THEME.reset} ${statusColor}${step.status}${THEME.reset}`) + + if (step.duration_ms != null) { + content.push(`${THEME.muted}Duration:${THEME.reset} ${THEME.white}${step.duration_ms}ms${THEME.reset}`) + } + + if (step.error) { + content.push("") + content.push(`${THEME.error}Error:${THEME.reset}`) + content.push(` ${THEME.error}${step.error}${THEME.reset}`) + } + + if (step.output) { + content.push("") + content.push(`${THEME.muted}Output:${THEME.reset}`) + const json = JSON.stringify(step.output, null, 2) + const jsonLines = json.split("\n") + for (const jl of jsonLines) { + content.push(` ${THEME.dim}${jl}${THEME.reset}`) + } + } + + return content +} + +const DEFAULT_CONFIG: PlaybookConfig = { + name: "Default Hunt Playbook", + description: "Standard threat hunting workflow", + timeRange: "24h", + rules: ["~/.clawdstrike/rules/*.yaml"], + iocFeeds: [], +} + +export const huntPlaybookScreen: Screen = { + onEnter(ctx: ScreenContext): void { + const pb = ctx.state.hunt.playbook + if (pb.steps.length > 0) return + + const steps = buildDefaultPlaybook(DEFAULT_CONFIG) + ctx.state.hunt.playbook = { + ...pb, + steps, + selectedStep: 0, + running: false, + error: null, + report: null, + } + }, + + render(ctx: ScreenContext): string { + const { state, width, height } = ctx + const pb = state.hunt.playbook + const lines: string[] = [] + + // Title bar + const title = `${THEME.accent}${THEME.bold} HUNT ${THEME.reset}${THEME.dim} // ${THEME.reset}${THEME.secondary}Playbook Runner${THEME.reset}` + lines.push(fitString(title, width)) + lines.push(fitString(`${THEME.dim}${"─".repeat(width)}${THEME.reset}`, width)) + + // Progress indicator + const totalSteps = pb.steps.length + const completedSteps = pb.steps.filter((s) => s.status === "passed" || s.status === "failed" || s.status === "skipped").length + const runningStep = pb.steps.findIndex((s) => s.status === "running") + + let progressText: string + if (pb.running) { + const stepLabel = runningStep >= 0 ? pb.steps[runningStep].name : "..." + progressText = + `${THEME.secondary}Running${THEME.reset} ` + + `${THEME.white}Step ${completedSteps + 1}/${totalSteps}${THEME.reset} ` + + `${THEME.dim}(${stepLabel})${THEME.reset}` + } else if (completedSteps === totalSteps && totalSteps > 0) { + const allPassed = pb.steps.every((s) => s.status === "passed" || s.status === "skipped") + if (allPassed) { + progressText = `${THEME.success}Completed${THEME.reset} ${THEME.white}${totalSteps}/${totalSteps} steps passed${THEME.reset}` + } else { + const failed = pb.steps.filter((s) => s.status === "failed").length + progressText = `${THEME.error}Finished${THEME.reset} ${THEME.white}${failed} step(s) failed${THEME.reset}` + } + } else { + progressText = `${THEME.muted}Ready${THEME.reset} ${THEME.dim}${totalSteps} steps${THEME.reset} ${THEME.dim}// press r to run${THEME.reset}` + } + + lines.push(fitString(` ${progressText}`, width)) + lines.push(fitString(`${THEME.dim}${"─".repeat(width)}${THEME.reset}`, width)) + + // Error + if (pb.error) { + lines.push(fitString(`${THEME.error} Error: ${pb.error}${THEME.reset}`, width)) + lines.push(fitString(`${THEME.dim}${"─".repeat(width)}${THEME.reset}`, width)) + } + + const helpLines = 1 + const headerLines = lines.length + const availableHeight = height - headerLines - helpLines + + if (pb.steps.length === 0) { + const msgY = Math.floor(availableHeight / 2) + for (let i = 0; i < msgY; i++) lines.push(" ".repeat(width)) + lines.push(fitString(`${THEME.muted} No playbook steps configured.${THEME.reset}`, width)) + for (let i = lines.length; i < height - 1; i++) lines.push(" ".repeat(width)) + lines.push(renderHelpBar(width, pb.running)) + return lines.join("\n") + } + + // Split: step list (left) | step detail (right) + const stepItems = buildStepItems(pb.steps, state.animationFrame) + + // Use a viewport for step selection + const stepViewport = { offset: 0, selected: pb.selectedStep } + const leftLines = renderList(stepItems, stepViewport, availableHeight, Math.floor(width * 0.4), THEME) + + // Detail for selected step + const selectedStep = pb.steps[pb.selectedStep] + let rightLines: string[] + if (selectedStep) { + const detailContent = renderStepDetail(selectedStep, Math.floor(width * 0.6) - 2) + rightLines = renderBox(selectedStep.name, detailContent, Math.floor(width * 0.6) - 1, THEME, { style: "rounded" }) + // Pad right lines to fill available height + while (rightLines.length < availableHeight) { + rightLines.push(" ".repeat(Math.floor(width * 0.6) - 1)) + } + } else { + rightLines = [] + for (let i = 0; i < availableHeight; i++) { + rightLines.push(" ".repeat(Math.floor(width * 0.6) - 1)) + } + } + + const splitLines = renderSplit(leftLines, rightLines, width, availableHeight, THEME, 0.4) + for (const sl of splitLines) lines.push(sl) + + // Help bar + lines.push(renderHelpBar(width, pb.running)) + + // Pad to fill + while (lines.length < height) lines.push(" ".repeat(width)) + + return lines.join("\n") + }, + + handleInput(key: string, ctx: ScreenContext): boolean { + const pb = ctx.state.hunt.playbook + + // Always allow ESC + if (key === "\x1b" || key === "\x1b\x1b" || key === "q") { + ctx.app.setScreen("main") + return true + } + + // When running, only allow ESC + if (pb.running) return false + + // Navigate steps + if (key === "j" || key === "down") { + if (pb.steps.length > 0) { + const next = Math.min(pb.steps.length - 1, pb.selectedStep + 1) + ctx.state.hunt.playbook = { ...pb, selectedStep: next } + } + return true + } + if (key === "k" || key === "up") { + if (pb.steps.length > 0) { + const prev = Math.max(0, pb.selectedStep - 1) + ctx.state.hunt.playbook = { ...pb, selectedStep: prev } + } + return true + } + + // View step detail (enter) + if (key === "\r" || key === "return") { + // Already showing detail in split view, this is a no-op + return true + } + + // Run playbook + if (key === "r") { + // Reset all steps to pending + const resetSteps = pb.steps.map((s) => ({ ...s, status: "pending" as const, output: undefined, error: undefined, duration_ms: undefined })) + ctx.state.hunt.playbook = { ...pb, steps: resetSteps, running: true, error: null, report: null } + ctx.app.render() + + executePlaybook( + DEFAULT_CONFIG, + resetSteps, + (index: number, step: PlaybookStep) => { + const current = ctx.state.hunt.playbook + const updatedSteps = [...current.steps] + updatedSteps[index] = step + ctx.state.hunt.playbook = { ...current, steps: updatedSteps } + ctx.app.render() + }, + ) + .then((result) => { + ctx.state.hunt.playbook = { + ...ctx.state.hunt.playbook, + steps: result.steps, + running: false, + report: result.report ?? null, + } + ctx.app.render() + }) + .catch((err) => { + ctx.state.hunt.playbook = { + ...ctx.state.hunt.playbook, + running: false, + error: err instanceof Error ? err.message : String(err), + } + ctx.app.render() + }) + + return true + } + + return false + }, +} + +function renderHelpBar(width: number, running: boolean): string { + if (running) { + const help = `${THEME.dim}esc${THEME.reset}${THEME.muted} back${THEME.reset} ${THEME.secondary}running...${THEME.reset}` + return fitString(help, width) + } + const help = + `${THEME.dim}esc${THEME.reset}${THEME.muted} back${THEME.reset} ` + + `${THEME.dim}j/k${THEME.reset}${THEME.muted} select${THEME.reset} ` + + `${THEME.dim}r${THEME.reset}${THEME.muted} run${THEME.reset}` + return fitString(help, width) +} diff --git a/apps/terminal/src/tui/screens/hunt-query.ts b/apps/terminal/src/tui/screens/hunt-query.ts new file mode 100644 index 000000000..8ea55a7cb --- /dev/null +++ b/apps/terminal/src/tui/screens/hunt-query.ts @@ -0,0 +1,267 @@ +/** + * Hunt Query Screen - Hunt Query REPL + * + * Two modes: natural language (free text) and structured (filter form). + * Results displayed as a scrollable list of timeline events. + */ + +import { THEME } from "../theme" +import type { Screen, ScreenContext } from "../types" +import type { ListItem } from "../components/scrollable-list" +import { renderList, scrollUp, scrollDown } from "../components/scrollable-list" +import { renderForm, focusNext, focusPrev, handleFieldInput } from "../components/form" +import type { SelectField, TextField } from "../components/form" +import { renderBox } from "../components/box" +import { fitString } from "../components/types" +import { runQuery } from "../../hunt/bridge-query" +import type { TimelineEvent } from "../../hunt/types" + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function verdictColor(verdict: string): string { + switch (verdict) { + case "allow": return THEME.success + case "deny": return THEME.error + case "audit": return THEME.warning + default: return THEME.muted + } +} + +function formatEvent(evt: TimelineEvent): string { + const ts = evt.timestamp.length > 19 ? evt.timestamp.slice(11, 19) : evt.timestamp + const vc = verdictColor(evt.verdict) + return `${THEME.dim}${ts}${THEME.reset} ${vc}${evt.verdict.padEnd(5)}${THEME.reset} ${THEME.muted}${evt.source}${THEME.reset} ${THEME.white}${evt.summary}${THEME.reset}` +} + +function eventsToListItems(events: TimelineEvent[]): ListItem[] { + return events.map((evt, i) => ({ + label: formatEvent(evt), + plainLength: `${evt.timestamp.slice(11, 19)} ${evt.verdict.padEnd(5)} ${evt.source} ${evt.summary}`.length, + key: `evt-${i}`, + })) +} + +// --------------------------------------------------------------------------- +// Screen +// --------------------------------------------------------------------------- + +export const huntQueryScreen: Screen = { + render(ctx: ScreenContext): string { + const { state, width, height } = ctx + const q = state.hunt.query + const lines: string[] = [] + + // Header + const modeLabel = q.mode === "nl" ? "Natural Language" : "Structured" + const title = `${THEME.secondary}${THEME.bold} Hunt Query ${THEME.reset}${THEME.dim} \u2014 ${modeLabel}${THEME.reset}` + lines.push(fitString(title, width)) + lines.push(fitString(`${THEME.dim}${"─".repeat(width)}${THEME.reset}`, width)) + + if (q.mode === "nl") { + // NL input box + const cursor = `${THEME.accent}\u2588${THEME.reset}` + const inputLine = `${THEME.muted} > ${THEME.reset}${THEME.white}${q.nlInput || `${THEME.dim}${THEME.italic}Type a query...${THEME.reset}`}${THEME.reset}${cursor}` + const boxContent = [fitString(inputLine, width - 4)] + const boxLines = renderBox("Query", boxContent, width, THEME, { style: "rounded", padding: 1 }) + lines.push(...boxLines) + } else { + // Structured form + const formLines = renderForm(q.structuredForm, width - 4, THEME) + const boxLines = renderBox("Filters", formLines, width, THEME, { style: "rounded", padding: 1 }) + lines.push(...boxLines) + } + + lines.push(fitString("", width)) + + // Loading indicator + if (q.loading) { + const spinChars = ["\u2847", "\u2846", "\u2834", "\u2831", "\u2839", "\u283B", "\u283F", "\u2857"] + const frame = ctx.state.animationFrame % spinChars.length + lines.push(fitString(`${THEME.accent} ${spinChars[frame]} Querying...${THEME.reset}`, width)) + while (lines.length < height - 1) lines.push(" ".repeat(width)) + lines.push(fitString(`${THEME.dim} Tab switch mode ESC back${THEME.reset}`, width)) + return lines.join("\n") + } + + // Error + if (q.error) { + lines.push(fitString(`${THEME.error} Error: ${q.error}${THEME.reset}`, width)) + lines.push(fitString("", width)) + } + + // Results header + const resultCount = q.results.length + lines.push(fitString(`${THEME.muted} Results: ${resultCount}${THEME.reset}`, width)) + lines.push(fitString(`${THEME.dim}${"─".repeat(width)}${THEME.reset}`, width)) + + // Results list + const usedLines = lines.length + 2 // reserve for footer + const listHeight = Math.max(1, height - usedLines) + + if (resultCount === 0 && !q.error) { + lines.push(fitString(`${THEME.muted} No results. Enter a query to search.${THEME.reset}`, width)) + while (lines.length < height - 1) lines.push(" ".repeat(width)) + } else { + const items = eventsToListItems(q.results) + const listLines = renderList(items, q.resultList, listHeight, width, THEME) + lines.push(...listLines) + } + + // Footer + while (lines.length < height - 1) lines.push(" ".repeat(width)) + const footerParts = q.mode === "nl" + ? "Enter execute Tab structured mode t timeline ESC back" + : "j/k navigate fields Enter execute Tab NL mode t timeline ESC back" + lines.push(fitString(`${THEME.dim} ${footerParts}${THEME.reset}`, width)) + + while (lines.length < height) lines.push(" ".repeat(width)) + return lines.join("\n") + }, + + handleInput(key: string, ctx: ScreenContext): boolean { + const q = ctx.state.hunt.query + + if (key === "\x1b" || key === "\x1b\x1b") { + ctx.app.setScreen("main") + return true + } + + if (q.loading) return false + + // Tab: switch modes + if (key === "\t" || key === "tab") { + ctx.state.hunt.query.mode = q.mode === "nl" ? "structured" : "nl" + ctx.app.render() + return true + } + + // t: pivot to timeline with selected event + if (key === "t" && q.results.length > 0) { + // Copy results to timeline state for pivot + ctx.state.hunt.timeline.events = q.results + ctx.state.hunt.timeline.list = { offset: 0, selected: q.resultList.selected } + ctx.app.setScreen("hunt-timeline") + return true + } + + if (q.mode === "nl") { + // Enter: execute NL query + if (key === "\r" || key === "enter") { + if (q.nlInput.trim()) { + doQuery(ctx) + } + return true + } + + // Backspace + if (key === "backspace" || key === "\x7f" || key === "\b") { + ctx.state.hunt.query.nlInput = q.nlInput.slice(0, -1) + ctx.app.render() + return true + } + + // Navigate results when we have them + if (q.results.length > 0) { + if (key === "j" || key === "down") { + ctx.state.hunt.query.resultList = scrollDown(q.resultList, q.results.length, ctx.height - 12) + ctx.app.render() + return true + } + if (key === "k" || key === "up") { + ctx.state.hunt.query.resultList = scrollUp(q.resultList) + ctx.app.render() + return true + } + } + + // Printable character: add to input + if (key.length === 1 && key >= " ") { + ctx.state.hunt.query.nlInput += key + ctx.app.render() + return true + } + + return false + } + + // Structured mode + if (key === "\r" || key === "enter") { + doStructuredQuery(ctx) + return true + } + + if (key === "j" || key === "down") { + // If we have results and are past the form, navigate results + ctx.state.hunt.query.structuredForm = focusNext(q.structuredForm) + ctx.app.render() + return true + } + + if (key === "k" || key === "up") { + ctx.state.hunt.query.structuredForm = focusPrev(q.structuredForm) + ctx.app.render() + return true + } + + // Field input (left/right for selects, chars for text) + const updated = handleFieldInput(q.structuredForm, key) + if (updated !== q.structuredForm) { + ctx.state.hunt.query.structuredForm = updated + ctx.app.render() + return true + } + + return false + }, +} + +async function doQuery(ctx: ScreenContext) { + const q = ctx.state.hunt.query + ctx.state.hunt.query.loading = true + ctx.state.hunt.query.error = null + ctx.app.render() + try { + const results = await runQuery({ nl: q.nlInput }) + ctx.state.hunt.query.results = results + ctx.state.hunt.query.resultList = { offset: 0, selected: 0 } + ctx.state.hunt.query.loading = false + } catch (err) { + ctx.state.hunt.query.error = err instanceof Error ? err.message : String(err) + ctx.state.hunt.query.loading = false + } + ctx.app.render() +} + +async function doStructuredQuery(ctx: ScreenContext) { + const form = ctx.state.hunt.query.structuredForm + const sourceField = form.fields[0] as SelectField + const verdictField = form.fields[1] as SelectField + const sinceField = form.fields[2] as TextField + const limitField = form.fields[3] as TextField + + const source = sourceField.options[sourceField.selectedIndex] + const verdict = verdictField.options[verdictField.selectedIndex] + const since = sinceField.value.trim() + const limit = parseInt(limitField.value.trim(), 10) + + ctx.state.hunt.query.loading = true + ctx.state.hunt.query.error = null + ctx.app.render() + try { + const results = await runQuery({ + source: source !== "any" ? source as "tetragon" | "hubble" | "receipt" | "spine" : undefined, + verdict: verdict !== "any" ? verdict as "allow" | "deny" | "audit" : undefined, + since: since || undefined, + limit: isNaN(limit) ? undefined : limit, + }) + ctx.state.hunt.query.results = results + ctx.state.hunt.query.resultList = { offset: 0, selected: 0 } + ctx.state.hunt.query.loading = false + } catch (err) { + ctx.state.hunt.query.error = err instanceof Error ? err.message : String(err) + ctx.state.hunt.query.loading = false + } + ctx.app.render() +} diff --git a/apps/terminal/src/tui/screens/hunt-report.ts b/apps/terminal/src/tui/screens/hunt-report.ts new file mode 100644 index 000000000..e52096eb3 --- /dev/null +++ b/apps/terminal/src/tui/screens/hunt-report.ts @@ -0,0 +1,259 @@ +/** + * Hunt Report Screen - Evidence report viewer with expand/collapse and export. + */ + +import { THEME } from "../theme" +import type { Screen, ScreenContext } from "../types" +import type { EvidenceItem, RuleSeverity } from "../../hunt/types" +import { scrollUp, scrollDown, type ListItem } from "../components/scrollable-list" +import { renderBox } from "../components/box" +import { fitString } from "../components/types" + +const SEVERITY_COLORS: Record<RuleSeverity, string> = { + low: THEME.muted, + medium: THEME.warning, + high: THEME.warning, + critical: THEME.error, +} + +function formatTimestamp(iso: string): string { + try { + const d = new Date(iso) + return d.toLocaleString() + } catch { + return iso + } +} + +function evidenceToListItem(item: EvidenceItem, expanded: boolean): ListItem { + const expandIcon = expanded ? "\u25BC" : "\u25B6" + const verdictColor = item.event.verdict === "deny" ? THEME.error + : item.event.verdict === "allow" ? THEME.success + : item.event.verdict === "audit" ? THEME.warning + : THEME.dim + const label = + `${THEME.muted}${expandIcon}${THEME.reset} ` + + `${THEME.secondary}#${item.index}${THEME.reset} ` + + `${verdictColor}[${item.event.verdict}]${THEME.reset} ` + + `${THEME.white}${item.event.summary}${THEME.reset} ` + + `${THEME.dim}- ${item.relevance}${THEME.reset}` + const plain = `${expandIcon} #${item.index} [${item.event.verdict}] ${item.event.summary} - ${item.relevance}` + return { label, plainLength: plain.length } +} + +function renderExpandedEvidence(item: EvidenceItem, width: number): string[] { + const lines: string[] = [] + const indent = " " + lines.push(fitString(`${indent}${THEME.dim}Source:${THEME.reset} ${THEME.white}${item.event.source}${THEME.reset}`, width)) + lines.push(fitString(`${indent}${THEME.dim}Kind:${THEME.reset} ${THEME.white}${item.event.kind}${THEME.reset}`, width)) + lines.push(fitString(`${indent}${THEME.dim}Time:${THEME.reset} ${THEME.white}${formatTimestamp(item.event.timestamp)}${THEME.reset}`, width)) + + // Show details + const detailKeys = Object.keys(item.event.details) + if (detailKeys.length > 0) { + lines.push(fitString(`${indent}${THEME.dim}Details:${THEME.reset}`, width)) + for (const key of detailKeys.slice(0, 8)) { + const val = String(item.event.details[key] ?? "") + const truncVal = val.length > 50 ? val.slice(0, 47) + "..." : val + lines.push(fitString(`${indent} ${THEME.tertiary}${key}${THEME.reset}: ${THEME.white}${truncVal}${THEME.reset}`, width)) + } + if (detailKeys.length > 8) { + lines.push(fitString(`${indent} ${THEME.dim}... +${detailKeys.length - 8} more fields${THEME.reset}`, width)) + } + } + + // Merkle proof + if (item.merkle_proof && item.merkle_proof.length > 0) { + lines.push(fitString(`${indent}${THEME.dim}Merkle proof:${THEME.reset}`, width)) + for (const hash of item.merkle_proof.slice(0, 4)) { + lines.push(fitString(`${indent} ${THEME.tertiary}${hash}${THEME.reset}`, width)) + } + if (item.merkle_proof.length > 4) { + lines.push(fitString(`${indent} ${THEME.dim}... +${item.merkle_proof.length - 4} more${THEME.reset}`, width)) + } + } + + lines.push(" ".repeat(width)) + return lines +} + +export const huntReportScreen: Screen = { + render(ctx: ScreenContext): string { + const { state, width, height } = ctx + const rs = state.hunt.report + const lines: string[] = [] + + // Title + const title = `${THEME.accent}${THEME.bold} HUNT ${THEME.reset}${THEME.dim} // ${THEME.reset}${THEME.secondary}Evidence Report${THEME.reset}` + lines.push(fitString(title, width)) + lines.push(fitString(`${THEME.dim}${"─".repeat(width)}${THEME.reset}`, width)) + + if (rs.error) { + lines.push(fitString(`${THEME.error} Error: ${rs.error}${THEME.reset}`, width)) + } + + // Empty state + if (!rs.report) { + const msgY = Math.floor(height / 2) - 1 + for (let i = 2; i < msgY; i++) lines.push(" ".repeat(width)) + lines.push(fitString(`${THEME.muted} No report loaded.${THEME.reset}`, width)) + lines.push(fitString(`${THEME.dim} Run a correlation or open a report to view evidence.${THEME.reset}`, width)) + for (let i = lines.length; i < height - 1; i++) lines.push(" ".repeat(width)) + lines.push(renderHelpBar(width)) + return lines.join("\n") + } + + const report = rs.report + const innerWidth = Math.min(78, width - 4) + + // -- Alert header box -- + const severityColor = SEVERITY_COLORS[report.severity] ?? THEME.muted + const headerLines: string[] = [ + fitString(`${THEME.white}${THEME.bold}${report.title}${THEME.reset}`, innerWidth - 2), + fitString( + `${severityColor}${report.severity.toUpperCase()}${THEME.reset}` + + `${THEME.dim} | ${THEME.reset}` + + `${THEME.muted}${formatTimestamp(report.created_at)}${THEME.reset}` + + `${THEME.dim} | ${THEME.reset}` + + `${THEME.muted}ID: ${report.id}${THEME.reset}`, + innerWidth - 2, + ), + fitString( + `${THEME.dim}Rule: ${THEME.reset}${THEME.white}${report.alert.rule}${THEME.reset}`, + innerWidth - 2, + ), + ] + if (report.summary) { + headerLines.push(fitString(`${THEME.muted}${report.summary}${THEME.reset}`, innerWidth - 2)) + } + const headerBox = renderBox("Report", headerLines, innerWidth, THEME, { style: "double", padding: 1 }) + for (const l of headerBox) lines.push(fitString(` ${l}`, width)) + + // -- Evidence list -- + const evidenceItems: ListItem[] = [] + const expandedRows: string[][] = [] + + for (let i = 0; i < report.evidence.length; i++) { + const ev = report.evidence[i] + const isExpanded = rs.expandedEvidence === i + evidenceItems.push(evidenceToListItem(ev, isExpanded)) + if (isExpanded) { + expandedRows.push(renderExpandedEvidence(ev, innerWidth - 4)) + } else { + expandedRows.push([]) + } + } + + // Interleave items and expanded details + const allEvidenceLines: string[] = [] + for (let i = 0; i < evidenceItems.length; i++) { + const item = evidenceItems[i] + const isSelected = i === rs.list.selected + if (isSelected) { + const marker = `${THEME.accent}${THEME.bold} \u25B8 ${THEME.reset}` + allEvidenceLines.push(fitString(`${marker}${item.label}`, innerWidth - 2)) + } else { + allEvidenceLines.push(fitString(` ${item.label}`, innerWidth - 2)) + } + for (const expLine of expandedRows[i]) { + allEvidenceLines.push(fitString(` ${expLine}`, innerWidth - 2)) + } + } + + if (allEvidenceLines.length === 0) { + allEvidenceLines.push(fitString(`${THEME.muted} (no evidence items)${THEME.reset}`, innerWidth - 2)) + } + + // Calculate available height for evidence + const usedLines = lines.length + 6 // 6 for merkle + help + padding + const evidenceHeight = Math.max(3, height - usedLines) + const visibleEvidence = allEvidenceLines.slice(0, evidenceHeight) + + const evidenceBox = renderBox(`Evidence (${report.evidence.length})`, visibleEvidence, innerWidth, THEME, { style: "rounded", padding: 1 }) + for (const l of evidenceBox) lines.push(fitString(` ${l}`, width)) + + // -- Merkle root -- + if (report.merkle_root) { + lines.push(fitString( + ` ${THEME.dim}Merkle Root:${THEME.reset} ${THEME.tertiary}${report.merkle_root}${THEME.reset}`, + width, + )) + } + + // -- Recommendations -- + if (report.recommendations && report.recommendations.length > 0) { + lines.push(fitString(` ${THEME.secondary}Recommendations:${THEME.reset}`, width)) + for (const rec of report.recommendations.slice(0, 3)) { + lines.push(fitString(` ${THEME.dim}-${THEME.reset} ${THEME.muted}${rec}${THEME.reset}`, width)) + } + } + + // Fill to bottom + while (lines.length < height - 1) lines.push(" ".repeat(width)) + + // Help bar + lines.push(renderHelpBar(width)) + + return lines.join("\n") + }, + + handleInput(key: string, ctx: ScreenContext): boolean { + const rs = ctx.state.hunt.report + + // Navigation: back + if (key === "q" || key === "\x1b" || key === "\x1b\x1b") { + ctx.app.setScreen("main") + return true + } + + if (!rs.report) return false + + const evidenceCount = rs.report.evidence.length + if (evidenceCount === 0) return false + + // Navigate evidence + if (key === "j" || key === "down") { + ctx.state.hunt.report = { ...rs, list: scrollDown(rs.list, evidenceCount, 20) } + return true + } + if (key === "k" || key === "up") { + ctx.state.hunt.report = { ...rs, list: scrollUp(rs.list) } + return true + } + + // Expand/collapse + if (key === "enter" || key === "\r") { + const selected = rs.list.selected + const isExpanded = rs.expandedEvidence === selected + ctx.state.hunt.report = { + ...rs, + expandedEvidence: isExpanded ? null : selected, + } + return true + } + + // Copy report JSON to clipboard + if (key === "c") { + const json = JSON.stringify(rs.report, null, 2) + try { + const proc = Bun.spawn(["pbcopy"], { stdin: "pipe" }) + proc.stdin.write(json) + proc.stdin.end() + } catch { + // Clipboard copy failed silently + } + return true + } + + return false + }, +} + +function renderHelpBar(width: number): string { + const help = + `${THEME.dim}j/k${THEME.reset}${THEME.muted} navigate${THEME.reset} ` + + `${THEME.dim}Enter${THEME.reset}${THEME.muted} expand${THEME.reset} ` + + `${THEME.dim}c${THEME.reset}${THEME.muted} copy JSON${THEME.reset} ` + + `${THEME.dim}ESC${THEME.reset}${THEME.muted} back${THEME.reset}` + return fitString(help, width) +} diff --git a/apps/terminal/src/tui/screens/hunt-rule-builder.ts b/apps/terminal/src/tui/screens/hunt-rule-builder.ts new file mode 100644 index 000000000..49f4dd529 --- /dev/null +++ b/apps/terminal/src/tui/screens/hunt-rule-builder.ts @@ -0,0 +1,370 @@ +/** + * Hunt Rule Builder Screen - Correlation rule creation with dry run. + */ + +import { THEME } from "../theme" +import type { Screen, ScreenContext, HuntRuleBuilderState } from "../types" +import type { RuleCondition, RuleSeverity, CorrelationRule, Alert } from "../../hunt/types" +import { runCorrelate } from "../../hunt/bridge-correlate" +import { renderForm, focusNext, focusPrev, handleFieldInput, type FormState } from "../components/form" +import { renderList, scrollUp, scrollDown, type ListItem } from "../components/scrollable-list" +import { renderBox } from "../components/box" +import { fitString } from "../components/types" + +type Section = "form" | "conditions" | "actions" +const SECTIONS: Section[] = ["form", "conditions", "actions"] + +let activeSection: Section = "form" + +function getFormValues(form: FormState): { name: string; severity: RuleSeverity; windowSeconds: number; description: string } { + const nameField = form.fields[0] + const severityField = form.fields[1] + const windowField = form.fields[2] + const descField = form.fields[3] + + const name = nameField.type === "text" ? nameField.value : "" + const severity = (severityField.type === "select" + ? severityField.options[severityField.selectedIndex] + : "medium") as RuleSeverity + const windowSeconds = windowField.type === "text" ? parseInt(windowField.value, 10) || 300 : 300 + const description = descField.type === "text" ? descField.value : "" + + return { name, severity, windowSeconds, description } +} + +function buildRuleYaml(rb: HuntRuleBuilderState): string { + const { name, severity, windowSeconds, description } = getFormValues(rb.form) + + const rule: CorrelationRule = { + name: name || "untitled-rule", + severity, + window_seconds: windowSeconds, + description: description || undefined, + conditions: rb.conditions.length > 0 ? rb.conditions : [{ source: "tetragon", verdict: "deny" }], + output: { + title: name || "Untitled Rule", + severity, + description: description || undefined, + }, + } + + // Simple YAML serialization + let yaml = `name: ${rule.name}\n` + yaml += `severity: ${rule.severity}\n` + yaml += `window_seconds: ${rule.window_seconds}\n` + if (rule.description) yaml += `description: ${rule.description}\n` + yaml += `conditions:\n` + for (const c of rule.conditions) { + yaml += ` - ` + const parts: string[] = [] + if (c.source) parts.push(`source: ${c.source}`) + if (c.kind) parts.push(`kind: ${c.kind}`) + if (c.verdict) parts.push(`verdict: ${c.verdict}`) + if (c.pattern) parts.push(`pattern: "${c.pattern}"`) + if (c.field) parts.push(`field: ${c.field}`) + if (c.value) parts.push(`value: "${c.value}"`) + yaml += `{ ${parts.join(", ")} }\n` + } + yaml += `output:\n` + yaml += ` title: ${rule.output.title}\n` + yaml += ` severity: ${rule.output.severity}\n` + if (rule.output.description) yaml += ` description: ${rule.output.description}\n` + + return yaml +} + +function conditionToListItem(c: RuleCondition, idx: number): ListItem { + const parts: string[] = [] + if (c.source) parts.push(`src:${c.source}`) + if (c.kind) parts.push(`kind:${c.kind}`) + if (c.verdict) parts.push(`verdict:${c.verdict}`) + if (c.pattern) parts.push(`pat:"${c.pattern}"`) + if (c.field) parts.push(`${c.field}=${c.value ?? "*"}`) + const label = parts.length > 0 + ? `${THEME.muted}${idx + 1}.${THEME.reset} ${THEME.white}${parts.join(" ")}${THEME.reset}` + : `${THEME.muted}${idx + 1}.${THEME.reset} ${THEME.dim}(empty condition)${THEME.reset}` + return { label, plainLength: `${idx + 1}. ${parts.join(" ")}`.length } +} + +function alertToListItem(a: Alert, _idx: number): ListItem { + const severityColor = a.severity === "critical" ? THEME.error + : a.severity === "high" ? THEME.warning + : THEME.muted + const label = + `${severityColor}[${a.severity}]${THEME.reset} ` + + `${THEME.white}${a.title}${THEME.reset} ` + + `${THEME.dim}(${a.matched_events.length} events)${THEME.reset}` + return { label, plainLength: `[${a.severity}] ${a.title} (${a.matched_events.length} events)`.length } +} + +export const huntRuleBuilderScreen: Screen = { + onEnter(_ctx: ScreenContext): void { + activeSection = "form" + }, + + render(ctx: ScreenContext): string { + const { state, width, height } = ctx + const rb = state.hunt.ruleBuilder + const lines: string[] = [] + + // Title + const title = `${THEME.accent}${THEME.bold} HUNT ${THEME.reset}${THEME.dim} // ${THEME.reset}${THEME.secondary}Rule Builder${THEME.reset}` + lines.push(fitString(title, width)) + lines.push(fitString(`${THEME.dim}${"─".repeat(width)}${THEME.reset}`, width)) + + // Error / status + if (rb.error) { + lines.push(fitString(`${THEME.error} Error: ${rb.error}${THEME.reset}`, width)) + } else if (rb.statusMessage) { + lines.push(fitString(`${THEME.success} ${rb.statusMessage}${THEME.reset}`, width)) + } + + const innerWidth = Math.min(74, width - 4) + + // -- Form section -- + const formHighlight = activeSection === "form" ? THEME.secondary : THEME.dim + const formTitle = `${formHighlight}Rule Metadata${THEME.reset}` + const formLines = renderForm(rb.form, innerWidth - 2, THEME) + const formBox = renderBox(formTitle, formLines, innerWidth, THEME, { style: "rounded", padding: 1 }) + for (const l of formBox) lines.push(fitString(` ${l}`, width)) + + // -- Conditions section -- + const condHighlight = activeSection === "conditions" ? THEME.secondary : THEME.dim + const condTitle = `${condHighlight}Conditions${THEME.reset}` + const condItems = rb.conditions.map((c, i) => conditionToListItem(c, i)) + const condListHeight = Math.min(6, Math.max(2, rb.conditions.length + 1)) + const condLines = renderList(condItems, rb.conditionList, condListHeight, innerWidth - 2, THEME) + const condBox = renderBox(condTitle, condLines, innerWidth, THEME, { style: "rounded", padding: 1 }) + for (const l of condBox) lines.push(fitString(` ${l}`, width)) + + // -- Dry run results (if any) -- + if (rb.dryRunResults.length > 0 || rb.dryRunning) { + const drTitle = rb.dryRunning + ? `${THEME.warning}Dry Run (running...)${THEME.reset}` + : `${THEME.success}Dry Run Results (${rb.dryRunResults.length})${THEME.reset}` + const drItems = rb.dryRunResults.map((a, i) => alertToListItem(a, i)) + const drListHeight = Math.min(5, Math.max(1, rb.dryRunResults.length)) + const drLines = renderList(drItems, { offset: 0, selected: 0 }, drListHeight, innerWidth - 2, THEME) + const drBox = renderBox(drTitle, drLines, innerWidth, THEME, { style: "single", padding: 1 }) + for (const l of drBox) lines.push(fitString(` ${l}`, width)) + } + + // -- Actions bar -- + const actHighlight = activeSection === "actions" ? THEME.secondary : THEME.dim + const actionBar = + ` ${actHighlight}Actions:${THEME.reset} ` + + `${THEME.dim}[D]${THEME.reset}${THEME.muted} Dry Run${THEME.reset} ` + + `${THEME.dim}[W]${THEME.reset}${THEME.muted} Save Rule${THEME.reset} ` + + `${THEME.dim}[a]${THEME.reset}${THEME.muted} Add Condition${THEME.reset} ` + + `${THEME.dim}[x]${THEME.reset}${THEME.muted} Delete Condition${THEME.reset}` + lines.push(fitString(actionBar, width)) + + // Fill to bottom + while (lines.length < height - 1) lines.push(" ".repeat(width)) + + // Help bar + lines.push(renderHelpBar(width)) + + return lines.join("\n") + }, + + handleInput(key: string, ctx: ScreenContext): boolean { + const rb = ctx.state.hunt.ruleBuilder + + // Navigation: back + if (key === "\x1b" || key === "\x1b\x1b") { + if (activeSection !== "form") { + activeSection = "form" + return true + } + ctx.app.setScreen("main") + return true + } + + // Tab: cycle sections + if (key === "\t") { + const idx = SECTIONS.indexOf(activeSection) + activeSection = SECTIONS[(idx + 1) % SECTIONS.length] + return true + } + + // Section-specific input + if (activeSection === "form") { + const focusedField = rb.form.fields[rb.form.focusedIndex] + const isTextField = focusedField?.type === "text" + + // When a text field is focused, send printable chars and backspace to the form first + if (isTextField) { + if (key === "\x7f" || key === "\b" || key === "backspace" || (key.length === 1 && key >= " ")) { + const newForm = handleFieldInput(rb.form, key) + if (newForm !== rb.form) { + ctx.state.hunt.ruleBuilder = { ...rb, form: newForm } + return true + } + return false + } + } + + // j/k or arrow keys navigate form fields (only when not typing in a text field) + if (key === "j" || key === "down" || key === "\x1b[B") { + ctx.state.hunt.ruleBuilder = { ...rb, form: focusNext(rb.form) } + return true + } + if (key === "k" || key === "up" || key === "\x1b[A") { + ctx.state.hunt.ruleBuilder = { ...rb, form: focusPrev(rb.form) } + return true + } + // Pass remaining input to form (for select left/right, toggle space, etc.) + const newForm = handleFieldInput(rb.form, key) + if (newForm !== rb.form) { + ctx.state.hunt.ruleBuilder = { ...rb, form: newForm } + return true + } + return false + } + + if (activeSection === "conditions") { + if (key === "j" || key === "down") { + if (rb.conditions.length > 0) { + ctx.state.hunt.ruleBuilder = { ...rb, conditionList: scrollDown(rb.conditionList, rb.conditions.length, 6) } + } + return true + } + if (key === "k" || key === "up") { + if (rb.conditions.length > 0) { + ctx.state.hunt.ruleBuilder = { ...rb, conditionList: scrollUp(rb.conditionList) } + } + return true + } + // Add condition + if (key === "a") { + const newCondition: RuleCondition = { source: "tetragon", verdict: "deny" } + ctx.state.hunt.ruleBuilder = { + ...rb, + conditions: [...rb.conditions, newCondition], + } + return true + } + // Delete condition + if (key === "x") { + if (rb.conditions.length > 0) { + const idx = rb.conditionList.selected + const newConditions = rb.conditions.filter((_, i) => i !== idx) + const newSelected = Math.min(rb.conditionList.selected, Math.max(0, newConditions.length - 1)) + ctx.state.hunt.ruleBuilder = { + ...rb, + conditions: newConditions, + conditionList: { ...rb.conditionList, selected: newSelected }, + } + } + return true + } + return false + } + + // Actions section + if (activeSection === "actions") { + // Dry run + if (key === "D" || key === "d") { + if (rb.dryRunning) return true + ctx.state.hunt.ruleBuilder = { ...rb, dryRunning: true, dryRunResults: [], error: null, statusMessage: null } + ctx.app.render() + + const yaml = buildRuleYaml(rb) + const tmpPath = `/tmp/clawdstrike-rule-${Date.now()}.yaml` + + // Write temp file and run correlate + void (async () => { + try { + await Bun.write(tmpPath, yaml) + const alerts = await runCorrelate({ rules: [tmpPath] }) + ctx.state.hunt.ruleBuilder = { + ...ctx.state.hunt.ruleBuilder, + dryRunning: false, + dryRunResults: alerts, + statusMessage: `Dry run complete: ${alerts.length} alert(s)`, + } + } catch (err) { + ctx.state.hunt.ruleBuilder = { + ...ctx.state.hunt.ruleBuilder, + dryRunning: false, + error: err instanceof Error ? err.message : String(err), + } + } + ctx.app.render() + })() + return true + } + + // Save rule + if (key === "W" || key === "w") { + if (rb.saving) return true + const { name } = getFormValues(rb.form) + if (!name) { + ctx.state.hunt.ruleBuilder = { ...rb, error: "Rule name is required" } + return true + } + + ctx.state.hunt.ruleBuilder = { ...rb, saving: true, error: null, statusMessage: null } + ctx.app.render() + + const yaml = buildRuleYaml(rb) + const home = process.env.HOME ?? "~" + const rulePath = `${home}/.clawdstrike/rules/${name}.yaml` + + void (async () => { + try { + await Bun.write(rulePath, yaml) + ctx.state.hunt.ruleBuilder = { + ...ctx.state.hunt.ruleBuilder, + saving: false, + statusMessage: `Rule saved to ${rulePath}`, + } + } catch (err) { + ctx.state.hunt.ruleBuilder = { + ...ctx.state.hunt.ruleBuilder, + saving: false, + error: err instanceof Error ? err.message : String(err), + } + } + ctx.app.render() + })() + return true + } + + // Also allow add/delete from actions section + if (key === "a") { + const newCondition: RuleCondition = { source: "tetragon", verdict: "deny" } + ctx.state.hunt.ruleBuilder = { ...rb, conditions: [...rb.conditions, newCondition] } + return true + } + if (key === "x") { + if (rb.conditions.length > 0) { + const idx = rb.conditionList.selected + const newConditions = rb.conditions.filter((_, i) => i !== idx) + const newSelected = Math.min(rb.conditionList.selected, Math.max(0, newConditions.length - 1)) + ctx.state.hunt.ruleBuilder = { + ...rb, + conditions: newConditions, + conditionList: { ...rb.conditionList, selected: newSelected }, + } + } + return true + } + + return false + } + + return false + }, +} + +function renderHelpBar(width: number): string { + const help = + `${THEME.dim}Tab${THEME.reset}${THEME.muted} section${THEME.reset} ` + + `${THEME.dim}j/k${THEME.reset}${THEME.muted} navigate${THEME.reset} ` + + `${THEME.dim}D${THEME.reset}${THEME.muted} dry run${THEME.reset} ` + + `${THEME.dim}W${THEME.reset}${THEME.muted} save${THEME.reset} ` + + `${THEME.dim}ESC${THEME.reset}${THEME.muted} back${THEME.reset}` + return fitString(help, width) +} diff --git a/apps/terminal/src/tui/screens/hunt-scan.ts b/apps/terminal/src/tui/screens/hunt-scan.ts new file mode 100644 index 000000000..a1bfc8f91 --- /dev/null +++ b/apps/terminal/src/tui/screens/hunt-scan.ts @@ -0,0 +1,367 @@ +/** + * Hunt Scan Screen - MCP Scan Explorer + * + * Tree view (left 60%) showing scanned paths/clients/servers/tools, + * with a detail pane (right 40%) for selected node info. + */ + +import { THEME } from "../theme" +import type { Screen, ScreenContext } from "../types" +import type { TreeNode } from "../components/tree-view" +import { + renderTree, + flattenTree, + toggleExpand, + moveUp, + moveDown, +} from "../components/tree-view" +import { renderSplit } from "../components/split-pane" +import { fitString } from "../components/types" +import { runScan } from "../../hunt/bridge-scan" +import type { ScanPathResult, ServerScanResult } from "../../hunt/types" + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function serverStatusColor(srv: ServerScanResult): string { + if (srv.violations.length > 0) return THEME.error + if (srv.issues.length > 0) return THEME.warning + return THEME.success +} + +function buildTreeNodes(results: ScanPathResult[]): TreeNode[] { + return results.map((r) => { + const serverNodes: TreeNode[] = r.servers.map((srv) => { + const children: TreeNode[] = [] + + if (srv.signature) { + if (srv.signature.tools.length > 0) { + children.push({ + label: `Tools (${srv.signature.tools.length})`, + plainLength: `Tools (${srv.signature.tools.length})`.length, + key: `${r.path}:${srv.name}:tools`, + icon: "\u2699", + color: THEME.muted, + children: srv.signature.tools.map((t) => ({ + label: t.name, + plainLength: t.name.length, + key: `${r.path}:${srv.name}:tool:${t.name}`, + color: THEME.white, + })), + }) + } + if (srv.signature.prompts.length > 0) { + children.push({ + label: `Prompts (${srv.signature.prompts.length})`, + plainLength: `Prompts (${srv.signature.prompts.length})`.length, + key: `${r.path}:${srv.name}:prompts`, + icon: "\u270E", + color: THEME.muted, + children: srv.signature.prompts.map((p) => ({ + label: p, + plainLength: p.length, + key: `${r.path}:${srv.name}:prompt:${p}`, + color: THEME.white, + })), + }) + } + if (srv.signature.resources.length > 0) { + children.push({ + label: `Resources (${srv.signature.resources.length})`, + plainLength: `Resources (${srv.signature.resources.length})`.length, + key: `${r.path}:${srv.name}:resources`, + icon: "\u2691", + color: THEME.muted, + children: srv.signature.resources.map((res) => ({ + label: res, + plainLength: res.length, + key: `${r.path}:${srv.name}:resource:${res}`, + color: THEME.white, + })), + }) + } + } + + if (srv.violations.length > 0) { + children.push({ + label: `Violations (${srv.violations.length})`, + plainLength: `Violations (${srv.violations.length})`.length, + key: `${r.path}:${srv.name}:violations`, + icon: "\u2716", + color: THEME.error, + children: srv.violations.map((v, vi) => ({ + label: `${v.guard}: ${v.target}`, + plainLength: `${v.guard}: ${v.target}`.length, + key: `${r.path}:${srv.name}:violation:${vi}`, + color: THEME.error, + })), + }) + } + + if (srv.issues.length > 0) { + children.push({ + label: `Issues (${srv.issues.length})`, + plainLength: `Issues (${srv.issues.length})`.length, + key: `${r.path}:${srv.name}:issues`, + icon: "\u26A0", + color: THEME.warning, + children: srv.issues.map((iss, ii) => ({ + label: `[${iss.severity}] ${iss.message}`, + plainLength: `[${iss.severity}] ${iss.message}`.length, + key: `${r.path}:${srv.name}:issue:${ii}`, + color: iss.severity === "critical" || iss.severity === "error" + ? THEME.error + : THEME.warning, + })), + }) + } + + return { + label: srv.name, + plainLength: srv.name.length, + key: `${r.path}:${srv.name}`, + icon: "\u25CF", + color: serverStatusColor(srv), + children, + } + }) + + return { + label: `${r.client} \u2014 ${r.path}`, + plainLength: `${r.client} \u2014 ${r.path}`.length, + key: r.path, + icon: "\u229A", + color: THEME.secondary, + children: serverNodes, + } + }) +} + +function findServerForKey( + results: ScanPathResult[], + key: string, +): { path: ScanPathResult; server: ServerScanResult } | null { + for (const r of results) { + for (const srv of r.servers) { + if (key.startsWith(`${r.path}:${srv.name}`)) { + return { path: r, server: srv } + } + } + } + return null +} + +function renderDetail( + results: ScanPathResult[], + selectedKey: string | null, + height: number, + width: number, +): string[] { + const lines: string[] = [] + + if (!selectedKey) { + lines.push(fitString(`${THEME.muted} Select a node to view details${THEME.reset}`, width)) + while (lines.length < height) lines.push(" ".repeat(width)) + return lines + } + + const match = findServerForKey(results, selectedKey) + if (!match) { + // It's a path-level node + const pathResult = results.find((r) => r.path === selectedKey) + if (pathResult) { + lines.push(fitString(`${THEME.secondary}${THEME.bold} ${pathResult.client}${THEME.reset}`, width)) + lines.push(fitString(`${THEME.muted} Path: ${pathResult.path}${THEME.reset}`, width)) + lines.push(fitString(`${THEME.muted} Servers: ${pathResult.servers.length}${THEME.reset}`, width)) + if (pathResult.errors.length > 0) { + lines.push(fitString("", width)) + lines.push(fitString(`${THEME.error} Errors:${THEME.reset}`, width)) + for (const e of pathResult.errors) { + lines.push(fitString(`${THEME.error} ${e.path}: ${e.error}${THEME.reset}`, width)) + } + } + } else { + lines.push(fitString(`${THEME.muted} No details for selection${THEME.reset}`, width)) + } + while (lines.length < height) lines.push(" ".repeat(width)) + return lines + } + + const { server: srv } = match + lines.push(fitString(`${THEME.secondary}${THEME.bold} ${srv.name}${THEME.reset}`, width)) + lines.push(fitString(`${THEME.muted} Command: ${srv.command}${srv.args ? " " + srv.args.join(" ") : ""}${THEME.reset}`, width)) + + if (srv.signature) { + lines.push(fitString("", width)) + lines.push(fitString(`${THEME.white} Signature${THEME.reset}`, width)) + if (srv.signature.version) { + lines.push(fitString(`${THEME.muted} Version: ${srv.signature.version}${THEME.reset}`, width)) + } + lines.push(fitString(`${THEME.muted} Tools: ${srv.signature.tools.length} Prompts: ${srv.signature.prompts.length} Resources: ${srv.signature.resources.length}${THEME.reset}`, width)) + } + + if (srv.violations.length > 0) { + lines.push(fitString("", width)) + lines.push(fitString(`${THEME.error}${THEME.bold} Violations (${srv.violations.length})${THEME.reset}`, width)) + for (const v of srv.violations) { + lines.push(fitString(`${THEME.error} ${v.guard} \u2192 ${v.action_type} ${v.target}${THEME.reset}`, width)) + if (v.reason) { + lines.push(fitString(`${THEME.dim} ${v.reason}${THEME.reset}`, width)) + } + } + } + + if (srv.issues.length > 0) { + lines.push(fitString("", width)) + lines.push(fitString(`${THEME.warning}${THEME.bold} Issues (${srv.issues.length})${THEME.reset}`, width)) + for (const iss of srv.issues) { + lines.push(fitString(`${THEME.warning} [${iss.severity}] ${iss.code}: ${iss.message}${THEME.reset}`, width)) + if (iss.detail) { + lines.push(fitString(`${THEME.dim} ${iss.detail}${THEME.reset}`, width)) + } + } + } + + if (srv.error) { + lines.push(fitString("", width)) + lines.push(fitString(`${THEME.error} Error: ${srv.error}${THEME.reset}`, width)) + } + + while (lines.length < height) lines.push(" ".repeat(width)) + return lines +} + +// --------------------------------------------------------------------------- +// Screen +// --------------------------------------------------------------------------- + +export const huntScanScreen: Screen = { + onEnter(ctx: ScreenContext) { + const scan = ctx.state.hunt.scan + if (scan.results.length === 0 && !scan.loading) { + doScan(ctx) + } + }, + + render(ctx: ScreenContext): string { + const { state, width, height } = ctx + const scan = state.hunt.scan + const lines: string[] = [] + + // Header + const title = `${THEME.secondary}${THEME.bold} MCP Scan Explorer ${THEME.reset}` + lines.push(fitString(title, width)) + lines.push(fitString(`${THEME.dim}${"─".repeat(width)}${THEME.reset}`, width)) + + if (scan.loading) { + lines.push(fitString(`${THEME.muted} Scanning MCP configurations...${THEME.reset}`, width)) + const spinChars = ["\u2847", "\u2846", "\u2834", "\u2831", "\u2839", "\u283B", "\u283F", "\u2857"] + const frame = ctx.state.animationFrame % spinChars.length + lines.push(fitString(`${THEME.accent} ${spinChars[frame]}${THEME.reset}`, width)) + while (lines.length < height - 1) lines.push(" ".repeat(width)) + lines.push(fitString(`${THEME.dim} ESC back${THEME.reset}`, width)) + return lines.join("\n") + } + + if (scan.error) { + lines.push(fitString(`${THEME.error} Error: ${scan.error}${THEME.reset}`, width)) + lines.push(fitString("", width)) + lines.push(fitString(`${THEME.muted} r rescan ESC back${THEME.reset}`, width)) + while (lines.length < height - 1) lines.push(" ".repeat(width)) + return lines.join("\n") + } + + if (scan.results.length === 0) { + lines.push(fitString(`${THEME.muted} No MCP configurations found.${THEME.reset}`, width)) + lines.push(fitString(`${THEME.dim} Run with MCP servers configured to see scan results.${THEME.reset}`, width)) + lines.push(fitString("", width)) + lines.push(fitString(`${THEME.muted} r rescan ESC back${THEME.reset}`, width)) + while (lines.length < height - 1) lines.push(" ".repeat(width)) + return lines.join("\n") + } + + // Build tree + const treeNodes = buildTreeNodes(scan.results) + const contentHeight = height - 4 // header(2) + footer(1) + spacing(1) + + // Determine selected node key + const flat = flattenTree(treeNodes, scan.tree.expandedKeys) + const selectedNode = flat[scan.tree.selected] + const selectedKey = selectedNode?.node.key ?? null + + // Split: tree left 60%, detail right 40% + const leftLines = renderTree(treeNodes, scan.tree, contentHeight, Math.floor(width * 0.58), THEME) + const rightLines = renderDetail(scan.results, selectedKey, contentHeight, Math.floor(width * 0.4)) + + const splitLines = renderSplit(leftLines, rightLines, width, contentHeight, THEME, 0.6) + lines.push(...splitLines) + + // Footer + lines.push(fitString(`${THEME.dim}${"─".repeat(width)}${THEME.reset}`, width)) + const footer = `${THEME.muted} j/k navigate Enter expand/collapse r rescan ESC back${THEME.reset}` + lines.push(fitString(footer, width)) + + while (lines.length < height) lines.push(" ".repeat(width)) + return lines.join("\n") + }, + + handleInput(key: string, ctx: ScreenContext): boolean { + const scan = ctx.state.hunt.scan + + if (key === "\x1b" || key === "\x1b\x1b" || key === "q") { + ctx.app.setScreen("main") + return true + } + + if (scan.loading) return false + + if (key === "j" || key === "down") { + const treeNodes = buildTreeNodes(scan.results) + const flat = flattenTree(treeNodes, scan.tree.expandedKeys) + ctx.state.hunt.scan.tree = moveDown(scan.tree, flat.length, ctx.height - 4) + ctx.app.render() + return true + } + + if (key === "k" || key === "up") { + ctx.state.hunt.scan.tree = moveUp(scan.tree) + ctx.app.render() + return true + } + + if (key === "\r" || key === "enter") { + const treeNodes = buildTreeNodes(scan.results) + const flat = flattenTree(treeNodes, scan.tree.expandedKeys) + const selected = flat[scan.tree.selected] + if (selected?.node.children && selected.node.children.length > 0) { + ctx.state.hunt.scan.tree = toggleExpand(scan.tree, selected.node.key) + ctx.app.render() + } + return true + } + + if (key === "r") { + doScan(ctx) + return true + } + + return false + }, +} + +async function doScan(ctx: ScreenContext) { + ctx.state.hunt.scan.loading = true + ctx.state.hunt.scan.error = null + ctx.app.render() + try { + const results = await runScan() + ctx.state.hunt.scan.results = results + ctx.state.hunt.scan.tree = { offset: 0, selected: 0, expandedKeys: new Set() } + ctx.state.hunt.scan.loading = false + } catch (err) { + ctx.state.hunt.scan.error = err instanceof Error ? err.message : String(err) + ctx.state.hunt.scan.loading = false + } + ctx.app.render() +} diff --git a/apps/terminal/src/tui/screens/hunt-timeline.ts b/apps/terminal/src/tui/screens/hunt-timeline.ts new file mode 100644 index 000000000..2bed04423 --- /dev/null +++ b/apps/terminal/src/tui/screens/hunt-timeline.ts @@ -0,0 +1,341 @@ +/** + * Hunt Timeline Screen - Timeline replay with source filtering and event expansion. + */ + +import { THEME } from "../theme" +import type { Screen, ScreenContext } from "../types" +import type { TimelineEvent, EventSource, NormalizedVerdict } from "../../hunt/types" +import { runTimeline } from "../../hunt/bridge-query" +import { renderList, scrollUp, scrollDown, type ListItem } from "../components/scrollable-list" +import { renderBox } from "../components/box" +import { fitString } from "../components/types" + +const SOURCE_ICONS: Record<EventSource, string> = { + tetragon: "T", + hubble: "H", + receipt: "R", + spine: "S", +} + +const SOURCE_KEYS: Record<string, EventSource> = { + "1": "tetragon", + "2": "hubble", + "3": "receipt", + "4": "spine", +} + +const VERDICT_COLORS: Record<NormalizedVerdict, string> = { + allow: THEME.success, + deny: THEME.error, + audit: THEME.warning, + unknown: THEME.dim, +} + +function formatTimestamp(iso: string): string { + try { + const d = new Date(iso) + const h = String(d.getHours()).padStart(2, "0") + const m = String(d.getMinutes()).padStart(2, "0") + const s = String(d.getSeconds()).padStart(2, "0") + return `${h}:${m}:${s}` + } catch { + return "??:??:??" + } +} + +function getFilteredEvents(ctx: ScreenContext): TimelineEvent[] { + const tl = ctx.state.hunt.timeline + return tl.events.filter((e) => { + const filters = tl.sourceFilters + return filters[e.source] === true + }) +} + +function buildEventItems(events: TimelineEvent[]): ListItem[] { + return events.map((e) => { + const ts = formatTimestamp(e.timestamp) + const icon = SOURCE_ICONS[e.source] ?? "?" + const verdictColor = VERDICT_COLORS[e.verdict] ?? THEME.dim + const label = + `${THEME.dim}[${ts}]${THEME.reset} ` + + `${THEME.tertiary}[${icon}]${THEME.reset} ` + + `${verdictColor}[${e.verdict}]${THEME.reset} ` + + `${THEME.white}${e.summary}${THEME.reset}` + const plainLength = `[${ts}] [${icon}] [${e.verdict}] ${e.summary}`.length + return { label, plainLength } + }) +} + +export const huntTimelineScreen: Screen = { + onEnter(ctx: ScreenContext): void { + const tl = ctx.state.hunt.timeline + if (tl.loading) return + + ctx.state.hunt.timeline = { ...tl, loading: true, error: null } + ctx.app.render() + + runTimeline({}) + .then((events) => { + ctx.state.hunt.timeline = { + ...ctx.state.hunt.timeline, + events, + loading: false, + list: { offset: 0, selected: 0 }, + expandedIndex: null, + } + ctx.app.render() + }) + .catch((err) => { + ctx.state.hunt.timeline = { + ...ctx.state.hunt.timeline, + loading: false, + error: err instanceof Error ? err.message : String(err), + } + ctx.app.render() + }) + }, + + render(ctx: ScreenContext): string { + const { state, width, height } = ctx + const tl = state.hunt.timeline + const lines: string[] = [] + + // Title bar + const title = `${THEME.accent}${THEME.bold} HUNT ${THEME.reset}${THEME.dim} // ${THEME.reset}${THEME.secondary}Timeline Replay${THEME.reset}` + lines.push(fitString(title, width)) + lines.push(fitString(`${THEME.dim}${"─".repeat(width)}${THEME.reset}`, width)) + + // Source filter toggles row + const filters = tl.sourceFilters + const toggles = (["tetragon", "hubble", "receipt", "spine"] as EventSource[]) + .map((src, i) => { + const active = filters[src] + const icon = SOURCE_ICONS[src] + const color = active ? THEME.secondary : THEME.dim + const indicator = active ? `${THEME.success}*` : `${THEME.dim}-` + return `${THEME.dim}${i + 1}${THEME.reset}${color}[${icon}]${indicator}${THEME.reset}` + }) + .join(" ") + lines.push(fitString(` ${THEME.muted}Sources:${THEME.reset} ${toggles}`, width)) + lines.push(fitString(`${THEME.dim}${"─".repeat(width)}${THEME.reset}`, width)) + + // Loading state + if (tl.loading) { + const spinChars = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] + const spinner = spinChars[state.animationFrame % spinChars.length] + const msgY = Math.floor(height / 2) - 2 + for (let i = lines.length; i < msgY; i++) lines.push(" ".repeat(width)) + lines.push(fitString(`${THEME.secondary} ${spinner} Loading timeline events...${THEME.reset}`, width)) + for (let i = lines.length; i < height - 1; i++) lines.push(" ".repeat(width)) + lines.push(renderHelpBar(width)) + return lines.join("\n") + } + + // Error state + if (tl.error) { + const msgY = Math.floor(height / 2) - 2 + for (let i = lines.length; i < msgY; i++) lines.push(" ".repeat(width)) + lines.push(fitString(`${THEME.error} Error: ${tl.error}${THEME.reset}`, width)) + lines.push(fitString(`${THEME.dim} Press r to retry.${THEME.reset}`, width)) + for (let i = lines.length; i < height - 1; i++) lines.push(" ".repeat(width)) + lines.push(renderHelpBar(width)) + return lines.join("\n") + } + + const filtered = getFilteredEvents(ctx) + + // Empty state + if (filtered.length === 0) { + const msgY = Math.floor(height / 2) - 2 + for (let i = lines.length; i < msgY; i++) lines.push(" ".repeat(width)) + if (tl.events.length === 0) { + lines.push(fitString(`${THEME.muted} No timeline events found.${THEME.reset}`, width)) + } else { + lines.push(fitString(`${THEME.muted} No events match active source filters.${THEME.reset}`, width)) + } + lines.push(fitString(`${THEME.dim} Toggle sources with 1-4 or press r to reload.${THEME.reset}`, width)) + for (let i = lines.length; i < height - 1; i++) lines.push(" ".repeat(width)) + lines.push(renderHelpBar(width)) + return lines.join("\n") + } + + // Calculate layout: event list takes top portion, detail takes bottom when expanded + const helpLines = 1 + const headerLines = lines.length + const availableHeight = height - headerLines - helpLines + + let listHeight: number + let detailHeight: number + + if (tl.expandedIndex !== null) { + detailHeight = Math.min(10, Math.floor(availableHeight * 0.4)) + listHeight = availableHeight - detailHeight + } else { + listHeight = availableHeight + detailHeight = 0 + } + + // Event list + const items = buildEventItems(filtered) + const listLines = renderList(items, tl.list, listHeight, width, THEME) + for (const l of listLines) lines.push(l) + + // Expanded detail pane + if (tl.expandedIndex !== null && tl.expandedIndex < filtered.length) { + const event = filtered[tl.expandedIndex] + const detailContent: string[] = [ + `${THEME.muted}Kind:${THEME.reset} ${THEME.white}${event.kind}${THEME.reset}`, + `${THEME.muted}Source:${THEME.reset} ${THEME.tertiary}${event.source}${THEME.reset}`, + `${THEME.muted}Verdict:${THEME.reset} ${(VERDICT_COLORS[event.verdict] ?? THEME.dim)}${event.verdict}${THEME.reset}`, + `${THEME.muted}Time:${THEME.reset} ${THEME.white}${event.timestamp}${THEME.reset}`, + ] + // Add details as JSON lines + const detailJson = JSON.stringify(event.details, null, 2) + const jsonLines = detailJson.split("\n") + detailContent.push(`${THEME.muted}Details:${THEME.reset}`) + for (const jl of jsonLines.slice(0, detailHeight - 6)) { + detailContent.push(` ${THEME.dim}${jl}${THEME.reset}`) + } + + const boxLines = renderBox("Event Detail", detailContent, width, THEME, { style: "rounded" }) + for (const bl of boxLines) lines.push(bl) + } + + // Help bar + lines.push(renderHelpBar(width)) + + // Pad to fill + while (lines.length < height) lines.push(" ".repeat(width)) + + return lines.join("\n") + }, + + handleInput(key: string, ctx: ScreenContext): boolean { + const tl = ctx.state.hunt.timeline + if (tl.loading) { + if (key === "\x1b" || key === "\x1b\x1b" || key === "q") { + ctx.app.setScreen("main") + return true + } + return false + } + + const filtered = getFilteredEvents(ctx) + + // Navigation + if (key === "\x1b" || key === "\x1b\x1b" || key === "q") { + ctx.app.setScreen("main") + return true + } + + // Scroll + if (key === "j" || key === "down") { + if (filtered.length > 0) { + ctx.state.hunt.timeline = { + ...tl, + list: scrollDown(tl.list, filtered.length, ctx.height - 8), + expandedIndex: null, + } + } + return true + } + if (key === "k" || key === "up") { + if (filtered.length > 0) { + ctx.state.hunt.timeline = { + ...tl, + list: scrollUp(tl.list), + expandedIndex: null, + } + } + return true + } + + // Page up/down + if (key === "h") { + if (filtered.length > 0) { + const pageSize = Math.max(1, ctx.height - 12) + let vp = tl.list + for (let i = 0; i < pageSize; i++) vp = scrollUp(vp) + ctx.state.hunt.timeline = { ...tl, list: vp, expandedIndex: null } + } + return true + } + if (key === "l") { + if (filtered.length > 0) { + const pageSize = Math.max(1, ctx.height - 12) + let vp = tl.list + for (let i = 0; i < pageSize; i++) vp = scrollDown(vp, filtered.length, ctx.height - 8) + ctx.state.hunt.timeline = { ...tl, list: vp, expandedIndex: null } + } + return true + } + + // Expand/collapse + if (key === "\r" || key === "return") { + if (filtered.length > 0) { + const current = tl.expandedIndex + const selected = tl.list.selected + ctx.state.hunt.timeline = { + ...tl, + expandedIndex: current === selected ? null : selected, + } + } + return true + } + + // Source toggles + const toggleSource = SOURCE_KEYS[key] + if (toggleSource) { + ctx.state.hunt.timeline = { + ...tl, + sourceFilters: { + ...tl.sourceFilters, + [toggleSource]: !tl.sourceFilters[toggleSource], + }, + list: { offset: 0, selected: 0 }, + expandedIndex: null, + } + return true + } + + // Reload + if (key === "r") { + ctx.state.hunt.timeline = { ...tl, loading: true, error: null } + ctx.app.render() + + runTimeline({}) + .then((events) => { + ctx.state.hunt.timeline = { + ...ctx.state.hunt.timeline, + events, + loading: false, + list: { offset: 0, selected: 0 }, + expandedIndex: null, + } + ctx.app.render() + }) + .catch((err) => { + ctx.state.hunt.timeline = { + ...ctx.state.hunt.timeline, + loading: false, + error: err instanceof Error ? err.message : String(err), + } + ctx.app.render() + }) + return true + } + + return false + }, +} + +function renderHelpBar(width: number): string { + const help = + `${THEME.dim}esc${THEME.reset}${THEME.muted} back${THEME.reset} ` + + `${THEME.dim}j/k${THEME.reset}${THEME.muted} scroll${THEME.reset} ` + + `${THEME.dim}h/l${THEME.reset}${THEME.muted} page${THEME.reset} ` + + `${THEME.dim}enter${THEME.reset}${THEME.muted} expand${THEME.reset} ` + + `${THEME.dim}1-4${THEME.reset}${THEME.muted} sources${THEME.reset} ` + + `${THEME.dim}r${THEME.reset}${THEME.muted} reload${THEME.reset}` + return fitString(help, width) +} diff --git a/apps/terminal/src/tui/screens/hunt-watch.ts b/apps/terminal/src/tui/screens/hunt-watch.ts new file mode 100644 index 000000000..12ba05ac1 --- /dev/null +++ b/apps/terminal/src/tui/screens/hunt-watch.ts @@ -0,0 +1,234 @@ +/** + * Hunt Watch Screen - Live event stream with filtering and alert banners. + */ + +import { THEME } from "../theme" +import type { Screen, ScreenContext, HuntWatchState } from "../types" +import type { TimelineEvent, Alert, WatchStats, EventSource, NormalizedVerdict } from "../../hunt/types" +import type { HuntStreamHandle } from "../../hunt/bridge" +import { startWatch } from "../../hunt/bridge-correlate" +import { + renderLog, + appendLine, + togglePause, + scrollLogUp, + scrollLogDown, + clearLog, + type LogLine, +} from "../components/streaming-log" +import { fitString } from "../components/types" + +const SOURCE_ICONS: Record<EventSource, string> = { + tetragon: "T", + hubble: "H", + receipt: "R", + spine: "S", +} + +const VERDICT_COLORS: Record<NormalizedVerdict, string> = { + allow: THEME.success, + deny: THEME.error, + audit: THEME.warning, + unknown: THEME.dim, +} + +const FILTERS: HuntWatchState["filter"][] = ["all", "allow", "deny", "audit"] + +let watchHandle: HuntStreamHandle | null = null + +function formatTimestamp(iso: string): string { + try { + const d = new Date(iso) + const h = String(d.getHours()).padStart(2, "0") + const m = String(d.getMinutes()).padStart(2, "0") + const s = String(d.getSeconds()).padStart(2, "0") + return `${h}:${m}:${s}` + } catch { + return "??:??:??" + } +} + +function formatEvent(event: TimelineEvent): LogLine { + const ts = formatTimestamp(event.timestamp) + const icon = SOURCE_ICONS[event.source] ?? "?" + const verdictColor = VERDICT_COLORS[event.verdict] ?? THEME.dim + const text = + `${THEME.dim}[${ts}]${THEME.reset} ` + + `${THEME.tertiary}[${icon}]${THEME.reset} ` + + `${verdictColor}[${event.verdict}]${THEME.reset} ` + + `${THEME.white}${event.summary}${THEME.reset}` + return { text, plainLength: `[${ts}] [${icon}] [${event.verdict}] ${event.summary}`.length } +} + +function matchesFilter(event: TimelineEvent, filter: HuntWatchState["filter"]): boolean { + if (filter === "all") return true + return event.verdict === filter +} + +export const huntWatchScreen: Screen = { + onEnter(ctx: ScreenContext): void { + const w = ctx.state.hunt.watch + if (w.running) return + + ctx.state.hunt.watch = { ...w, running: true } + + const rules = ["~/.clawdstrike/rules/*.yaml"] + + watchHandle = startWatch( + rules, + (event: TimelineEvent) => { + const ws = ctx.state.hunt.watch + if (!matchesFilter(event, ws.filter)) return + ctx.state.hunt.watch = { + ...ws, + log: appendLine(ws.log, formatEvent(event)), + } + ctx.app.render() + }, + (alert: Alert) => { + const ws = ctx.state.hunt.watch + + // Clear previous fade timer + if (ws.alertFadeTimer) clearTimeout(ws.alertFadeTimer) + + const fadeTimer = setTimeout(() => { + ctx.state.hunt.watch = { ...ctx.state.hunt.watch, lastAlert: null, alertFadeTimer: null } + ctx.app.render() + }, 5000) + + ctx.state.hunt.watch = { ...ws, lastAlert: alert, alertFadeTimer: fadeTimer } + ctx.app.render() + }, + (stats: WatchStats) => { + ctx.state.hunt.watch = { ...ctx.state.hunt.watch, stats } + ctx.app.render() + }, + ) + }, + + onExit(ctx: ScreenContext): void { + if (watchHandle) { + watchHandle.kill() + watchHandle = null + } + const w = ctx.state.hunt.watch + if (w.alertFadeTimer) clearTimeout(w.alertFadeTimer) + ctx.state.hunt.watch = { ...w, running: false, alertFadeTimer: null } + }, + + render(ctx: ScreenContext): string { + const { state, width, height } = ctx + const w = state.hunt.watch + const lines: string[] = [] + + // Title bar + const title = `${THEME.accent}${THEME.bold} HUNT ${THEME.reset}${THEME.dim} // ${THEME.reset}${THEME.secondary}Live Watch${THEME.reset}` + const filterLabel = `${THEME.dim}filter: ${THEME.reset}${THEME.white}${w.filter}${THEME.reset}` + lines.push(fitString(`${title} ${filterLabel}`, width)) + lines.push(fitString(`${THEME.dim}${"─".repeat(width)}${THEME.reset}`, width)) + + if (!w.running) { + // Not running state + const msgY = Math.floor(height / 2) - 2 + for (let i = 2; i < msgY; i++) lines.push(" ".repeat(width)) + lines.push(fitString(`${THEME.muted} Watch is not running.${THEME.reset}`, width)) + lines.push(fitString(`${THEME.dim} Press any key to return, or restart the screen.${THEME.reset}`, width)) + for (let i = lines.length; i < height - 1; i++) lines.push(" ".repeat(width)) + lines.push(renderHelpBar(width)) + return lines.join("\n") + } + + // Alert banner (if present) + let alertLines = 0 + if (w.lastAlert) { + const severityColor = w.lastAlert.severity === "critical" ? THEME.error : THEME.warning + const alertText = + `${severityColor}${THEME.bold} ALERT ${THEME.reset} ` + + `${severityColor}${w.lastAlert.title}${THEME.reset} ` + + `${THEME.dim}(${w.lastAlert.rule})${THEME.reset}` + lines.push(fitString(alertText, width)) + alertLines = 1 + } + + // Stats bar height + const statsLines = 1 + // Log area: remaining height minus header(2) - alert - stats - help(1) + const logHeight = height - 2 - alertLines - statsLines - 1 + + // Streaming log + const logOutput = renderLog(w.log, logHeight, width, THEME) + for (const l of logOutput) lines.push(l) + + // Stats bar + if (w.stats) { + const statsText = + `${THEME.dim}events: ${THEME.reset}${THEME.white}${w.stats.events_processed}${THEME.reset}` + + `${THEME.dim} | alerts: ${THEME.reset}${THEME.warning}${w.stats.alerts_fired}${THEME.reset}` + + `${THEME.dim} | rules: ${THEME.reset}${THEME.white}${w.stats.active_rules}${THEME.reset}` + + `${THEME.dim} | uptime: ${THEME.reset}${THEME.white}${w.stats.uptime_seconds}s${THEME.reset}` + lines.push(fitString(statsText, width)) + } else { + lines.push(fitString(`${THEME.dim}Waiting for stats...${THEME.reset}`, width)) + } + + // Help bar + lines.push(renderHelpBar(width)) + + // Pad to fill + while (lines.length < height) lines.push(" ".repeat(width)) + + return lines.join("\n") + }, + + handleInput(key: string, ctx: ScreenContext): boolean { + const w = ctx.state.hunt.watch + + // Navigation + if (key === "q" || key === "\x1b" || key === "\x1b\x1b") { + ctx.app.setScreen("main") + return true + } + + // Filter cycle + if (key === "f") { + const idx = FILTERS.indexOf(w.filter) + const next = FILTERS[(idx + 1) % FILTERS.length] + ctx.state.hunt.watch = { ...w, filter: next } + return true + } + + // Clear log + if (key === "c") { + ctx.state.hunt.watch = { ...w, log: clearLog(w.log) } + return true + } + + // Pause/resume + if (key === " ") { + ctx.state.hunt.watch = { ...w, log: togglePause(w.log) } + return true + } + + // Scroll when paused + if (key === "up" || key === "k") { + ctx.state.hunt.watch = { ...w, log: scrollLogUp(w.log) } + return true + } + if (key === "down" || key === "j") { + ctx.state.hunt.watch = { ...w, log: scrollLogDown(w.log) } + return true + } + + return false + }, +} + +function renderHelpBar(width: number): string { + const help = + `${THEME.dim}q${THEME.reset}${THEME.muted} back${THEME.reset} ` + + `${THEME.dim}f${THEME.reset}${THEME.muted} filter${THEME.reset} ` + + `${THEME.dim}c${THEME.reset}${THEME.muted} clear${THEME.reset} ` + + `${THEME.dim}space${THEME.reset}${THEME.muted} pause${THEME.reset} ` + + `${THEME.dim}j/k${THEME.reset}${THEME.muted} scroll${THEME.reset}` + return fitString(help, width) +} diff --git a/apps/terminal/src/tui/screens/integrations.ts b/apps/terminal/src/tui/screens/integrations.ts new file mode 100644 index 000000000..69dd10fe7 --- /dev/null +++ b/apps/terminal/src/tui/screens/integrations.ts @@ -0,0 +1,99 @@ +/** + * Integrations Screen - System health status + */ + +import { THEME } from "../theme" +import type { Screen, ScreenContext } from "../types" +import type { HealthStatus } from "../../health" + +export const integrationsScreen: Screen = { + render(ctx: ScreenContext): string { + return renderIntegrationsScreen(ctx) + }, + + handleInput(key: string, ctx: ScreenContext): boolean { + const { app } = ctx + + if (key === "\x1b" || key === "\x1b\x1b" || key === "q" || key === "i") { + app.setScreen("main") + return true + } + + if (key === "r") { + app.runHealthcheck() + return true + } + + return false + }, +} + +function renderIntegrationsScreen(ctx: ScreenContext): string { + const { state, width, height } = ctx + const lines: string[] = [] + const health = state.health + + const boxWidth = Math.min(65, width - 10) + const boxPad = Math.max(0, Math.floor((width - boxWidth) / 2)) + const startY = Math.max(2, Math.floor(height / 6)) + + for (let i = 0; i < startY; i++) { + lines.push("") + } + + // Gothic title bar + const title = "⟨ Integrations ⟩" + const titlePadLeft = Math.floor((boxWidth - title.length - 4) / 2) + const titlePadRight = boxWidth - title.length - titlePadLeft - 4 + const titleLine = "╔═" + "═".repeat(titlePadLeft) + title + "═".repeat(titlePadRight) + "═╗" + lines.push(" ".repeat(boxPad) + THEME.dim + titleLine + THEME.reset) + lines.push(" ".repeat(boxPad) + THEME.dim + "║" + " ".repeat(boxWidth - 2) + "║" + THEME.reset) + + const addSection = (label: string, items: HealthStatus[], color: string) => { + lines.push(" ".repeat(boxPad) + THEME.dim + "║ " + THEME.reset + THEME.secondary + "◇ " + THEME.reset + THEME.white + THEME.bold + label + THEME.reset + " ".repeat(boxWidth - label.length - 6) + THEME.dim + "║" + THEME.reset) + + for (const item of items) { + const icon = item.available ? `${color}◆${THEME.reset}` : `${THEME.dim}◇${THEME.reset}` + const name = item.name.toLowerCase().padEnd(12) + const version = item.available ? (item.version || "").padEnd(12) : "" + const latency = item.available && item.latency ? `${THEME.muted}${item.latency}ms${THEME.reset}` : "" + const error = !item.available && item.error ? THEME.dim + item.error + THEME.reset : "" + + const content = ` ${icon} ${THEME.muted}${name}${THEME.reset}${version}${latency}${error}` + const contentLen = ` ◆ ${item.name.toLowerCase().padEnd(12)}${version}${item.latency ? `${item.latency}ms` : ""}${item.error || ""}`.length + const rightPad = Math.max(0, boxWidth - contentLen - 3) + + lines.push(" ".repeat(boxPad) + THEME.dim + "║" + THEME.reset + content + " ".repeat(rightPad) + THEME.dim + "║" + THEME.reset) + } + + lines.push(" ".repeat(boxPad) + THEME.dim + "║" + " ".repeat(boxWidth - 2) + "║" + THEME.reset) + } + + if (state.healthChecking) { + lines.push(" ".repeat(boxPad) + THEME.dim + "║ " + THEME.secondary + "◈" + THEME.reset + THEME.muted + " Divining system state..." + THEME.reset + " ".repeat(boxWidth - 30) + THEME.dim + "║" + THEME.reset) + lines.push(" ".repeat(boxPad) + THEME.dim + "║" + " ".repeat(boxWidth - 2) + "║" + THEME.reset) + } else if (health) { + addSection("Security", health.security, THEME.warning) + addSection("AI Toolchains", health.ai, THEME.accent) + addSection("Infrastructure", health.infra, THEME.white) + addSection("MCP Server", health.mcp, THEME.success) + } else { + lines.push(" ".repeat(boxPad) + THEME.dim + "║ " + THEME.muted + "No readings available. Press r to divine." + THEME.reset + " ".repeat(boxWidth - 45) + THEME.dim + "║" + THEME.reset) + lines.push(" ".repeat(boxPad) + THEME.dim + "║" + " ".repeat(boxWidth - 2) + "║" + THEME.reset) + } + + // Help text + const helpText = "r refresh ◆ esc back" + const helpPad = Math.max(0, Math.floor((boxWidth - helpText.length) / 2)) + lines.push(" ".repeat(boxPad) + THEME.dim + "║" + " ".repeat(helpPad) + helpText + " ".repeat(boxWidth - helpPad - helpText.length - 2) + "║" + THEME.reset) + + // Bottom border + lines.push(" ".repeat(boxPad) + THEME.dim + "╚" + "═".repeat(boxWidth - 2) + "╝" + THEME.reset) + + // Fill remaining + for (let i = lines.length; i < height - 1; i++) { + lines.push("") + } + + return lines.join("\n") +} diff --git a/apps/terminal/src/tui/screens/main.ts b/apps/terminal/src/tui/screens/main.ts new file mode 100644 index 000000000..a0eea9a33 --- /dev/null +++ b/apps/terminal/src/tui/screens/main.ts @@ -0,0 +1,338 @@ +/** + * Main Screen - Hero input + command palette overlay + */ + +import { THEME, LOGO, AGENTS, getAnimatedStrike } from "../theme" +import type { Screen, ScreenContext, Command } from "../types" + +export function createMainScreen(commands: Command[]): Screen { + return { + render(ctx: ScreenContext): string { + let content = renderMainContent(ctx, commands) + if (ctx.state.inputMode === "commands") { + content = overlayCommandPalette(content, ctx, commands) + } + return content + }, + + handleInput(key: string, ctx: ScreenContext): boolean { + if (ctx.state.inputMode === "commands") { + return handleCommandsInput(key, ctx, commands) + } + return handleMainInput(key, ctx) + }, + } +} + +function handleMainInput(key: string, ctx: ScreenContext): boolean { + const { state, app } = ctx + + // Ctrl+S - security overview + if (key === "\x13") { + app.setScreen("security") + return true + } + + // Tab - cycle agents + if (key === "\t") { + state.agentIndex = (state.agentIndex + 1) % AGENTS.length + app.render() + return true + } + + // Ctrl+P - open command palette + if (key === "\x10") { + state.inputMode = "commands" + state.commandIndex = 0 + app.render() + return true + } + + // Enter - submit prompt + if (key === "\r") { + if (state.promptBuffer.trim()) { + app.submitPrompt("dispatch") + } + return true + } + + // Backspace + if (key === "\x7f" || key === "\b") { + state.promptBuffer = state.promptBuffer.slice(0, -1) + app.render() + return true + } + + // Ctrl+U - clear line + if (key === "\x15") { + state.promptBuffer = "" + app.render() + return true + } + + // Escape - clear or quit + if (key === "\x1b" || key === "\x1b\x1b") { + if (state.promptBuffer) { + state.promptBuffer = "" + app.render() + } else { + app.quit() + } + return true + } + + // Regular characters - add to prompt + if (key.length === 1 && key >= " ") { + state.promptBuffer += key + app.render() + return true + } + + return false +} + +function handleCommandsInput(key: string, ctx: ScreenContext, commands: Command[]): boolean { + const { state, app } = ctx + + // Escape - close palette + if (key === "\x1b" || key === "\x1b\x1b" || key === "\x10") { + state.inputMode = "main" + app.render() + return true + } + + // Arrow up / k + if (key === "\x1b[A" || key === "k") { + state.commandIndex = Math.max(0, state.commandIndex - 1) + app.render() + return true + } + + // Arrow down / j + if (key === "\x1b[B" || key === "j") { + state.commandIndex = Math.min(commands.length - 1, state.commandIndex + 1) + app.render() + return true + } + + // Enter - execute command + if (key === "\r") { + const cmd = commands[state.commandIndex] + state.inputMode = "main" + cmd.action() + return true + } + + // Direct key shortcuts + const cmd = commands.find((c) => c.key.toLowerCase() === key.toLowerCase()) + if (cmd) { + state.inputMode = "main" + cmd.action() + return true + } + + return false +} + +function renderMainContent(ctx: ScreenContext, _commands: Command[]): string { + const { state, width, height } = ctx + const lines: string[] = [] + + // Calculate vertical centering for logo + input + const contentHeight = LOGO.main.length + LOGO.strike.length + 9 + const startY = Math.max(2, Math.floor((height - contentHeight) / 2)) + + // Top padding + for (let i = 0; i < startY; i++) { + lines.push("") + } + + // Logo - stacked layout: CLAWD on top, STRIKE below + const mainWidth = LOGO.main[0].length + const strikeWidth = LOGO.strike[0].length + const mainPad = Math.max(0, Math.floor((width - mainWidth) / 2)) + const strikePad = Math.max(0, Math.floor((width - strikeWidth) / 2)) + + // Render CLAWD lines in crimson + for (let i = 0; i < LOGO.main.length; i++) { + lines.push(" ".repeat(mainPad) + THEME.accent + LOGO.main[i] + THEME.reset) + } + + // Get animated STRIKE for current frame and render below + const animatedStrike = getAnimatedStrike(state.animationFrame) + for (let i = 0; i < animatedStrike.length; i++) { + lines.push(" ".repeat(strikePad) + animatedStrike[i]) + } + + lines.push("") + lines.push("") + + // Hero input box + const inputWidth = Math.min(80, width - 8) + const inputPad = Math.max(0, Math.floor((width - inputWidth) / 2)) + + const prompt = state.promptBuffer + const placeholder = 'Ask anything... "Fix broken tests"' + const cursor = prompt ? THEME.secondary + "▎" + THEME.reset : "" + + // Top of input box + lines.push(" ".repeat(inputPad) + THEME.dim + "┌" + "─".repeat(inputWidth - 2) + "┐" + THEME.reset) + + // Input line with accent bar + const innerWidth = inputWidth - 4 + const visiblePrompt = prompt.length > innerWidth - 2 + ? "…" + prompt.slice(-(innerWidth - 3)) + : prompt + const inputContent = visiblePrompt + cursor + const inputPadding = Math.max(0, innerWidth - visiblePrompt.length - 1) + + if (prompt) { + lines.push(" ".repeat(inputPad) + THEME.accent + "│" + THEME.reset + " " + THEME.white + inputContent + THEME.reset + " ".repeat(inputPadding) + THEME.dim + "│" + THEME.reset) + } else { + lines.push(" ".repeat(inputPad) + THEME.accent + "│" + THEME.reset + " " + THEME.dim + placeholder + THEME.reset + " ".repeat(Math.max(0, innerWidth - placeholder.length)) + THEME.dim + "│" + THEME.reset) + } + + lines.push(" ".repeat(inputPad) + THEME.dim + "│" + " ".repeat(inputWidth - 2) + "│" + THEME.reset) + + // Agent info line + const agent = AGENTS[state.agentIndex] + const agentLine = `${THEME.accent}${agent.name}${THEME.reset} ${THEME.muted}${agent.model}${THEME.reset} ${THEME.dim}${agent.provider}${THEME.reset}` + const agentTextLen = agent.name.length + 2 + agent.model.length + 1 + agent.provider.length + const agentPadding = Math.max(0, inputWidth - 4 - agentTextLen) + lines.push(" ".repeat(inputPad) + THEME.dim + "│" + THEME.reset + " " + agentLine + " ".repeat(agentPadding) + THEME.dim + "│" + THEME.reset) + + // Bottom of input box + lines.push(" ".repeat(inputPad) + THEME.dim + "└" + "─".repeat(inputWidth - 2) + "┘" + THEME.reset) + + lines.push("") + + // Hint bar - centered + const hints = `${THEME.bold}tab${THEME.reset}${THEME.muted} switch agent${THEME.reset} ${THEME.bold}ctrl+p${THEME.reset}${THEME.muted} commands${THEME.reset}` + const hintsTextLen = "tab switch agent ctrl+p commands".length + const hintsPad = Math.max(0, Math.floor((width - hintsTextLen) / 2)) + lines.push(" ".repeat(hintsPad) + hints) + + // Security event ticker + if (state.recentEvents.length > 0) { + lines.push("") + const latest = state.recentEvents[0] + if (latest.type === "check") { + const data = latest.data as { action_type?: string; target?: string; guard?: string; decision?: string } + const icon = data.decision === "deny" ? THEME.error + "◆" : THEME.success + "◆" + const target = (data.target ?? "").length > 40 ? "…" + (data.target ?? "").slice(-39) : (data.target ?? "") + const ticker = `${icon}${THEME.reset} ${data.action_type ?? ""} ${THEME.muted}${target}${THEME.reset} via ${THEME.dim}${data.guard ?? ""}${THEME.reset}` + const tickerLen = `◆ ${data.action_type ?? ""} ${target} via ${data.guard ?? ""}`.length + const tickerPad = Math.max(0, Math.floor((width - tickerLen) / 2)) + lines.push(" ".repeat(tickerPad) + ticker) + } + } + + // Status message (if any) + if (state.statusMessage) { + lines.push("") + const statusLen = state.statusMessage.replace(/\x1b\[[0-9;]*m/g, "").length + const statusPad = Math.max(0, Math.floor((width - statusLen) / 2)) + lines.push(" ".repeat(statusPad) + state.statusMessage) + } + + // Fill remaining space (leave room for status bar) + const currentLines = lines.length + for (let i = currentLines; i < height - 2; i++) { + lines.push("") + } + + return lines.join("\n") +} + +function overlayCommandPalette(baseScreen: string, ctx: ScreenContext, commands: Command[]): string { + const { state, width } = ctx + const lines = baseScreen.split("\n") + + const paletteWidth = Math.min(70, width - 10) + const startX = Math.max(0, Math.floor((width - paletteWidth) / 2)) + const startY = 3 + + const modalBg = "\x1b[48;2;32;32;36m" + const highlightBg = "\x1b[48;2;204;153;102m" + const highlightFg = "\x1b[38;5;235m" + + const paletteLines: string[] = [] + + // Top border (rounded) + paletteLines.push(modalBg + THEME.dim + "╭" + "─".repeat(paletteWidth - 2) + "╮" + THEME.reset) + + // Header + const title = "Commands" + const escHint = "esc" + const headerPad = paletteWidth - 4 - title.length - escHint.length + paletteLines.push(modalBg + THEME.dim + "│" + THEME.reset + modalBg + " " + THEME.white + THEME.bold + title + THEME.reset + modalBg + " ".repeat(headerPad) + THEME.muted + escHint + " " + THEME.dim + "│" + THEME.reset) + + paletteLines.push(modalBg + THEME.dim + "│" + THEME.reset + modalBg + " ".repeat(paletteWidth - 2) + THEME.dim + "│" + THEME.reset) + + // Search placeholder + paletteLines.push(modalBg + THEME.dim + "│" + THEME.reset + modalBg + " " + THEME.dim + "Search" + THEME.reset + modalBg + " ".repeat(paletteWidth - 9) + THEME.dim + "│" + THEME.reset) + + paletteLines.push(modalBg + THEME.dim + "│" + THEME.reset + modalBg + " ".repeat(paletteWidth - 2) + THEME.dim + "│" + THEME.reset) + + // Group commands by category + const categories = [ + { name: "Actions", commands: commands.filter(c => ["d", "s", "g"].includes(c.key)) }, + { name: "Security", commands: commands.filter(c => ["S", "a", "p"].includes(c.key)) }, + { name: "Hunt", commands: commands.filter(c => ["W", "X", "T", "R", "Q", "D", "E", "M", "P"].includes(c.key)) }, + { name: "Views", commands: commands.filter(c => ["b", "r", "i"].includes(c.key)) }, + { name: "System", commands: commands.filter(c => ["?", "q"].includes(c.key)) }, + ] + + let globalIndex = 0 + for (const category of categories) { + if (category.commands.length === 0) continue + + const catPad = paletteWidth - 3 - category.name.length + paletteLines.push(modalBg + THEME.dim + "│" + THEME.reset + modalBg + " " + THEME.secondary + category.name + THEME.reset + modalBg + " ".repeat(catPad) + THEME.dim + "│" + THEME.reset) + + for (const cmd of category.commands) { + const isSelected = globalIndex === state.commandIndex + const label = cmd.label + const shortcut = cmd.key + const contentWidth = paletteWidth - 4 + + if (isSelected) { + const labelPad = contentWidth - label.length - shortcut.length - 1 + paletteLines.push( + modalBg + THEME.dim + "│" + THEME.reset + + highlightBg + highlightFg + " " + THEME.bold + label + THEME.reset + + highlightBg + " ".repeat(Math.max(1, labelPad)) + + highlightFg + shortcut + " " + THEME.reset + + modalBg + THEME.dim + "│" + THEME.reset + ) + } else { + const labelPad = contentWidth - label.length - shortcut.length - 1 + paletteLines.push( + modalBg + THEME.dim + "│" + THEME.reset + + modalBg + " " + THEME.white + label + THEME.reset + + modalBg + " ".repeat(Math.max(1, labelPad)) + + THEME.dim + shortcut + " " + THEME.reset + + modalBg + THEME.dim + "│" + THEME.reset + ) + } + globalIndex++ + } + + if (category !== categories[categories.length - 1]) { + paletteLines.push(modalBg + THEME.dim + "│" + THEME.reset + modalBg + " ".repeat(paletteWidth - 2) + THEME.dim + "│" + THEME.reset) + } + } + + paletteLines.push(modalBg + THEME.dim + "│" + THEME.reset + modalBg + " ".repeat(paletteWidth - 2) + THEME.dim + "│" + THEME.reset) + paletteLines.push(modalBg + THEME.dim + "╰" + "─".repeat(paletteWidth - 2) + "╯" + THEME.reset) + + // Overlay palette onto base screen + for (let i = 0; i < paletteLines.length; i++) { + const lineIndex = startY + i + if (lineIndex < lines.length) { + lines[lineIndex] = " ".repeat(startX) + paletteLines[i] + } + } + + return lines.join("\n") +} diff --git a/apps/terminal/src/tui/screens/policy.ts b/apps/terminal/src/tui/screens/policy.ts new file mode 100644 index 000000000..1627c8758 --- /dev/null +++ b/apps/terminal/src/tui/screens/policy.ts @@ -0,0 +1,94 @@ +/** + * Policy Screen - Active policy viewer + */ + +import { THEME } from "../theme" +import type { Screen, ScreenContext } from "../types" + +export const policyScreen: Screen = { + render(ctx: ScreenContext): string { + return renderPolicyScreen(ctx) + }, + + handleInput(key: string, ctx: ScreenContext): boolean { + const { app } = ctx + + if (key === "\x1b" || key === "\x1b\x1b" || key === "q") { + app.setScreen("main") + return true + } + + if (key === "r") { + app.connectHushd() + return true + } + + return false + }, +} + +function renderPolicyScreen(ctx: ScreenContext): string { + const { state, width, height } = ctx + const lines: string[] = [] + const boxWidth = Math.min(65, width - 10) + const boxPad = Math.max(0, Math.floor((width - boxWidth) / 2)) + + lines.push("") + lines.push("") + + const title = "⟨ Active Policy ⟩" + const titlePadLeft = Math.floor((boxWidth - title.length - 4) / 2) + const titlePadRight = boxWidth - title.length - titlePadLeft - 4 + lines.push(" ".repeat(boxPad) + THEME.dim + "╔═" + "═".repeat(titlePadLeft) + title + "═".repeat(titlePadRight) + "═╗" + THEME.reset) + lines.push(" ".repeat(boxPad) + THEME.dim + "║" + " ".repeat(boxWidth - 2) + "║" + THEME.reset) + + const p = state.activePolicy + if (!state.hushdConnected || !p) { + const msg = !state.hushdConnected ? " hushd not connected" : " No policy loaded" + const mLen = msg.length + lines.push(" ".repeat(boxPad) + THEME.dim + "║" + THEME.reset + ` ${THEME.muted}${msg.trim()}${THEME.reset}` + " ".repeat(Math.max(0, boxWidth - mLen - 2)) + THEME.dim + "║" + THEME.reset) + } else { + // Policy metadata + const fields = [ + ["Name", p.name], + ["Version", p.version], + ["Schema", p.schema_version], + ["Hash", p.hash.slice(0, 16) + "…"], + ["Loaded", new Date(p.loaded_at).toLocaleString()], + ] + + for (const [key, value] of fields) { + const fLine = ` ${THEME.muted}${key.padEnd(10)}${THEME.reset}${THEME.white}${value}${THEME.reset}` + const fLen = ` ${key.padEnd(10)}${value}`.length + lines.push(" ".repeat(boxPad) + THEME.dim + "║" + THEME.reset + fLine + " ".repeat(Math.max(0, boxWidth - fLen - 2)) + THEME.dim + "║" + THEME.reset) + } + + if (p.extends && p.extends.length > 0) { + const eLine = ` ${THEME.muted}Extends ${THEME.reset}${THEME.dim}${p.extends.join(", ")}${THEME.reset}` + const eLen = ` Extends ${p.extends.join(", ")}`.length + lines.push(" ".repeat(boxPad) + THEME.dim + "║" + THEME.reset + eLine + " ".repeat(Math.max(0, boxWidth - eLen - 2)) + THEME.dim + "║" + THEME.reset) + } + + // Guards list + lines.push(" ".repeat(boxPad) + THEME.dim + "║" + " ".repeat(boxWidth - 2) + "║" + THEME.reset) + const guardsHeader = ` ${THEME.secondary}◇${THEME.reset} ${THEME.white}${THEME.bold}Guards${THEME.reset}` + lines.push(" ".repeat(boxPad) + THEME.dim + "║" + THEME.reset + guardsHeader + " ".repeat(Math.max(0, boxWidth - 12)) + THEME.dim + "║" + THEME.reset) + + for (const guard of p.guards) { + const icon = guard.enabled ? `${THEME.success}◆` : `${THEME.dim}◇` + const status = guard.enabled ? "active" : "disabled" + const gLine = ` ${icon}${THEME.reset} ${THEME.muted}${guard.id.padEnd(30)}${THEME.reset}${THEME.dim}${status}${THEME.reset}` + const gLen = ` ◆ ${guard.id.padEnd(30)}${status}`.length + lines.push(" ".repeat(boxPad) + THEME.dim + "║" + THEME.reset + gLine + " ".repeat(Math.max(0, boxWidth - gLen - 2)) + THEME.dim + "║" + THEME.reset) + } + } + + lines.push(" ".repeat(boxPad) + THEME.dim + "║" + " ".repeat(boxWidth - 2) + "║" + THEME.reset) + const helpText = "r refresh ◆ esc back" + const helpPad = Math.max(0, Math.floor((boxWidth - helpText.length) / 2)) + lines.push(" ".repeat(boxPad) + THEME.dim + "║" + " ".repeat(helpPad) + helpText + " ".repeat(boxWidth - helpPad - helpText.length - 2) + "║" + THEME.reset) + lines.push(" ".repeat(boxPad) + THEME.dim + "╚" + "═".repeat(boxWidth - 2) + "╝" + THEME.reset) + + for (let i = lines.length; i < height - 1; i++) lines.push("") + return lines.join("\n") +} diff --git a/apps/terminal/src/tui/screens/result.ts b/apps/terminal/src/tui/screens/result.ts new file mode 100644 index 000000000..b6a11d858 --- /dev/null +++ b/apps/terminal/src/tui/screens/result.ts @@ -0,0 +1,106 @@ +/** + * Result Screen - Task dispatch/speculate result display + */ + +import { TUI } from "../index" +import { THEME } from "../theme" +import type { Screen, ScreenContext } from "../types" + +export const resultScreen: Screen = { + render(ctx: ScreenContext): string { + return renderResultScreen(ctx) + }, + + handleInput(key: string, ctx: ScreenContext): boolean { + if (key === "\x1b" || key === "q" || key === "\r" || key === " ") { + ctx.app.setScreen("main") + return true + } + return false + }, +} + +function renderResultScreen(ctx: ScreenContext): string { + const { state, width, height } = ctx + const lines: string[] = [] + const r = state.lastResult + + const boxWidth = Math.min(65, width - 10) + const boxPad = Math.max(0, Math.floor((width - boxWidth) / 2)) + const startY = Math.max(2, Math.floor(height / 6)) + + for (let i = 0; i < startY; i++) lines.push("") + + // Title + const titleIcon = r?.success ? `${THEME.success}✓` : `${THEME.error}✗` + const titleText = r?.success ? "Task Completed" : "Task Failed" + const title = `⟨ ${titleText} ⟩` + const titlePadLeft = Math.floor((boxWidth - title.length - 4) / 2) + const titlePadRight = boxWidth - title.length - titlePadLeft - 4 + lines.push(" ".repeat(boxPad) + THEME.dim + "╔═" + "═".repeat(titlePadLeft) + title + "═".repeat(titlePadRight) + "═╗" + THEME.reset) + lines.push(" ".repeat(boxPad) + THEME.dim + "║" + " ".repeat(boxWidth - 2) + "║" + THEME.reset) + + const addRow = (label: string, value: string) => { + const content = ` ${THEME.muted}${label.padEnd(14)}${THEME.reset}${value}` + const contentLen = ` ${label.padEnd(14)}${value.replace(/\x1b\[[0-9;]*m/g, "")}`.length + const rightPad = Math.max(0, boxWidth - contentLen - 2) + lines.push(" ".repeat(boxPad) + THEME.dim + "║" + THEME.reset + content + " ".repeat(rightPad) + THEME.dim + "║" + THEME.reset) + } + + if (r) { + addRow("Status", `${titleIcon}${THEME.reset} ${titleText}`) + addRow("Agent", `${THEME.white}${r.agent}${THEME.reset}`) + addRow("Duration", `${THEME.muted}${TUI.formatDuration(r.duration)}${THEME.reset}`) + if (r.taskId) addRow("Task", `${THEME.dim}${r.taskId.slice(0, 8)}${THEME.reset}`) + + lines.push(" ".repeat(boxPad) + THEME.dim + "║" + " ".repeat(boxWidth - 2) + "║" + THEME.reset) + + // Routing + if (r.routing) { + lines.push(" ".repeat(boxPad) + THEME.dim + "║ " + THEME.reset + THEME.secondary + "◇ " + THEME.reset + THEME.white + THEME.bold + "Routing" + THEME.reset + " ".repeat(boxWidth - 12) + THEME.dim + "║" + THEME.reset) + addRow("Toolchain", `${THEME.white}${r.routing.toolchain}${THEME.reset}`) + addRow("Strategy", `${THEME.muted}${r.routing.strategy}${THEME.reset}`) + if (r.routing.gates.length > 0) addRow("Gates", `${THEME.muted}${r.routing.gates.join(", ")}${THEME.reset}`) + lines.push(" ".repeat(boxPad) + THEME.dim + "║" + " ".repeat(boxWidth - 2) + "║" + THEME.reset) + } + + // Execution + if (r.execution) { + const execIcon = r.execution.success ? `${THEME.success}✓` : `${THEME.error}✗` + lines.push(" ".repeat(boxPad) + THEME.dim + "║ " + THEME.reset + THEME.secondary + "◇ " + THEME.reset + THEME.white + THEME.bold + "Execution" + THEME.reset + " ".repeat(boxWidth - 14) + THEME.dim + "║" + THEME.reset) + addRow("Result", `${execIcon}${THEME.reset} ${r.execution.success ? "success" : "failed"}`) + if (r.execution.model) addRow("Model", `${THEME.muted}${r.execution.model}${THEME.reset}`) + if (r.execution.tokens) addRow("Tokens", `${THEME.muted}${r.execution.tokens.input} in / ${r.execution.tokens.output} out${THEME.reset}`) + if (r.execution.cost) addRow("Cost", `${THEME.muted}$${r.execution.cost.toFixed(4)}${THEME.reset}`) + if (r.execution.error) addRow("Error", `${THEME.error}${r.execution.error.slice(0, 40)}${THEME.reset}`) + lines.push(" ".repeat(boxPad) + THEME.dim + "║" + " ".repeat(boxWidth - 2) + "║" + THEME.reset) + } + + // Verification + if (r.verification) { + const vIcon = r.verification.allPassed ? `${THEME.success}✓` : `${THEME.error}✗` + lines.push(" ".repeat(boxPad) + THEME.dim + "║ " + THEME.reset + THEME.secondary + "◇ " + THEME.reset + THEME.white + THEME.bold + "Verification" + THEME.reset + " ".repeat(boxWidth - 17) + THEME.dim + "║" + THEME.reset) + addRow("Score", `${vIcon}${THEME.reset} ${r.verification.score}/100`) + for (const g of r.verification.results) { + const gIcon = g.passed ? `${THEME.success}✓` : `${THEME.error}✗` + addRow("", ` ${gIcon}${THEME.reset} ${g.gate}`) + } + lines.push(" ".repeat(boxPad) + THEME.dim + "║" + " ".repeat(boxWidth - 2) + "║" + THEME.reset) + } + + // Error + if (r.error && !r.execution?.error) { + addRow("Error", `${THEME.error}${r.error.slice(0, 45)}${THEME.reset}`) + lines.push(" ".repeat(boxPad) + THEME.dim + "║" + " ".repeat(boxWidth - 2) + "║" + THEME.reset) + } + } + + // Help + const helpText = "enter continue ◆ esc back" + const helpPad2 = Math.max(0, Math.floor((boxWidth - helpText.length) / 2)) + lines.push(" ".repeat(boxPad) + THEME.dim + "║" + " ".repeat(helpPad2) + helpText + " ".repeat(boxWidth - helpPad2 - helpText.length - 2) + "║" + THEME.reset) + lines.push(" ".repeat(boxPad) + THEME.dim + "╚" + "═".repeat(boxWidth - 2) + "╝" + THEME.reset) + + for (let i = lines.length; i < height - 1; i++) lines.push("") + return lines.join("\n") +} diff --git a/apps/terminal/src/tui/screens/security.ts b/apps/terminal/src/tui/screens/security.ts new file mode 100644 index 000000000..46cd8fd6c --- /dev/null +++ b/apps/terminal/src/tui/screens/security.ts @@ -0,0 +1,107 @@ +/** + * Security Screen - Security overview with hushd connection status + */ + +import { THEME } from "../theme" +import type { Screen, ScreenContext } from "../types" + +export const securityScreen: Screen = { + render(ctx: ScreenContext): string { + return renderSecurityScreen(ctx) + }, + + handleInput(key: string, ctx: ScreenContext): boolean { + const { app } = ctx + + if (key === "\x1b" || key === "\x1b\x1b" || key === "q") { + app.setScreen("main") + return true + } + + if (key === "r") { + app.connectHushd() + return true + } + + return false + }, +} + +function renderSecurityScreen(ctx: ScreenContext): string { + const { state, width, height } = ctx + const lines: string[] = [] + const boxWidth = Math.min(75, width - 6) + const boxPad = Math.max(0, Math.floor((width - boxWidth) / 2)) + const startY = Math.max(1, Math.floor(height / 8)) + + for (let i = 0; i < startY; i++) lines.push("") + + // Title + const title = "⟨ Security Overview ⟩" + const titlePadLeft = Math.floor((boxWidth - title.length - 4) / 2) + const titlePadRight = boxWidth - title.length - titlePadLeft - 4 + lines.push(" ".repeat(boxPad) + THEME.dim + "╔═" + "═".repeat(titlePadLeft) + title + "═".repeat(titlePadRight) + "═╗" + THEME.reset) + lines.push(" ".repeat(boxPad) + THEME.dim + "║" + " ".repeat(boxWidth - 2) + "║" + THEME.reset) + + // Connection status + const connIcon = state.hushdConnected ? `${THEME.success}◆` : `${THEME.dim}◇` + const connText = state.hushdConnected ? "connected" : "disconnected" + const connLine = ` ${connIcon}${THEME.reset} hushd: ${THEME.muted}${connText}${THEME.reset}` + lines.push(" ".repeat(boxPad) + THEME.dim + "║" + THEME.reset + connLine + " ".repeat(Math.max(0, boxWidth - connText.length - 16)) + THEME.dim + "║" + THEME.reset) + + // Policy info + if (state.activePolicy) { + const p = state.activePolicy + const policyLine = ` ${THEME.secondary}◇${THEME.reset} policy: ${THEME.white}${p.name}${THEME.reset} ${THEME.dim}v${p.version}${THEME.reset}` + const pLen = ` ◇ policy: ${p.name} v${p.version}`.length + lines.push(" ".repeat(boxPad) + THEME.dim + "║" + THEME.reset + policyLine + " ".repeat(Math.max(0, boxWidth - pLen - 2)) + THEME.dim + "║" + THEME.reset) + const guardsLine = ` ${THEME.dim}guards: ${p.guards.filter(g => g.enabled).length} active${THEME.reset}` + const gLen = ` guards: ${p.guards.filter(g => g.enabled).length} active`.length + lines.push(" ".repeat(boxPad) + THEME.dim + "║" + THEME.reset + guardsLine + " ".repeat(Math.max(0, boxWidth - gLen - 2)) + THEME.dim + "║" + THEME.reset) + } + + // Stats + if (state.auditStats) { + const s = state.auditStats + lines.push(" ".repeat(boxPad) + THEME.dim + "║" + " ".repeat(boxWidth - 2) + "║" + THEME.reset) + const statsHeader = ` ${THEME.secondary}◇${THEME.reset} ${THEME.white}${THEME.bold}Statistics${THEME.reset}` + lines.push(" ".repeat(boxPad) + THEME.dim + "║" + THEME.reset + statsHeader + " ".repeat(Math.max(0, boxWidth - 15)) + THEME.dim + "║" + THEME.reset) + const totalLine = ` total: ${THEME.white}${s.total_checks}${THEME.reset} allowed: ${THEME.success}${s.allowed}${THEME.reset} denied: ${THEME.error}${s.denied}${THEME.reset}` + const tLen = ` total: ${s.total_checks} allowed: ${s.allowed} denied: ${s.denied}`.length + lines.push(" ".repeat(boxPad) + THEME.dim + "║" + THEME.reset + totalLine + " ".repeat(Math.max(0, boxWidth - tLen - 2)) + THEME.dim + "║" + THEME.reset) + } + + // Recent events + lines.push(" ".repeat(boxPad) + THEME.dim + "║" + " ".repeat(boxWidth - 2) + "║" + THEME.reset) + const evtHeader = ` ${THEME.secondary}◇${THEME.reset} ${THEME.white}${THEME.bold}Recent Events${THEME.reset}` + lines.push(" ".repeat(boxPad) + THEME.dim + "║" + THEME.reset + evtHeader + " ".repeat(Math.max(0, boxWidth - 19)) + THEME.dim + "║" + THEME.reset) + + const maxEvents = Math.min(state.recentEvents.length, height - lines.length - 8) + if (maxEvents === 0) { + const noEvt = ` ${THEME.muted}No events yet${THEME.reset}` + lines.push(" ".repeat(boxPad) + THEME.dim + "║" + THEME.reset + noEvt + " ".repeat(Math.max(0, boxWidth - 17)) + THEME.dim + "║" + THEME.reset) + } else { + for (let i = 0; i < maxEvents; i++) { + const evt = state.recentEvents[i] + if (evt.type === "check") { + const d = evt.data as { action_type?: string; target?: string; guard?: string; decision?: string } + const icon = d.decision === "deny" ? `${THEME.error}✗` : `${THEME.success}✓` + const target = (d.target ?? "").length > 25 ? "…" + (d.target ?? "").slice(-24) : (d.target ?? "") + const evtLine = ` ${icon}${THEME.reset} ${THEME.muted}${(d.action_type ?? "").padEnd(7)}${THEME.reset} ${target.padEnd(26)} ${THEME.dim}${d.guard ?? ""}${THEME.reset}` + const evtLen = ` ✗ ${(d.action_type ?? "").padEnd(7)} ${target.padEnd(26)} ${d.guard ?? ""}`.length + lines.push(" ".repeat(boxPad) + THEME.dim + "║" + THEME.reset + evtLine + " ".repeat(Math.max(0, boxWidth - evtLen - 2)) + THEME.dim + "║" + THEME.reset) + } + } + } + + // Help + lines.push(" ".repeat(boxPad) + THEME.dim + "║" + " ".repeat(boxWidth - 2) + "║" + THEME.reset) + const helpText = "r refresh ◆ esc back" + const helpPad = Math.max(0, Math.floor((boxWidth - helpText.length) / 2)) + lines.push(" ".repeat(boxPad) + THEME.dim + "║" + " ".repeat(helpPad) + helpText + " ".repeat(boxWidth - helpPad - helpText.length - 2) + "║" + THEME.reset) + lines.push(" ".repeat(boxPad) + THEME.dim + "╚" + "═".repeat(boxWidth - 2) + "╝" + THEME.reset) + + // Fill remaining + for (let i = lines.length; i < height - 1; i++) lines.push("") + return lines.join("\n") +} diff --git a/apps/terminal/src/tui/screens/setup.ts b/apps/terminal/src/tui/screens/setup.ts new file mode 100644 index 000000000..19665c4a8 --- /dev/null +++ b/apps/terminal/src/tui/screens/setup.ts @@ -0,0 +1,186 @@ +/** + * Setup Screen - First-run wizard + */ + +import { THEME } from "../theme" +import type { Screen, ScreenContext } from "../types" +import type { SandboxMode } from "../../types" +import { Config } from "../../config" + +export const setupScreen: Screen = { + render(ctx: ScreenContext): string { + return renderSetupScreen(ctx) + }, + + handleInput(key: string, ctx: ScreenContext): boolean { + return handleSetupInput(key, ctx) + }, +} + +function getAvailableSandboxModes(ctx: ScreenContext): number[] { + const modes = [0] // inplace always available + if (ctx.state.setupDetection?.git_available) { + modes.push(1) // worktree + } + modes.push(2) // tmpdir always available + return modes +} + +function handleSetupInput(key: string, ctx: ScreenContext): boolean { + const { state, app } = ctx + + if (state.setupStep === "detecting") return true + + // j/↓: next sandbox option + if (key === "j" || key === "\x1b[B") { + const modes = getAvailableSandboxModes(ctx) + const currentIdx = modes.indexOf(state.setupSandboxIndex) + if (currentIdx < modes.length - 1) { + state.setupSandboxIndex = modes[currentIdx + 1] + } + app.render() + return true + } + + // k/↑: previous sandbox option + if (key === "k" || key === "\x1b[A") { + const modes = getAvailableSandboxModes(ctx) + const currentIdx = modes.indexOf(state.setupSandboxIndex) + if (currentIdx > 0) { + state.setupSandboxIndex = modes[currentIdx - 1] + } + app.render() + return true + } + + // Enter: confirm + if (key === "\r") { + confirmSetup(ctx) + return true + } + + // Esc: quit + if (key === "\x1b" || key === "\x1b\x1b") { + app.quit() + return true + } + + return true +} + +async function confirmSetup(ctx: ScreenContext): Promise<void> { + const { state, app } = ctx + const detection = state.setupDetection + if (!detection) return + + const modeNames: SandboxMode[] = ["inplace", "worktree", "tmpdir"] + const config = { + schema_version: "1.0.0" as const, + sandbox: modeNames[state.setupSandboxIndex], + toolchain: detection.recommended_toolchain, + adapters: detection.adapters, + git_available: detection.git_available, + project_id: "default", + } + await Config.save(app.getCwd(), config) + + state.inputMode = "main" + state.statusMessage = `${THEME.success}✓${THEME.reset} Configuration saved` + app.render() + + setTimeout(() => { + state.statusMessage = "" + app.render() + }, 3000) +} + +function renderSetupScreen(ctx: ScreenContext): string { + const { state, width, height } = ctx + const lines: string[] = [] + const detection = state.setupDetection + + const boxWidth = Math.min(60, width - 10) + const boxPad = Math.max(0, Math.floor((width - boxWidth) / 2)) + const startY = Math.max(2, Math.floor(height / 6)) + + for (let i = 0; i < startY; i++) lines.push("") + + // Title + const title = "⟨ Setup ⟩" + const titlePadLeft = Math.floor((boxWidth - title.length - 4) / 2) + const titlePadRight = boxWidth - title.length - titlePadLeft - 4 + lines.push(" ".repeat(boxPad) + THEME.dim + "╔═" + "═".repeat(titlePadLeft) + title + "═".repeat(titlePadRight) + "═╗" + THEME.reset) + lines.push(" ".repeat(boxPad) + THEME.dim + "║" + " ".repeat(boxWidth - 2) + "║" + THEME.reset) + + if (state.setupStep === "detecting" || !detection) { + const msg = "◈ Divining system state..." + lines.push(" ".repeat(boxPad) + THEME.dim + "║ " + THEME.reset + THEME.secondary + msg + THEME.reset + " ".repeat(Math.max(0, boxWidth - msg.length - 4)) + THEME.dim + "║" + THEME.reset) + } else { + // Detected Toolchains section + const tcLabel = "Detected Toolchains" + lines.push(" ".repeat(boxPad) + THEME.dim + "║ " + THEME.reset + THEME.secondary + "◇ " + THEME.reset + THEME.white + THEME.bold + tcLabel + THEME.reset + " ".repeat(boxWidth - tcLabel.length - 6) + THEME.dim + "║" + THEME.reset) + + const adapterOrder = ["claude", "codex", "opencode", "crush"] + for (const id of adapterOrder) { + const adapter = detection.adapters[id] + const available = adapter?.available ?? false + const icon = available ? `${THEME.success}◆` : `${THEME.dim}◇` + const status = available ? "available" : "not found" + const statusColor = available ? THEME.muted : THEME.dim + const content = ` ${icon}${THEME.reset} ${THEME.muted}${id.padEnd(12)}${THEME.reset}${statusColor}${status}${THEME.reset}` + const contentLen = ` ◆ ${id.padEnd(12)}${status}`.length + lines.push(" ".repeat(boxPad) + THEME.dim + "║" + THEME.reset + content + " ".repeat(Math.max(0, boxWidth - contentLen - 2)) + THEME.dim + "║" + THEME.reset) + } + + lines.push(" ".repeat(boxPad) + THEME.dim + "║" + " ".repeat(boxWidth - 2) + "║" + THEME.reset) + + // Sandbox Mode section + const sbLabel = "Sandbox Mode" + lines.push(" ".repeat(boxPad) + THEME.dim + "║ " + THEME.reset + THEME.secondary + "◇ " + THEME.reset + THEME.white + THEME.bold + sbLabel + THEME.reset + " ".repeat(boxWidth - sbLabel.length - 6) + THEME.dim + "║" + THEME.reset) + + const sandboxOptions: Array<{ idx: number; name: string; desc: string; disabled: boolean }> = [ + { idx: 0, name: "inplace", desc: "run in current directory", disabled: false }, + { idx: 1, name: "worktree", desc: "git worktree isolation", disabled: !detection.git_available }, + { idx: 2, name: "tmpdir", desc: "copy to temp directory", disabled: false }, + ] + + for (const opt of sandboxOptions) { + const selected = state.setupSandboxIndex === opt.idx + const selIcon = selected ? `${THEME.accent}>` : " " + const boxIcon = selected ? `${THEME.secondary}■` : `${THEME.dim}□` + const nameColor = opt.disabled ? THEME.dim : THEME.muted + const descColor = opt.disabled ? THEME.dim : THEME.dim + const suffix = opt.idx === 0 ? " (recommended)" : opt.disabled ? " (no git)" : "" + const content = ` ${selIcon}${THEME.reset} ${boxIcon}${THEME.reset} ${nameColor}${opt.name.padEnd(12)}${THEME.reset}${descColor}${opt.desc}${suffix}${THEME.reset}` + const contentLen = ` > ■ ${opt.name.padEnd(12)}${opt.desc}${suffix}`.length + lines.push(" ".repeat(boxPad) + THEME.dim + "║" + THEME.reset + content + " ".repeat(Math.max(0, boxWidth - contentLen - 2)) + THEME.dim + "║" + THEME.reset) + } + + lines.push(" ".repeat(boxPad) + THEME.dim + "║" + " ".repeat(boxWidth - 2) + "║" + THEME.reset) + + // Environment section + const envLabel = "Environment" + lines.push(" ".repeat(boxPad) + THEME.dim + "║ " + THEME.reset + THEME.secondary + "◇ " + THEME.reset + THEME.white + THEME.bold + envLabel + THEME.reset + " ".repeat(boxWidth - envLabel.length - 6) + THEME.dim + "║" + THEME.reset) + + const gitIcon = detection.git_available ? `${THEME.success}◆` : `${THEME.dim}◇` + const gitStatus = detection.git_available ? "detected" : "not found" + const gitLine = ` ${gitIcon}${THEME.reset} ${THEME.muted}${"git".padEnd(12)}${THEME.reset}${THEME.dim}${gitStatus}${THEME.reset}` + const gitLen = ` ◆ ${"git".padEnd(12)}${gitStatus}`.length + lines.push(" ".repeat(boxPad) + THEME.dim + "║" + THEME.reset + gitLine + " ".repeat(Math.max(0, boxWidth - gitLen - 2)) + THEME.dim + "║" + THEME.reset) + + lines.push(" ".repeat(boxPad) + THEME.dim + "║" + " ".repeat(boxWidth - 2) + "║" + THEME.reset) + } + + // Help text + const helpText = "enter confirm ◆ j/k select ◆ esc quit" + const helpPad = Math.max(0, Math.floor((boxWidth - helpText.length) / 2)) + lines.push(" ".repeat(boxPad) + THEME.dim + "║" + " ".repeat(helpPad) + helpText + " ".repeat(Math.max(0, boxWidth - helpPad - helpText.length - 2)) + "║" + THEME.reset) + + // Bottom border + lines.push(" ".repeat(boxPad) + THEME.dim + "╚" + "═".repeat(boxWidth - 2) + "╝" + THEME.reset) + + // Fill remaining + for (let i = lines.length; i < height - 1; i++) lines.push("") + + return lines.join("\n") +} diff --git a/apps/terminal/src/tui/theme.ts b/apps/terminal/src/tui/theme.ts new file mode 100644 index 000000000..d704628af --- /dev/null +++ b/apps/terminal/src/tui/theme.ts @@ -0,0 +1,167 @@ +/** + * Theme - Techno Classical Gothic + * + * Extracted from app.ts. Contains all visual constants: + * colors, logo, animation, escape sequences, and agent definitions. + */ + +// ============================================================================= +// COLORS +// ============================================================================= + +// Background color - deep obsidian black (defined first so reset can use it) +export const BG_COLOR = "\x1b[48;2;12;12;16m" + +export const THEME = { + // Primary accent - deep crimson (gothic blood) + accent: BG_COLOR + "\x1b[38;5;124m", + // Secondary accent - antique gold (classical elegance) + secondary: BG_COLOR + "\x1b[38;5;178m", + // Tertiary - deep violet (gothic shadow) + tertiary: BG_COLOR + "\x1b[38;5;97m", + // Success - verdigris/aged copper + success: BG_COLOR + "\x1b[38;5;30m", + // Warning - burnt sienna + warning: BG_COLOR + "\x1b[38;5;166m", + // Error - dark crimson + error: BG_COLOR + "\x1b[38;5;160m", + // Muted text - stone gray + muted: BG_COLOR + "\x1b[38;5;246m", + // Dimmer muted - charcoal + dim: BG_COLOR + "\x1b[38;5;240m", + // White text - ivory/pearl + white: BG_COLOR + "\x1b[38;5;188m", + // Background - deep obsidian black + bg: BG_COLOR, + // Reset - resets foreground but keeps background + reset: "\x1b[0m" + BG_COLOR, + // Bold + bold: "\x1b[1m", + // Dim + dimAttr: "\x1b[2m", + // Italic + italic: "\x1b[3m", +} as const + +export type ThemeColors = typeof THEME + +// ============================================================================= +// LOGO +// ============================================================================= + +// Gothic ASCII logo - stacked two-part layout +// "CLAWD" is static crimson, "STRIKE" is animated with gold shimmer +export const LOGO = { + // "CLAWD" - static crimson + main: [ + " ██████╗██╗ █████╗ ██╗ ██╗██████╗ ", + "██╔════╝██║ ██╔══██╗██║ ██║██╔══██╗", + "██║ ██║ ███████║██║ █╗ ██║██║ ██║", + "██║ ██║ ██╔══██║██║███╗██║██║ ██║", + "╚██████╗███████╗██║ ██║╚███╔███╔╝██████╔╝", + " ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝ ╚═════╝", + ], + // "STRIKE" - will be animated with gold shimmer + strike: [ + "███████╗████████╗██████╗ ██╗██╗ ██╗███████╗", + "██╔════╝╚══██╔══╝██╔══██╗██║██║ ██╔╝██╔════╝", + "███████╗ ██║ ██████╔╝██║█████╔╝ █████╗ ", + "╚════██║ ██║ ██╔══██╗██║██╔═██╗ ██╔══╝ ", + "███████║ ██║ ██║ ██║██║██║ ██╗███████╗", + "╚══════╝ ╚═╝ ╚═╝ ╚═╝╚═╝╚═╝ ╚═╝╚══════╝", + ], +} + +// ============================================================================= +// ANIMATION +// ============================================================================= + +// Gold shimmer palette for the animated "STRIKE" +export const GOLD_SHIMMER_COLORS = [ + "\x1b[38;2;132;90;32m", // Deep brass + "\x1b[38;2;165;113;40m", // Burnished bronze + "\x1b[38;2;198;142;58m", // Antique gold + "\x1b[38;2;224;180;96m", // Warm gold + "\x1b[38;2;242;215;150m", // Champagne + "\x1b[38;2;255;246;228m", // Ivory glint +] as const + +// Get animated "STRIKE" with smooth metallic shimmer +export function getAnimatedStrike(frame: number): string[] { + const result: string[] = [] + + const height = LOGO.strike.length + const width = LOGO.strike[0]?.length ?? 0 + + const diagonalSlope = 0.75 + const travel = (width - 1) + (height - 1) * diagonalSlope + const shimmerCenter = (frame * 0.32) % (travel + 1) + const bandWidth = 1.65 + + for (let row = 0; row < height; row++) { + let line = "" + let currentColor: string | null = null + const chars = [...LOGO.strike[row]] + + for (let col = 0; col < chars.length; col++) { + const char = chars[col] + + if (char === " ") { + line += " " + continue + } + + // Smooth diagonal shimmer band + subtle micro-variation for "metal" feel + const pos = col + row * diagonalSlope + let dist = Math.abs(pos - shimmerCenter) + dist = Math.min(dist, (travel + 1) - dist) + const glint = Math.exp(-(dist * dist) / (2 * bandWidth * bandWidth)) + + const microWave = (Math.sin(frame * 0.18 + row * 1.1 + col * 0.65) + 1) / 2 + const intensity = Math.min(1, Math.max(0, 0.58 + glint * 0.42 + (microWave - 0.5) * 0.08)) + + const colorIdx = Math.min( + GOLD_SHIMMER_COLORS.length - 1, + Math.floor(intensity * (GOLD_SHIMMER_COLORS.length - 1)), + ) + const color = GOLD_SHIMMER_COLORS[colorIdx] + + if (color !== currentColor) { + line += BG_COLOR + color + currentColor = color + } + line += char + } + result.push(line + THEME.reset) + } + + return result +} + +// ============================================================================= +// ESCAPE SEQUENCES +// ============================================================================= + +export const ESC = { + clearScreen: "\x1b[2J", + moveTo: (row: number, col: number) => `\x1b[${row};${col}H`, + hideCursor: "\x1b[?25l", + showCursor: "\x1b[?25h", + altScreen: "\x1b[?1049h", + mainScreen: "\x1b[?1049l", + clearLine: "\x1b[2K", + clearToEndOfScreen: "\x1b[J", +} as const + +// ============================================================================= +// AGENTS +// ============================================================================= + +export const AGENTS = [ + { id: "claude", name: "Claude", model: "Opus 4", provider: "Anthropic" }, + { id: "codex", name: "Codex", model: "GPT-5.2", provider: "OpenAI" }, + { id: "opencode", name: "OpenCode", model: "Multi", provider: "Open" }, + { id: "crush", name: "Crush", model: "Fallback", provider: "Multi" }, +] as const + +export type Agent = (typeof AGENTS)[number] diff --git a/apps/terminal/src/tui/types.ts b/apps/terminal/src/tui/types.ts new file mode 100644 index 000000000..597397cdf --- /dev/null +++ b/apps/terminal/src/tui/types.ts @@ -0,0 +1,391 @@ +/** + * TUI Types - Core type definitions for the screen-based architecture. + */ + +import type { ThemeColors } from "./theme" +import type { HealthSummary } from "../health" +import type { DaemonEvent, AuditStats, PolicyResponse } from "../hushd" +import type { DetectionResult } from "../config" +import type { + TimelineEvent, + Alert, + ScanPathResult, + ScanDiff, + HuntReport, + WatchStats, + RuleCondition, +} from "../hunt/types" +import type { ListViewport } from "./components/scrollable-list" +import type { TreeViewport } from "./components/tree-view" +import type { FormState } from "./components/form" +import type { LogState } from "./components/streaming-log" +import type { GridSelection } from "./components/grid" + +// ============================================================================= +// SCREEN SYSTEM +// ============================================================================= + +/** + * Context passed to every screen method. + * Provides access to shared state, dimensions, and theme. + */ +export interface ScreenContext { + state: AppState + width: number + height: number + theme: ThemeColors + /** Reference to the app for triggering actions */ + app: AppController +} + +/** + * Screen interface - each screen implements render + input handling. + */ +export interface Screen { + /** Render the screen content as a single string */ + render(ctx: ScreenContext): string + /** Handle a keypress. Return true if the key was consumed. */ + handleInput(key: string, ctx: ScreenContext): boolean + /** Called when this screen becomes active */ + onEnter?(ctx: ScreenContext): void + /** Called when this screen is being left */ + onExit?(ctx: ScreenContext): void +} + +/** + * Minimal interface for screens to call back into the app. + */ +export interface AppController { + /** Navigate to a different screen */ + setScreen(mode: InputMode): void + /** Trigger a re-render */ + render(): void + /** Run healthcheck */ + runHealthcheck(): void + /** Reconnect to hushd */ + connectHushd(): void + /** Submit a prompt */ + submitPrompt(action: "dispatch" | "speculate"): void + /** Run quality gates */ + runGates(): void + /** Show beads (exits TUI) */ + showBeads(): void + /** Show runs (exits TUI) */ + showRuns(): void + /** Show help (exits TUI) */ + showHelp(): void + /** Quit the app */ + quit(): void + /** Get CWD */ + getCwd(): string +} + +// ============================================================================= +// COMMANDS +// ============================================================================= + +export interface Command { + key: string + label: string + description: string + action: () => Promise<void> | void +} + +// ============================================================================= +// INPUT MODES +// ============================================================================= + +export type InputMode = + | "main" + | "commands" + | "integrations" + | "security" + | "audit" + | "policy" + | "result" + | "setup" + // Hunt screens + | "hunt-watch" + | "hunt-scan" + | "hunt-timeline" + | "hunt-rule-builder" + | "hunt-query" + | "hunt-diff" + | "hunt-report" + | "hunt-mitre" + | "hunt-playbook" + +// ============================================================================= +// DISPATCH RESULT +// ============================================================================= + +export interface DispatchResultInfo { + success: boolean + taskId: string + agent: string + action: "dispatch" | "speculate" + routing?: { toolchain: string; strategy: string; gates: string[] } + execution?: { + success: boolean + error?: string + model?: string + tokens?: { input: number; output: number } + cost?: number + } + verification?: { + allPassed: boolean + score: number + summary: string + results: Array<{ gate: string; passed: boolean }> + } + error?: string + duration: number +} + +// ============================================================================= +// HUNT STATE +// ============================================================================= + +export interface HuntWatchState { + log: LogState + running: boolean + filter: "all" | "allow" | "deny" | "audit" + stats: WatchStats | null + lastAlert: Alert | null + alertFadeTimer: ReturnType<typeof setTimeout> | null +} + +export interface HuntScanState { + results: ScanPathResult[] + tree: TreeViewport + loading: boolean + error: string | null + selectedDetail: string | null +} + +export interface HuntTimelineState { + events: TimelineEvent[] + list: ListViewport + expandedIndex: number | null + sourceFilters: { tetragon: boolean; hubble: boolean; receipt: boolean; spine: boolean } + loading: boolean + error: string | null +} + +export interface HuntRuleBuilderState { + form: FormState + conditions: RuleCondition[] + conditionList: ListViewport + dryRunResults: Alert[] + dryRunning: boolean + saving: boolean + error: string | null + statusMessage: string | null +} + +export interface HuntQueryState { + mode: "nl" | "structured" + nlInput: string + structuredForm: FormState + results: TimelineEvent[] + resultList: ListViewport + loading: boolean + error: string | null +} + +export interface HuntDiffState { + current: ScanPathResult[] + previous: ScanPathResult[] + diff: ScanDiff | null + list: ListViewport + expandedServer: string | null + loading: boolean + error: string | null +} + +export interface HuntReportState { + report: HuntReport | null + list: ListViewport + expandedEvidence: number | null + error: string | null +} + +export interface HuntMitreState { + grid: GridSelection + matrix: number[][] + tactics: string[] + techniques: string[] + events: TimelineEvent[] + drilldownEvents: TimelineEvent[] + drilldownList: ListViewport + loading: boolean + error: string | null +} + +export interface HuntPlaybookState { + steps: import("../hunt/types").PlaybookStep[] + selectedStep: number + detailList: ListViewport + running: boolean + error: string | null + report: HuntReport | null +} + +export interface HuntState { + watch: HuntWatchState + scan: HuntScanState + timeline: HuntTimelineState + ruleBuilder: HuntRuleBuilderState + query: HuntQueryState + diff: HuntDiffState + report: HuntReportState + mitre: HuntMitreState + playbook: HuntPlaybookState +} + +// ============================================================================= +// APP STATE +// ============================================================================= + +export interface AppState { + // Input + promptBuffer: string + agentIndex: number + + // UI mode + inputMode: InputMode + commandIndex: number + + // Status + statusMessage: string + isRunning: boolean + activeRuns: number + openBeads: number + lastRefresh: Date + + // Health + health: HealthSummary | null + healthChecking: boolean + + // Animation + animationFrame: number + + // Security (hushd) + hushdConnected: boolean + recentEvents: DaemonEvent[] + auditStats: AuditStats | null + activePolicy: PolicyResponse | null + securityError: string | null + + // Last dispatch result + lastResult: DispatchResultInfo | null + + // Setup wizard + setupDetection: DetectionResult | null + setupStep: "detecting" | "review" | "done" + setupSandboxIndex: number + + // Hunt + hunt: HuntState +} + +// ============================================================================= +// FACTORY +// ============================================================================= + +export function createInitialHuntState(): HuntState { + return { + watch: { + log: { lines: [], maxLines: 1000, viewport: 0, paused: false }, + running: false, + filter: "all", + stats: null, + lastAlert: null, + alertFadeTimer: null, + }, + scan: { + results: [], + tree: { offset: 0, selected: 0, expandedKeys: new Set() }, + loading: false, + error: null, + selectedDetail: null, + }, + timeline: { + events: [], + list: { offset: 0, selected: 0 }, + expandedIndex: null, + sourceFilters: { tetragon: true, hubble: true, receipt: true, spine: true }, + loading: false, + error: null, + }, + ruleBuilder: { + form: { + fields: [ + { type: "text", label: "Name", value: "", placeholder: "rule-name" }, + { type: "select", label: "Severity", options: ["low", "medium", "high", "critical"], selectedIndex: 1 }, + { type: "text", label: "Window (s)", value: "300", placeholder: "300" }, + { type: "text", label: "Description", value: "", placeholder: "Rule description" }, + ], + focusedIndex: 0, + }, + conditions: [], + conditionList: { offset: 0, selected: 0 }, + dryRunResults: [], + dryRunning: false, + saving: false, + error: null, + statusMessage: null, + }, + query: { + mode: "nl", + nlInput: "", + structuredForm: { + fields: [ + { type: "select", label: "Source", options: ["any", "tetragon", "hubble", "receipt", "spine"], selectedIndex: 0 }, + { type: "select", label: "Verdict", options: ["any", "allow", "deny", "audit"], selectedIndex: 0 }, + { type: "text", label: "Since", value: "", placeholder: "1h, 24h, 7d" }, + { type: "text", label: "Limit", value: "50", placeholder: "50" }, + ], + focusedIndex: 0, + }, + results: [], + resultList: { offset: 0, selected: 0 }, + loading: false, + error: null, + }, + diff: { + current: [], + previous: [], + diff: null, + list: { offset: 0, selected: 0 }, + expandedServer: null, + loading: false, + error: null, + }, + report: { + report: null, + list: { offset: 0, selected: 0 }, + expandedEvidence: null, + error: null, + }, + mitre: { + grid: { row: 0, col: 0 }, + matrix: [], + tactics: [], + techniques: [], + events: [], + drilldownEvents: [], + drilldownList: { offset: 0, selected: 0 }, + loading: false, + error: null, + }, + playbook: { + steps: [], + selectedStep: 0, + detailList: { offset: 0, selected: 0 }, + running: false, + error: null, + report: null, + }, + } +} diff --git a/apps/terminal/src/types.ts b/apps/terminal/src/types.ts new file mode 100644 index 000000000..fce66ca10 --- /dev/null +++ b/apps/terminal/src/types.ts @@ -0,0 +1,330 @@ +/** + * ClawdStrike Canonical Type Definitions + * + * All types are defined using Zod schemas for runtime validation. + * This is the single source of truth for all ClawdStrike data structures. + */ + +import { z } from "zod" + +// ============================================================================ +// IDENTIFIERS +// ============================================================================ + +export const TaskId = z.string().uuid() +export const WorkcellId = z.string().uuid() +export const BeadId = z.string().regex(/^[A-Z]+-\d+$/) // e.g., "PROJ-123" + +// ============================================================================ +// TOOLCHAINS +// ============================================================================ + +export const Toolchain = z.enum([ + "codex", // OpenAI Codex CLI + "claude", // Anthropic Claude Code + "opencode", // Local OpenCode + "crush", // Multi-provider fallback +]) + +export const ToolchainConfig = z.object({ + id: Toolchain, + enabled: z.boolean().default(true), + priority: z.number().int().default(0), + authType: z.enum(["oauth", "api_key", "none"]), + settings: z.record(z.string(), z.any()).optional(), +}) + +// ============================================================================ +// TASKS +// ============================================================================ + +export const TaskInput = z.object({ + id: TaskId.optional(), + prompt: z.string().min(1).max(100000), + context: z.object({ + cwd: z.string(), + projectId: z.string(), + branch: z.string().optional(), + files: z.array(z.string()).optional(), + env: z.record(z.string(), z.string()).optional(), + }), + labels: z.array(z.string()).optional(), + hint: Toolchain.optional(), + gates: z.array(z.string()).optional(), + beadId: BeadId.optional(), + timeout: z.number().int().positive().optional(), +}) + +export const TaskStatus = z.enum([ + "pending", + "routing", + "executing", + "verifying", + "completed", + "failed", + "cancelled", +]) + +// ============================================================================ +// EXECUTION +// ============================================================================ + +export const ExecutionResult = z.object({ + taskId: TaskId, + workcellId: WorkcellId, + toolchain: Toolchain, + success: z.boolean(), + patch: z.string().optional(), + output: z.string(), + error: z.string().optional(), + telemetry: z.object({ + startedAt: z.number(), + completedAt: z.number(), + model: z.string().optional(), + tokens: z + .object({ + input: z.number(), + output: z.number(), + }) + .optional(), + cost: z.number().optional(), + }), +}) + +// ============================================================================ +// VERIFICATION +// ============================================================================ + +export const Severity = z.enum(["error", "warning", "info"]) + +export const Diagnostic = z.object({ + file: z.string().optional(), + line: z.number().int().positive().optional(), + column: z.number().int().positive().optional(), + severity: Severity, + message: z.string(), + code: z.string().optional(), + source: z.string().optional(), // e.g., "mypy", "ruff" +}) + +export const GateResult = z.object({ + gate: z.string(), + passed: z.boolean(), + critical: z.boolean(), + output: z.string(), + diagnostics: z.array(Diagnostic).optional(), + timing: z.object({ + startedAt: z.number(), + completedAt: z.number(), + }), +}) + +export const GateResults = z.object({ + allPassed: z.boolean(), + criticalPassed: z.boolean(), + results: z.array(GateResult), + score: z.number().int().min(0).max(100), + summary: z.string(), +}) + +// ============================================================================ +// WORKCELLS +// ============================================================================ + +export const WorkcellStatus = z.enum([ + "creating", + "warm", + "in_use", + "cleaning", + "destroyed", +]) + +export const SandboxMode = z.enum(["inplace", "worktree", "tmpdir"]) + +export const WorkcellInfo = z.object({ + id: WorkcellId, + name: z.string(), + directory: z.string(), + branch: z.string(), + status: WorkcellStatus, + toolchain: Toolchain.optional(), + projectId: z.string(), + createdAt: z.number(), + lastUsedAt: z.number().optional(), + useCount: z.number().int().default(0), +}) + +// ============================================================================ +// SPECULATION +// ============================================================================ + +export const VoteStrategy = z.enum([ + "first_pass", // First passing result wins + "best_score", // Highest gate score wins + "consensus", // Most similar patch wins +]) + +export const SpeculationConfig = z.object({ + count: z.number().int().min(2).max(5), + toolchains: z.array(Toolchain), + voteStrategy: VoteStrategy, + timeout: z.number().int().positive().default(300000), +}) + +export const SpeculationResult = z.object({ + winner: z + .object({ + workcellId: WorkcellId, + toolchain: Toolchain, + result: ExecutionResult, + gateResults: GateResults, + }) + .optional(), + allResults: z.array( + z.object({ + workcellId: WorkcellId, + toolchain: Toolchain, + result: ExecutionResult.optional(), + gateResults: GateResults.optional(), + error: z.string().optional(), + }) + ), + timing: z.object({ + startedAt: z.number(), + completedAt: z.number(), + }), + votes: z.record(z.string(), z.number()).optional(), +}) + +// ============================================================================ +// ROUTING +// ============================================================================ + +export const RoutingStrategy = z.enum(["single", "speculate"]) + +export const RoutingDecision = z.object({ + taskId: TaskId, + toolchain: Toolchain, + strategy: RoutingStrategy, + speculation: SpeculationConfig.optional(), + gates: z.array(z.string()), + retries: z.number().int().min(0).max(3).default(1), + priority: z.number().int().min(0).max(100).default(50), + reasoning: z.string().optional(), +}) + +// ============================================================================ +// PATCHES +// ============================================================================ + +export const PatchStatus = z.enum([ + "captured", // Diff extracted from workcell + "validating", // Gates running + "validated", // Gates passed + "rejected", // Gates failed or user rejected + "staged", // Awaiting user review + "approved", // User approved + "merging", // Applying to main repo + "merged", // Successfully applied + "failed", // Merge failed (conflicts, etc.) +]) + +export const Patch = z.object({ + id: z.string().uuid(), + workcellId: WorkcellId, + taskId: TaskId, + diff: z.string(), // Unified diff format + stats: z.object({ + filesChanged: z.number().int().nonnegative(), + insertions: z.number().int().nonnegative(), + deletions: z.number().int().nonnegative(), + }), + files: z.array(z.string()), // List of changed files + status: PatchStatus, + createdAt: z.number(), + validatedAt: z.number().optional(), + mergedAt: z.number().optional(), +}) + +// ============================================================================ +// BEADS (Work Graph) +// ============================================================================ + +export const BeadStatus = z.enum([ + "open", + "in_progress", + "blocked", + "completed", + "cancelled", +]) + +export const BeadPriority = z.enum(["p0", "p1", "p2", "p3"]) + +export const Bead = z.object({ + id: BeadId, + title: z.string(), + description: z.string().optional(), + status: BeadStatus, + priority: BeadPriority.optional(), + labels: z.array(z.string()).optional(), + assignee: z.string().optional(), + createdAt: z.number(), + updatedAt: z.number(), + closedAt: z.number().optional(), +}) + +// ============================================================================ +// TELEMETRY +// ============================================================================ + +export const TelemetryEvent = z.object({ + timestamp: z.number(), + type: z.string(), + taskId: TaskId.optional(), + workcellId: WorkcellId.optional(), + data: z.record(z.string(), z.any()).optional(), +}) + +export const Rollout = z.object({ + id: z.string().uuid(), + taskId: TaskId, + startedAt: z.number(), + completedAt: z.number().optional(), + status: TaskStatus, + routing: RoutingDecision.optional(), + execution: ExecutionResult.optional(), + verification: GateResults.optional(), + events: z.array(TelemetryEvent), +}) + +// ============================================================================ +// TYPE EXPORTS (inferred types for TypeScript usage) +// ============================================================================ + +export type TaskId = z.infer<typeof TaskId> +export type WorkcellId = z.infer<typeof WorkcellId> +export type BeadId = z.infer<typeof BeadId> +export type Toolchain = z.infer<typeof Toolchain> +export type ToolchainConfig = z.infer<typeof ToolchainConfig> +export type TaskInput = z.infer<typeof TaskInput> +export type TaskStatus = z.infer<typeof TaskStatus> +export type ExecutionResult = z.infer<typeof ExecutionResult> +export type Severity = z.infer<typeof Severity> +export type Diagnostic = z.infer<typeof Diagnostic> +export type GateResult = z.infer<typeof GateResult> +export type GateResults = z.infer<typeof GateResults> +export type WorkcellStatus = z.infer<typeof WorkcellStatus> +export type SandboxMode = z.infer<typeof SandboxMode> +export type WorkcellInfo = z.infer<typeof WorkcellInfo> +export type VoteStrategy = z.infer<typeof VoteStrategy> +export type SpeculationConfig = z.infer<typeof SpeculationConfig> +export type SpeculationResult = z.infer<typeof SpeculationResult> +export type RoutingStrategy = z.infer<typeof RoutingStrategy> +export type RoutingDecision = z.infer<typeof RoutingDecision> +export type PatchStatus = z.infer<typeof PatchStatus> +export type Patch = z.infer<typeof Patch> +export type BeadStatus = z.infer<typeof BeadStatus> +export type BeadPriority = z.infer<typeof BeadPriority> +export type Bead = z.infer<typeof Bead> +export type TelemetryEvent = z.infer<typeof TelemetryEvent> +export type Rollout = z.infer<typeof Rollout> diff --git a/apps/terminal/src/verifier/gates/clawdstrike.ts b/apps/terminal/src/verifier/gates/clawdstrike.ts new file mode 100644 index 000000000..f8ca682d1 --- /dev/null +++ b/apps/terminal/src/verifier/gates/clawdstrike.ts @@ -0,0 +1,134 @@ +/** + * ClawdStrike Gate - Security policy enforcement via hushd + * + * Posts diffs to hushd /api/v1/check with action_type "patch". + * Uses PatchIntegrityGuard + SecretLeakGuard on agent output. + * Non-critical by default (warns, doesn't block). + * Skipped if hushd is unavailable. + */ + +import type { Gate } from "../index" +import type { GateResult, WorkcellInfo, Diagnostic } from "../../types" +import { Hushd } from "../../hushd" + +/** + * ClawdStrike security gate implementation + */ +export const ClawdStrikeGate: Gate = { + info: { + id: "clawdstrike", + name: "ClawdStrike", + description: "Security policy check via hushd (PatchIntegrity + SecretLeak)", + critical: false, // Non-critical by default - warns, doesn't block + }, + + async isAvailable(_workcell: WorkcellInfo): Promise<boolean> { + try { + const client = Hushd.getClient() + return await client.probe() + } catch { + return false + } + }, + + async run(workcell: WorkcellInfo, signal: AbortSignal): Promise<GateResult> { + const startTime = Date.now() + const diagnostics: Diagnostic[] = [] + + try { + const client = Hushd.getClient() + + // Check if hushd is available + const available = await client.probe() + if (!available) { + return { + gate: "clawdstrike", + passed: true, // Fail-open: pass if hushd unavailable + critical: false, + output: "hushd unavailable - skipped", + timing: { startedAt: startTime, completedAt: Date.now() }, + } + } + + if (signal.aborted) { + return { + gate: "clawdstrike", + passed: false, + critical: false, + output: "Cancelled", + timing: { startedAt: startTime, completedAt: Date.now() }, + } + } + + // Get diff from workcell + const { getWorktreeDiff } = await import("../../workcell/git") + const diff = await getWorktreeDiff(workcell.directory) + + if (!diff || diff.trim().length === 0) { + return { + gate: "clawdstrike", + passed: true, + critical: false, + output: "No changes to check", + timing: { startedAt: startTime, completedAt: Date.now() }, + } + } + + // Submit patch for security check + const result = await client.check({ + action_type: "patch", + target: workcell.directory, + context: { + diff, + workcell_id: workcell.id, + branch: workcell.branch, + }, + }) + + if (!result) { + return { + gate: "clawdstrike", + passed: true, // Fail-open on connectivity error + critical: false, + output: "hushd check failed - skipped", + timing: { startedAt: startTime, completedAt: Date.now() }, + } + } + + // Parse guard results into diagnostics + for (const guard of result.guards) { + if (guard.decision === "deny") { + diagnostics.push({ + severity: guard.severity === "critical" ? "error" : "warning", + message: `${guard.guard}: ${guard.reason ?? "denied"}`, + source: "clawdstrike", + }) + } + } + + const passed = result.decision === "allow" + const guardSummary = result.guards + .map(g => `${g.guard}: ${g.decision}`) + .join(", ") + + return { + gate: "clawdstrike", + passed, + critical: false, + output: `Policy: ${result.policy} v${result.policy_version} | ${guardSummary}`, + diagnostics: diagnostics.length > 0 ? diagnostics : undefined, + timing: { startedAt: startTime, completedAt: Date.now() }, + } + } catch (err) { + return { + gate: "clawdstrike", + passed: true, // Fail-open on errors + critical: false, + output: `Error: ${err instanceof Error ? err.message : String(err)}`, + timing: { startedAt: startTime, completedAt: Date.now() }, + } + } + }, +} + +export default ClawdStrikeGate diff --git a/apps/terminal/src/verifier/gates/index.ts b/apps/terminal/src/verifier/gates/index.ts new file mode 100644 index 000000000..477ccc575 --- /dev/null +++ b/apps/terminal/src/verifier/gates/index.ts @@ -0,0 +1,45 @@ +/** + * Gate registry - Quality gate exports + */ + +export { PytestGate } from "./pytest" +export { MypyGate } from "./mypy" +export { RuffGate } from "./ruff" +export { ClawdStrikeGate } from "./clawdstrike" + +import { PytestGate } from "./pytest" +import { MypyGate } from "./mypy" +import { RuffGate } from "./ruff" +import { ClawdStrikeGate } from "./clawdstrike" +import type { Gate } from "../index" + +/** + * Built-in gates registry + */ +export const gates: Record<string, Gate> = { + pytest: PytestGate, + mypy: MypyGate, + ruff: RuffGate, + clawdstrike: ClawdStrikeGate, +} + +/** + * Get gate by ID + */ +export function getGate(id: string): Gate | undefined { + return gates[id] +} + +/** + * Get all gates + */ +export function getAllGates(): Gate[] { + return Object.values(gates) +} + +/** + * Register a custom gate + */ +export function registerGate(gate: Gate): void { + gates[gate.info.id] = gate +} diff --git a/apps/terminal/src/verifier/gates/mypy.ts b/apps/terminal/src/verifier/gates/mypy.ts new file mode 100644 index 000000000..4e70dbd5c --- /dev/null +++ b/apps/terminal/src/verifier/gates/mypy.ts @@ -0,0 +1,213 @@ +/** + * Mypy Gate - Python type checker + * + * Runs mypy type checking on Python code and parses output for diagnostics. + */ + +import { $ } from "bun" +import { join } from "path" +import { stat } from "fs/promises" +import type { Gate } from "../index" +import type { GateResult, WorkcellInfo, Diagnostic, Severity } from "../../types" + +/** + * Default mypy configuration + */ +export const DEFAULT_CONFIG = { + timeout: 120000, // 2 minutes + args: ["--show-column-numbers"], + cwd: undefined as string | undefined, + filePatterns: ["**/*.py"], +} + +/** + * Parse mypy output for diagnostics + * + * Mypy output format: file.py:line:col: error: message [error-code] + */ +export function parseDiagnostics(output: string): Diagnostic[] { + const diagnostics: Diagnostic[] = [] + + // Parse mypy error/warning/note lines + // Example: foo.py:10:5: error: Incompatible types [assignment] + const errorRegex = /^(.+):(\d+):(\d+):\s*(error|warning|note):\s*(.+?)(?:\s+\[([^\]]+)\])?$/gm + let match: RegExpExecArray | null + while ((match = errorRegex.exec(output)) !== null) { + const severityMap: Record<string, Severity> = { + error: "error", + warning: "warning", + note: "info", + } + + diagnostics.push({ + file: match[1], + line: parseInt(match[2]), + column: parseInt(match[3]), + severity: severityMap[match[4]] || "error", + message: match[5], + code: match[6], + source: "mypy", + }) + } + + // Also parse simpler format without column (older mypy versions) + // Format: file.py:line: error/warning/note: message [code] + // Use non-greedy match for filename and ensure the line number is followed + // directly by severity (not another number indicating column) + const simpleRegex = /^([^:]+\.py):(\d+):\s+(error|warning|note):\s*(.+?)(?:\s+\[([^\]]+)\])?$/gm + let simpleMatch: RegExpExecArray | null + while ((simpleMatch = simpleRegex.exec(output)) !== null) { + const file = simpleMatch[1] + const line = parseInt(simpleMatch[2]) + const severity = simpleMatch[3] + const message = simpleMatch[4] + const code = simpleMatch[5] + + // Still check for duplicates in case both regexes somehow match + const alreadyParsed = diagnostics.some( + (d) => d.file === file && d.line === line + ) + if (!alreadyParsed) { + const severityMap: Record<string, Severity> = { + error: "error", + warning: "warning", + note: "info", + } + + diagnostics.push({ + file, + line, + severity: severityMap[severity] || "error", + message, + code, + source: "mypy", + }) + } + } + + return diagnostics +} + +/** + * Check if mypy config exists + */ +async function hasConfig(directory: string): Promise<boolean> { + const configFiles = ["mypy.ini", "pyproject.toml", ".mypy.ini", "setup.cfg"] + + for (const file of configFiles) { + try { + await stat(join(directory, file)) + return true + } catch { + // File doesn't exist + } + } + return false +} + +export const MypyGate: Gate = { + info: { + id: "mypy", + name: "Mypy", + description: "Python static type checker", + critical: false, + }, + + async isAvailable(workcell: WorkcellInfo): Promise<boolean> { + // Check for mypy command + const which = await $`which mypy`.quiet().nothrow() + if (which.exitCode !== 0) { + return false + } + + // Check for Python files in workcell + const findPy = await $`find ${workcell.directory} -name "*.py" -not -path "*/.venv/*" -not -path "*/venv/*" 2>/dev/null | head -1` + .quiet() + .nothrow() + + return findPy.stdout.toString().trim().length > 0 + }, + + async run( + workcell: WorkcellInfo, + signal: AbortSignal + ): Promise<GateResult> { + const startedAt = Date.now() + + // Resolve working directory + const cwd = DEFAULT_CONFIG.cwd + ? join(workcell.directory, DEFAULT_CONFIG.cwd) + : workcell.directory + + try { + // Check for mypy config to determine args + const configExists = await hasConfig(cwd) + const args = configExists + ? [...DEFAULT_CONFIG.args, "."] + : [...DEFAULT_CONFIG.args, "--ignore-missing-imports", "."] + + // Run mypy + const proc = Bun.spawn(["mypy", ...args], { + cwd, + env: { + ...process.env, + PYTHONDONTWRITEBYTECODE: "1", + CLAWDSTRIKE_SANDBOX: "1", + }, + stdout: "pipe", + stderr: "pipe", + }) + + // Handle abort signal + const abortHandler = () => proc.kill() + signal.addEventListener("abort", abortHandler) + + // Set up timeout + const timeoutId = setTimeout(() => { + proc.kill() + }, DEFAULT_CONFIG.timeout) + + const [stdout, stderr] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]) + const exitCode = await proc.exited + + clearTimeout(timeoutId) + signal.removeEventListener("abort", abortHandler) + + const output = stdout + stderr + const completedAt = Date.now() + + if (signal.aborted) { + return { + gate: this.info.id, + passed: false, + critical: this.info.critical, + output: "Gate cancelled", + timing: { startedAt, completedAt }, + } + } + + return { + gate: this.info.id, + passed: exitCode === 0, + critical: this.info.critical, + output, + diagnostics: parseDiagnostics(output), + timing: { startedAt, completedAt }, + } + } catch (error) { + const completedAt = Date.now() + return { + gate: this.info.id, + passed: false, + critical: this.info.critical, + output: error instanceof Error ? error.message : String(error), + timing: { startedAt, completedAt }, + } + } + }, +} + +export default MypyGate diff --git a/apps/terminal/src/verifier/gates/pytest.ts b/apps/terminal/src/verifier/gates/pytest.ts new file mode 100644 index 000000000..c1e29c8cc --- /dev/null +++ b/apps/terminal/src/verifier/gates/pytest.ts @@ -0,0 +1,168 @@ +/** + * Pytest Gate - Python test runner + * + * Runs Python tests with pytest and parses output for diagnostics. + */ + +import { $ } from "bun" +import { join } from "path" +import type { Gate } from "../index" +import type { GateResult, WorkcellInfo, Diagnostic } from "../../types" + +/** + * Default pytest configuration + */ +export const DEFAULT_CONFIG = { + timeout: 300000, // 5 minutes + args: ["-v", "--tb=short", "-q"], + cwd: undefined as string | undefined, + filePatterns: ["**/*.py", "**/test_*.py", "**/*_test.py"], +} + +/** + * Parse pytest output for diagnostics + */ +export function parseDiagnostics(output: string): Diagnostic[] { + const diagnostics: Diagnostic[] = [] + + // Parse FAILED lines: FAILED tests/test_foo.py::test_bar - AssertionError + const failedRegex = /FAILED\s+([^:]+)::(\w+)(?:\s+-\s+(.+))?/gm + let match + while ((match = failedRegex.exec(output)) !== null) { + diagnostics.push({ + file: match[1], + severity: "error", + message: match[3] || `Test ${match[2]} failed`, + source: "pytest", + }) + } + + // Parse ERROR lines: ERROR tests/conftest.py - SyntaxError + // or: ERROR tests/test_foo.py::test_bar - ModuleNotFoundError + const errorRegex = /ERROR\s+([^\s:]+(?:\.[^\s:]+)*)(?:::(\w+))?(?:\s+-\s+(.+))?/gm + while ((match = errorRegex.exec(output)) !== null) { + diagnostics.push({ + file: match[1], + severity: "error", + message: match[3] || `Error in ${match[2] || "collection"}`, + source: "pytest", + }) + } + + // Parse file:line: error format (assertion errors, etc.) + const lineErrorRegex = /^([^\s:]+\.py):(\d+):\s*(\w+(?:Error|Exception)?):?\s*(.*)$/gm + while ((match = lineErrorRegex.exec(output)) !== null) { + diagnostics.push({ + file: match[1], + line: parseInt(match[2]), + severity: "error", + message: `${match[3]}${match[4] ? ": " + match[4] : ""}`, + source: "pytest", + }) + } + + return diagnostics +} + +export const PytestGate: Gate = { + info: { + id: "pytest", + name: "Pytest", + description: "Run Python tests with pytest", + critical: true, + }, + + async isAvailable(workcell: WorkcellInfo): Promise<boolean> { + // Check for pytest command + const which = await $`which pytest`.quiet().nothrow() + if (which.exitCode !== 0) { + return false + } + + // Check for test files in workcell + const findTests = await $`find ${workcell.directory} -name "test_*.py" -o -name "*_test.py" 2>/dev/null | head -1` + .quiet() + .nothrow() + + return findTests.stdout.toString().trim().length > 0 + }, + + async run( + workcell: WorkcellInfo, + signal: AbortSignal + ): Promise<GateResult> { + const startedAt = Date.now() + + // Resolve working directory + const cwd = DEFAULT_CONFIG.cwd + ? join(workcell.directory, DEFAULT_CONFIG.cwd) + : workcell.directory + + const args = DEFAULT_CONFIG.args + + try { + // Run pytest + const proc = Bun.spawn(["pytest", ...args], { + cwd, + env: { + ...process.env, + PYTHONDONTWRITEBYTECODE: "1", + CLAWDSTRIKE_SANDBOX: "1", + }, + stdout: "pipe", + stderr: "pipe", + }) + + // Handle abort signal + const abortHandler = () => proc.kill() + signal.addEventListener("abort", abortHandler) + + // Set up timeout + const timeoutId = setTimeout(() => { + proc.kill() + }, DEFAULT_CONFIG.timeout) + + const [stdout, stderr] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]) + const exitCode = await proc.exited + + clearTimeout(timeoutId) + signal.removeEventListener("abort", abortHandler) + + const output = stdout + stderr + const completedAt = Date.now() + + if (signal.aborted) { + return { + gate: this.info.id, + passed: false, + critical: this.info.critical, + output: "Gate cancelled", + timing: { startedAt, completedAt }, + } + } + + return { + gate: this.info.id, + passed: exitCode === 0, + critical: this.info.critical, + output, + diagnostics: parseDiagnostics(output), + timing: { startedAt, completedAt }, + } + } catch (error) { + const completedAt = Date.now() + return { + gate: this.info.id, + passed: false, + critical: this.info.critical, + output: error instanceof Error ? error.message : String(error), + timing: { startedAt, completedAt }, + } + } + }, +} + +export default PytestGate diff --git a/apps/terminal/src/verifier/gates/ruff.ts b/apps/terminal/src/verifier/gates/ruff.ts new file mode 100644 index 000000000..f925ec798 --- /dev/null +++ b/apps/terminal/src/verifier/gates/ruff.ts @@ -0,0 +1,219 @@ +/** + * Ruff Gate - Python linter and formatter + * + * Runs ruff check and format verification on Python code. + * Outputs JSON format for easy diagnostic parsing. + */ + +import { $ } from "bun" +import { join } from "path" +import type { Gate } from "../index" +import type { GateResult, WorkcellInfo, Diagnostic, Severity } from "../../types" + +/** + * Default ruff configuration + */ +export const DEFAULT_CONFIG = { + timeout: 60000, // 1 minute + checkArgs: ["check", "--output-format=json", "."], + formatArgs: ["format", "--check", "."], + cwd: undefined as string | undefined, + filePatterns: ["**/*.py"], +} + +/** + * Ruff JSON diagnostic format + */ +interface RuffDiagnostic { + code: string + message: string + filename: string + location: { + row: number + column: number + } + end_location?: { + row: number + column: number + } + fix?: { + applicability: string + message: string + edits: unknown[] + } + url?: string +} + +/** + * Parse ruff JSON output for diagnostics + */ +export function parseDiagnostics(output: string): Diagnostic[] { + try { + const issues = JSON.parse(output) as RuffDiagnostic[] + return issues.map((issue) => ({ + file: issue.filename, + line: issue.location.row, + column: issue.location.column, + severity: (issue.fix ? "warning" : "error") as Severity, + message: issue.message, + code: issue.code, + source: "ruff", + })) + } catch { + // Fallback: parse text output + const diagnostics: Diagnostic[] = [] + const lineRegex = /^(.+):(\d+):(\d+):\s*(\w+)\s+(.+)$/gm + let match + while ((match = lineRegex.exec(output)) !== null) { + diagnostics.push({ + file: match[1], + line: parseInt(match[2]), + column: parseInt(match[3]), + severity: "warning", + message: match[5], + code: match[4], + source: "ruff", + }) + } + return diagnostics + } +} + +export const RuffGate: Gate = { + info: { + id: "ruff", + name: "Ruff", + description: "Python linter and formatter", + critical: false, + }, + + async isAvailable(_workcell: WorkcellInfo): Promise<boolean> { + // Check for ruff command (available if installed, regardless of workcell contents) + const which = await $`which ruff`.quiet().nothrow() + return which.exitCode === 0 + }, + + async run( + workcell: WorkcellInfo, + signal: AbortSignal + ): Promise<GateResult> { + const startedAt = Date.now() + + // Resolve working directory + const cwd = DEFAULT_CONFIG.cwd + ? join(workcell.directory, DEFAULT_CONFIG.cwd) + : workcell.directory + + try { + // Run ruff check + const checkProc = Bun.spawn(["ruff", ...DEFAULT_CONFIG.checkArgs], { + cwd, + env: { + ...process.env, + CLAWDSTRIKE_SANDBOX: "1", + }, + stdout: "pipe", + stderr: "pipe", + }) + + // Handle abort signal + const abortHandler = () => { + checkProc.kill() + } + signal.addEventListener("abort", abortHandler) + + // Set up timeout + const timeoutId = setTimeout(() => { + checkProc.kill() + }, DEFAULT_CONFIG.timeout) + + const [checkStdout, checkStderr] = await Promise.all([ + new Response(checkProc.stdout).text(), + new Response(checkProc.stderr).text(), + ]) + const checkExitCode = await checkProc.exited + + clearTimeout(timeoutId) + signal.removeEventListener("abort", abortHandler) + + if (signal.aborted) { + return { + gate: this.info.id, + passed: false, + critical: this.info.critical, + output: "Gate cancelled", + timing: { startedAt, completedAt: Date.now() }, + } + } + + // Run ruff format --check + const formatProc = Bun.spawn(["ruff", ...DEFAULT_CONFIG.formatArgs], { + cwd, + env: { + ...process.env, + CLAWDSTRIKE_SANDBOX: "1", + }, + stdout: "pipe", + stderr: "pipe", + }) + + const formatTimeoutId = setTimeout(() => { + formatProc.kill() + }, DEFAULT_CONFIG.timeout) + + const [formatStdout, formatStderr] = await Promise.all([ + new Response(formatProc.stdout).text(), + new Response(formatProc.stderr).text(), + ]) + const formatExitCode = await formatProc.exited + + clearTimeout(formatTimeoutId) + + const completedAt = Date.now() + + // Combine outputs + const checkOutput = checkStdout + checkStderr + const formatOutput = formatStdout + formatStderr + const output = `=== ruff check ===\n${checkOutput}\n=== ruff format --check ===\n${formatOutput}` + + // Parse diagnostics from check output (JSON format) + const diagnostics = parseDiagnostics(checkStdout) + + // Add format issues as diagnostics + if (formatExitCode !== 0 && formatOutput.trim()) { + // Parse files that would be reformatted + const reformatRegex = /^Would reformat:\s*(.+)$/gm + let match + while ((match = reformatRegex.exec(formatOutput)) !== null) { + diagnostics.push({ + file: match[1], + severity: "warning", + message: "File would be reformatted", + code: "format", + source: "ruff", + }) + } + } + + return { + gate: this.info.id, + passed: checkExitCode === 0 && formatExitCode === 0, + critical: this.info.critical, + output, + diagnostics, + timing: { startedAt, completedAt }, + } + } catch (error) { + const completedAt = Date.now() + return { + gate: this.info.id, + passed: false, + critical: this.info.critical, + output: error instanceof Error ? error.message : String(error), + timing: { startedAt, completedAt }, + } + } + }, +} + +export default RuffGate diff --git a/apps/terminal/src/verifier/index.ts b/apps/terminal/src/verifier/index.ts new file mode 100644 index 000000000..d22bb5306 --- /dev/null +++ b/apps/terminal/src/verifier/index.ts @@ -0,0 +1,278 @@ +/** + * Verifier - Quality gate orchestrator + * + * Runs quality checks (pytest, mypy, ruff, etc.) on patches. + * Implements fail-fast semantics and score calculation. + */ + +import type { GateResult, GateResults, WorkcellInfo, Diagnostic } from "../types" +import { PytestGate } from "./gates/pytest" +import { MypyGate } from "./gates/mypy" +import { RuffGate } from "./gates/ruff" +import { ClawdStrikeGate } from "./gates/clawdstrike" + +export interface GateConfig { + name: string + command: string + critical: boolean + timeout?: number + parseOutput?: (output: string) => Diagnostic[] +} + +export interface Gate { + info: { + id: string + name: string + description: string + critical: boolean + } + isAvailable(workcell: WorkcellInfo): Promise<boolean> + run(workcell: WorkcellInfo, signal: AbortSignal): Promise<GateResult> +} + +export interface GateInfo { + id: string + name: string + description: string + critical: boolean +} + +export interface VerifyOptions { + gates: string[] + failFast?: boolean + timeout?: number +} + +// Gate registry - stores all available gates +const gateRegistry = new Map<string, Gate>() + +// Initialize with built-in gates +function initializeBuiltinGates(): void { + if (gateRegistry.size === 0) { + gateRegistry.set("pytest", PytestGate) + gateRegistry.set("mypy", MypyGate) + gateRegistry.set("ruff", RuffGate) + gateRegistry.set("clawdstrike", ClawdStrikeGate) + } +} + +// Ensure gates are initialized +initializeBuiltinGates() + +/** + * Verifier namespace - Quality gate operations + */ +export namespace Verifier { + /** + * Run all specified gates on workcell + */ + export async function run( + workcell: WorkcellInfo, + options: VerifyOptions + ): Promise<GateResults> { + const { gates, failFast = true, timeout = 300000 } = options + const results: GateResult[] = [] + + // Create abort controller for overall timeout + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), timeout) + + try { + for (const gateName of gates) { + if (controller.signal.aborted) { + break + } + + const result = await runGate(workcell, gateName, controller.signal) + results.push(result) + + // Fail-fast: stop on critical gate failure + if (failFast && !result.passed && result.critical) { + break + } + } + } finally { + clearTimeout(timeoutId) + } + + // Calculate aggregated results + const allPassed = results.every((r) => r.passed) + const criticalPassed = results + .filter((r) => r.critical) + .every((r) => r.passed) + const score = calculateScore(results) + + const gateResults: GateResults = { + allPassed, + criticalPassed, + results, + score, + summary: "", + } + + // Generate summary + gateResults.summary = generateSummary(gateResults) + + return gateResults + } + + /** + * Run a single gate + */ + export async function runGate( + workcell: WorkcellInfo, + gateName: string, + signal?: AbortSignal + ): Promise<GateResult> { + initializeBuiltinGates() + + const gate = gateRegistry.get(gateName) + if (!gate) { + return { + gate: gateName, + passed: false, + critical: false, + output: `Gate "${gateName}" not found`, + timing: { startedAt: Date.now(), completedAt: Date.now() }, + } + } + + // Check if gate is available + const isAvailable = await gate.isAvailable(workcell) + if (!isAvailable) { + return { + gate: gate.info.id, + passed: true, // Skip unavailable gates (pass by default) + critical: gate.info.critical, + output: `Gate "${gate.info.name}" not available (skipped)`, + timing: { startedAt: Date.now(), completedAt: Date.now() }, + } + } + + // Run the gate + const abortSignal = signal || new AbortController().signal + return gate.run(workcell, abortSignal) + } + + /** + * List all registered gates + */ + export function listGates(): GateInfo[] { + initializeBuiltinGates() + return Array.from(gateRegistry.values()).map((gate) => ({ + id: gate.info.id, + name: gate.info.name, + description: gate.info.description, + critical: gate.info.critical, + })) + } + + /** + * Get available gates (alias for backwards compatibility) + */ + export function getAvailableGates(): Gate[] { + initializeBuiltinGates() + return Array.from(gateRegistry.values()) + } + + /** + * Register a custom gate + */ + export function registerGate(gate: Gate): void { + gateRegistry.set(gate.info.id, gate) + } + + /** + * Unregister a gate (useful for testing) + */ + export function unregisterGate(gateId: string): boolean { + return gateRegistry.delete(gateId) + } + + /** + * Get a specific gate by ID + */ + export function getGate(gateId: string): Gate | undefined { + initializeBuiltinGates() + return gateRegistry.get(gateId) + } + + /** + * Calculate score from gate results + * + * Score = 100 - (errors * 10) - (warnings * 2) + * Minimum score is 0 + */ + export function calculateScore(results: GateResult[]): number { + let score = 100 + + for (const result of results) { + if (!result.diagnostics) continue + + for (const diag of result.diagnostics) { + if (diag.severity === "error") { + score -= 10 + } else if (diag.severity === "warning") { + score -= 2 + } + // info severity doesn't affect score + } + } + + // Also penalize failed gates without diagnostics + for (const result of results) { + if (!result.passed && (!result.diagnostics || result.diagnostics.length === 0)) { + score -= result.critical ? 50 : 20 + } + } + + return Math.max(0, score) + } + + /** + * Generate summary from gate results + */ + export function generateSummary(results: GateResults): string { + const lines: string[] = [] + + // Overall status + if (results.allPassed) { + lines.push("✓ All gates passed") + } else if (results.criticalPassed) { + lines.push("⚠ Some gates failed (non-critical)") + } else { + lines.push("✗ Critical gate(s) failed") + } + + lines.push(`Score: ${results.score}/100`) + lines.push("") + + // Per-gate summary + for (const result of results.results) { + const status = result.passed ? "✓" : "✗" + const critical = result.critical ? " (critical)" : "" + const duration = result.timing.completedAt - result.timing.startedAt + lines.push(`${status} ${result.gate}${critical} [${duration}ms]`) + + // Show diagnostic counts + if (result.diagnostics && result.diagnostics.length > 0) { + const errors = result.diagnostics.filter((d) => d.severity === "error").length + const warnings = result.diagnostics.filter((d) => d.severity === "warning").length + const infos = result.diagnostics.filter((d) => d.severity === "info").length + + const counts: string[] = [] + if (errors > 0) counts.push(`${errors} error${errors > 1 ? "s" : ""}`) + if (warnings > 0) counts.push(`${warnings} warning${warnings > 1 ? "s" : ""}`) + if (infos > 0) counts.push(`${infos} info`) + + if (counts.length > 0) { + lines.push(` ${counts.join(", ")}`) + } + } + } + + return lines.join("\n") + } +} + +export default Verifier diff --git a/apps/terminal/src/workcell/git.ts b/apps/terminal/src/workcell/git.ts new file mode 100644 index 000000000..f3a01c234 --- /dev/null +++ b/apps/terminal/src/workcell/git.ts @@ -0,0 +1,250 @@ +/** + * Git operations for workcell management + * + * Handles git worktree creation, cleanup, and branch management. + */ + +import { $ } from "bun" +import { join, dirname } from "path" +import { mkdir, rm, stat } from "fs/promises" + +export interface WorktreeInfo { + path: string + branch: string + commit: string +} + +/** + * Get the git root directory for the current working directory + */ +export async function getGitRoot(cwd: string): Promise<string> { + const result = await $`git -C ${cwd} rev-parse --show-toplevel`.quiet().nothrow() + if (result.exitCode !== 0) { + throw new Error(`Not a git repository: ${cwd}`) + } + return result.text().trim() +} + +/** + * Get the current branch name + */ +export async function getCurrentBranch(cwd: string): Promise<string> { + const result = await $`git -C ${cwd} rev-parse --abbrev-ref HEAD`.quiet().nothrow() + if (result.exitCode !== 0) { + throw new Error(`Failed to get current branch: ${result.stderr.toString()}`) + } + return result.text().trim() +} + +/** + * Get the current commit hash + */ +export async function getCurrentCommit(cwd: string): Promise<string> { + const result = await $`git -C ${cwd} rev-parse HEAD`.quiet().nothrow() + if (result.exitCode !== 0) { + throw new Error(`Failed to get current commit: ${result.stderr.toString()}`) + } + return result.text().trim() +} + +/** + * List existing worktrees + */ +export async function listWorktrees(gitRoot: string): Promise<WorktreeInfo[]> { + const result = await $`git -C ${gitRoot} worktree list --porcelain`.quiet().nothrow() + if (result.exitCode !== 0) { + return [] + } + + const output = result.text() + const worktrees: WorktreeInfo[] = [] + let current: Partial<WorktreeInfo> = {} + + for (const line of output.split("\n")) { + if (line.startsWith("worktree ")) { + if (current.path) { + worktrees.push(current as WorktreeInfo) + } + current = { path: line.substring(9) } + } else if (line.startsWith("HEAD ")) { + current.commit = line.substring(5) + } else if (line.startsWith("branch ")) { + // Format: refs/heads/branch-name + current.branch = line.substring(7).replace("refs/heads/", "") + } else if (line === "detached") { + current.branch = "HEAD" + } + } + + if (current.path) { + worktrees.push(current as WorktreeInfo) + } + + return worktrees +} + +/** + * Create a new worktree + */ +export async function createWorktree( + gitRoot: string, + worktreePath: string, + options: { + branch?: string + newBranch?: string + commit?: string + detach?: boolean + } = {} +): Promise<WorktreeInfo> { + // Ensure parent directory exists + await mkdir(dirname(worktreePath), { recursive: true }) + + const args: string[] = ["worktree", "add"] + + if (options.detach) { + args.push("--detach") + } + + if (options.newBranch) { + args.push("-b", options.newBranch) + } + + args.push(worktreePath) + + if (options.commit) { + args.push(options.commit) + } else if (options.branch) { + args.push(options.branch) + } + + const result = await $`git -C ${gitRoot} ${args}`.quiet().nothrow() + if (result.exitCode !== 0) { + throw new Error(`Failed to create worktree: ${result.stderr.toString()}`) + } + + // Get the actual branch/commit info + const branch = options.newBranch || options.branch || await getCurrentBranch(worktreePath) + const commit = await getCurrentCommit(worktreePath) + + return { + path: worktreePath, + branch, + commit, + } +} + +/** + * Remove a worktree + */ +export async function removeWorktree( + gitRoot: string, + worktreePath: string, + options: { force?: boolean } = {} +): Promise<void> { + const args = ["worktree", "remove"] + if (options.force) { + args.push("--force") + } + args.push(worktreePath) + + const result = await $`git -C ${gitRoot} ${args}`.quiet().nothrow() + + // If worktree remove failed, try to clean up manually + if (result.exitCode !== 0) { + // Check if directory exists + try { + await stat(worktreePath) + // Directory exists, try force removal + await rm(worktreePath, { recursive: true, force: true }) + // Also prune stale worktree references + await $`git -C ${gitRoot} worktree prune`.quiet().nothrow() + } catch { + // Directory doesn't exist, just prune + await $`git -C ${gitRoot} worktree prune`.quiet().nothrow() + } + } +} + +/** + * Reset a worktree to a clean state + */ +export async function resetWorktree(worktreePath: string): Promise<void> { + // Hard reset to HEAD + await $`git -C ${worktreePath} reset --hard HEAD`.quiet().nothrow() + + // Clean untracked files + await $`git -C ${worktreePath} clean -fd`.quiet().nothrow() +} + +/** + * Check if a path is inside a worktree + */ +export async function isWorktree(path: string): Promise<boolean> { + const result = await $`git -C ${path} rev-parse --is-inside-work-tree`.quiet().nothrow() + return result.exitCode === 0 && result.text().trim() === "true" +} + +/** + * Get diff of changes in worktree + */ +export async function getWorktreeDiff(worktreePath: string): Promise<string> { + // Include both staged and unstaged changes + const result = await $`git -C ${worktreePath} diff HEAD`.quiet().nothrow() + return result.text() +} + +/** + * Check if worktree has uncommitted changes + */ +export async function hasChanges(worktreePath: string): Promise<boolean> { + const result = await $`git -C ${worktreePath} status --porcelain`.quiet().nothrow() + return result.text().trim().length > 0 +} + +/** + * Stage all changes in worktree + */ +export async function stageAll(worktreePath: string): Promise<void> { + await $`git -C ${worktreePath} add -A`.quiet().nothrow() +} + +/** + * Create a commit in worktree + */ +export async function commit( + worktreePath: string, + message: string +): Promise<string> { + await stageAll(worktreePath) + + const result = await $`git -C ${worktreePath} commit -m ${message}`.quiet().nothrow() + if (result.exitCode !== 0) { + throw new Error(`Failed to commit: ${result.stderr.toString()}`) + } + + return getCurrentCommit(worktreePath) +} + +/** + * Get list of changed files in worktree + */ +export async function getChangedFiles(worktreePath: string): Promise<string[]> { + const result = await $`git -C ${worktreePath} diff --name-only HEAD`.quiet().nothrow() + const files = result.text().trim() + if (!files) return [] + return files.split("\n").filter(Boolean) +} + +/** + * Generate unique worktree branch name + */ +export function generateWorktreeBranch(prefix: string, id: string): string { + return `clawdstrike/${prefix}/${id}` +} + +/** + * Get clawdstrike workcells directory path + */ +export function getWorkcellsDir(gitRoot: string): string { + return join(gitRoot, ".clawdstrike", "workcells") +} diff --git a/apps/terminal/src/workcell/index.ts b/apps/terminal/src/workcell/index.ts new file mode 100644 index 000000000..a8f0b1064 --- /dev/null +++ b/apps/terminal/src/workcell/index.ts @@ -0,0 +1,316 @@ +/** + * Workcell - Isolated execution environment manager + * + * Manages git worktree-based isolation for task execution. + * Handles pool lifecycle: create, acquire, release, destroy. + */ + +import { z } from "zod" +import { join } from "path" +import { mkdir } from "fs/promises" +import type { WorkcellInfo, Toolchain, SandboxMode } from "../types" +import * as pool from "./pool" +import * as lifecycle from "./lifecycle" +import * as git from "./git" + +// Re-export git utilities for external use +export { git } + +/** + * Pool configuration schema + */ +export const PoolConfig = z.object({ + minSize: z.number().int().min(0).max(10).default(2), + maxSize: z.number().int().min(1).max(50).default(10), + ttl: z.number().int().positive().default(3600000), // 1 hour + preWarm: z.boolean().default(true), + cleanupInterval: z.number().int().positive().default(300000), // 5 min +}) + +export type PoolConfig = z.infer<typeof PoolConfig> + +export interface PoolStatus { + projectId: string + total: number + warm: number + inUse: number + config: PoolConfig +} + +export interface GCResult { + destroyed: number + remaining: number + duration: number +} + +/** + * Default pool configuration + */ +const DEFAULT_CONFIG: PoolConfig = { + minSize: 2, + maxSize: 10, + ttl: 3600000, + preWarm: true, + cleanupInterval: 300000, +} + +/** + * Cached git root per project + */ +const gitRoots: Map<string, string> = new Map() + +/** + * Get git root for a project (with caching) + */ +async function getGitRoot(projectId: string, cwd?: string): Promise<string> { + let gitRoot = gitRoots.get(projectId) + if (gitRoot) return gitRoot + + // Use provided cwd or current directory + const directory = cwd || process.cwd() + gitRoot = await git.getGitRoot(directory) + gitRoots.set(projectId, gitRoot) + return gitRoot +} + +/** + * Workcell namespace - Workcell lifecycle operations + */ +export namespace Workcell { + /** + * Initialize a project's workcell pool + */ + export async function init( + projectId: string, + options?: { + cwd?: string + config?: Partial<PoolConfig> + } + ): Promise<void> { + const gitRoot = await getGitRoot(projectId, options?.cwd) + const config = PoolConfig.parse({ ...DEFAULT_CONFIG, ...options?.config }) + + pool.initPool(projectId, gitRoot, config) + + // Restore any existing workcells + await lifecycle.restoreWorkcells(projectId, gitRoot, config) + + // Pre-warm if configured + if (config.preWarm) { + await lifecycle.preWarmPool(projectId, gitRoot, config) + } + } + + /** + * Acquire a workcell from the pool (or create new) + */ + export async function acquire( + projectId: string, + toolchain?: Toolchain, + options?: { cwd?: string; sandboxMode?: SandboxMode } + ): Promise<WorkcellInfo> { + const sandboxMode = options?.sandboxMode ?? "worktree" + const cwd = options?.cwd ?? process.cwd() + + // Inplace mode: return synthetic workcell pointing at cwd + if (sandboxMode === "inplace") { + return { + id: crypto.randomUUID(), + name: "inplace", + directory: cwd, + branch: "HEAD", + status: "in_use", + toolchain, + projectId, + createdAt: Date.now(), + useCount: 1, + } + } + + // Tmpdir mode: create a temp directory under .clawdstrike/tmp/ + if (sandboxMode === "tmpdir") { + const id = crypto.randomUUID() + const shortId = id.slice(0, 8) + const tmpDir = join(cwd, ".clawdstrike", "tmp", `wc-${shortId}`) + await mkdir(tmpDir, { recursive: true }) + return { + id, + name: `wc-${shortId}`, + directory: tmpDir, + branch: "HEAD", + status: "in_use", + toolchain, + projectId, + createdAt: Date.now(), + useCount: 1, + } + } + + // Worktree mode: existing behavior + const gitRoot = await getGitRoot(projectId, cwd) + const config = pool.getPoolConfig(projectId) + + // Ensure pool is initialized + if (!pool.getPool(projectId)) { + pool.initPool(projectId, gitRoot, config) + } + + return lifecycle.acquireWorkcell(projectId, gitRoot, config, toolchain) + } + + /** + * Release workcell back to pool (or destroy) + * No-op for inplace/tmpdir workcells that aren't pooled. + */ + export async function release( + workcellId: string, + options?: { keep?: boolean; reset?: boolean } + ): Promise<void> { + const workcell = pool.getWorkcell(workcellId) + if (!workcell) { + // Not pooled (inplace/tmpdir) — no-op + return + } + + const gitRoot = await getGitRoot(workcell.projectId) + await lifecycle.releaseWorkcell(workcellId, gitRoot, options) + } + + /** + * Get current pool status + */ + export function status(projectId: string): PoolStatus { + const poolState = pool.getPool(projectId) + const counts = pool.countByStatus(projectId) + + return { + projectId, + total: counts.total, + warm: counts.warm, + inUse: counts.inUse, + config: poolState?.config || DEFAULT_CONFIG, + } + } + + /** + * Run garbage collection + */ + export async function gc(projectId?: string): Promise<GCResult> { + if (projectId) { + const gitRoot = await getGitRoot(projectId) + return lifecycle.gcWorkcells(projectId, gitRoot) + } + + // GC all pools + let totalDestroyed = 0 + let totalRemaining = 0 + const startTime = Date.now() + + for (const id of pool.getPoolIds()) { + const gitRoot = await getGitRoot(id) + const result = await lifecycle.gcWorkcells(id, gitRoot) + totalDestroyed += result.destroyed + totalRemaining += result.remaining + } + + return { + destroyed: totalDestroyed, + remaining: totalRemaining, + duration: Date.now() - startTime, + } + } + + /** + * Destroy all workcells (for shutdown) + */ + export async function destroyAll(projectId?: string): Promise<void> { + if (projectId) { + const gitRoot = await getGitRoot(projectId) + await lifecycle.destroyAllWorkcells(projectId, gitRoot) + gitRoots.delete(projectId) + return + } + + // Destroy all pools + for (const id of pool.getPoolIds()) { + const gitRoot = await getGitRoot(id) + await lifecycle.destroyAllWorkcells(id, gitRoot) + } + + gitRoots.clear() + pool.clearAllPools() + } + + /** + * List all workcells + */ + export function list(projectId?: string): WorkcellInfo[] { + if (projectId) { + return pool.getProjectWorkcells(projectId) + } + return pool.getAllWorkcells() + } + + /** + * Get workcell by ID + */ + export function get(workcellId: string): WorkcellInfo | undefined { + return pool.getWorkcell(workcellId) + } + + /** + * Configure pool settings + */ + export function configure( + projectId: string, + config: Partial<PoolConfig> + ): void { + pool.updatePoolConfig(projectId, config) + } + + /** + * Check if workcell has uncommitted changes + */ + export async function hasChanges(workcellId: string): Promise<boolean> { + const workcell = pool.getWorkcell(workcellId) + if (!workcell) { + throw new Error(`Workcell not found: ${workcellId}`) + } + return git.hasChanges(workcell.directory) + } + + /** + * Get diff of changes in workcell + */ + export async function getDiff(workcellId: string): Promise<string> { + const workcell = pool.getWorkcell(workcellId) + if (!workcell) { + throw new Error(`Workcell not found: ${workcellId}`) + } + return git.getWorktreeDiff(workcell.directory) + } + + /** + * Get list of changed files in workcell + */ + export async function getChangedFiles(workcellId: string): Promise<string[]> { + const workcell = pool.getWorkcell(workcellId) + if (!workcell) { + throw new Error(`Workcell not found: ${workcellId}`) + } + return git.getChangedFiles(workcell.directory) + } + + /** + * Reset workcell to clean state + */ + export async function reset(workcellId: string): Promise<void> { + const workcell = pool.getWorkcell(workcellId) + if (!workcell) { + throw new Error(`Workcell not found: ${workcellId}`) + } + await git.resetWorktree(workcell.directory) + } +} + +export default Workcell diff --git a/apps/terminal/src/workcell/lifecycle.ts b/apps/terminal/src/workcell/lifecycle.ts new file mode 100644 index 000000000..ec2768188 --- /dev/null +++ b/apps/terminal/src/workcell/lifecycle.ts @@ -0,0 +1,361 @@ +/** + * Workcell Lifecycle Management + * + * Handles creation, cleanup, and state transitions for workcells. + */ + +import { randomUUID } from "crypto" +import { join } from "path" +import { mkdir, rm, writeFile, readFile } from "fs/promises" +import type { WorkcellInfo, Toolchain, WorkcellStatus } from "../types" +import type { PoolConfig } from "./index" +import * as git from "./git" +import * as pool from "./pool" + +/** + * Workcell metadata file name + */ +const METADATA_FILE = ".clawdstrike-workcell.json" + +/** + * Generate a unique workcell name + */ +function generateWorkcellName(index: number): string { + const timestamp = Date.now().toString(36) + return `wc-${timestamp}-${index}` +} + +/** + * Create a new workcell + */ +export async function createWorkcell( + projectId: string, + gitRoot: string, + options: { + toolchain?: Toolchain + branch?: string + } = {} +): Promise<WorkcellInfo> { + const workcellId = randomUUID() + const workcellsDir = git.getWorkcellsDir(gitRoot) + const index = pool.countByStatus(projectId).total + 1 + const name = generateWorkcellName(index) + const workcellPath = join(workcellsDir, name) + + // Get base branch + const baseBranch = options.branch || (await git.getCurrentBranch(gitRoot)) + + // Create workcell info (status: creating) + const workcell: WorkcellInfo = { + id: workcellId, + name, + directory: workcellPath, + branch: baseBranch, + status: "creating", + toolchain: options.toolchain, + projectId, + createdAt: Date.now(), + useCount: 0, + } + + // Add to pool immediately to track creation + pool.addToPool(workcell) + + try { + // Create worktree with detached HEAD at current commit + const commit = await git.getCurrentCommit(gitRoot) + await git.createWorktree(gitRoot, workcellPath, { + commit, + detach: true, + }) + + // Create .clawdstrike directory in workcell + const metaDir = join(workcellPath, ".clawdstrike") + await mkdir(metaDir, { recursive: true }) + + // Write metadata + await writeMetadata(workcellPath, workcell) + + // Update status to warm + workcell.status = "warm" + pool.updateWorkcell(workcell) + + return workcell + } catch (error) { + // Remove from pool on failure + pool.removeFromPool(workcellId) + + // Try to clean up the partially created worktree + try { + await git.removeWorktree(gitRoot, workcellPath, { force: true }) + } catch { + // Ignore cleanup errors + } + + throw error + } +} + +/** + * Write workcell metadata + */ +async function writeMetadata( + workcellPath: string, + workcell: WorkcellInfo +): Promise<void> { + const metadataPath = join(workcellPath, ".clawdstrike", METADATA_FILE) + await writeFile(metadataPath, JSON.stringify(workcell, null, 2)) +} + +/** + * Read workcell metadata + */ +export async function readMetadata( + workcellPath: string +): Promise<WorkcellInfo | null> { + const metadataPath = join(workcellPath, ".clawdstrike", METADATA_FILE) + try { + const content = await readFile(metadataPath, "utf-8") + return JSON.parse(content) as WorkcellInfo + } catch { + return null + } +} + +/** + * Acquire a workcell (get from pool or create new) + */ +export async function acquireWorkcell( + projectId: string, + gitRoot: string, + config: PoolConfig, + toolchain?: Toolchain +): Promise<WorkcellInfo> { + // Ensure pool is initialized + pool.initPool(projectId, gitRoot, config) + + // Try to find a warm workcell + let workcell = pool.findWarmWorkcell(projectId, toolchain) + + // If no warm workcell, create new one if allowed + if (!workcell) { + if (!pool.canCreateMore(projectId)) { + throw new Error( + `Pool limit reached for project ${projectId}. Max: ${config.maxSize}` + ) + } + workcell = await createWorkcell(projectId, gitRoot, { toolchain }) + } + + // Mark as in_use + workcell = { + ...workcell, + status: "in_use" as WorkcellStatus, + toolchain: toolchain || workcell.toolchain, + lastUsedAt: Date.now(), + useCount: workcell.useCount + 1, + } + + pool.updateWorkcell(workcell) + await writeMetadata(workcell.directory, workcell) + + return workcell +} + +/** + * Release a workcell back to pool + */ +export async function releaseWorkcell( + workcellId: string, + gitRoot: string, + options: { keep?: boolean; reset?: boolean } = {} +): Promise<void> { + const workcell = pool.getWorkcell(workcellId) + if (!workcell) { + throw new Error(`Workcell not found: ${workcellId}`) + } + + const poolState = pool.getPool(workcell.projectId) + if (!poolState) { + throw new Error(`Pool not found for project: ${workcell.projectId}`) + } + + // Check if we should destroy or return to pool + const counts = pool.countByStatus(workcell.projectId) + const shouldDestroy = + !options.keep && + counts.warm >= poolState.config.minSize && + counts.total > poolState.config.minSize + + if (shouldDestroy) { + await destroyWorkcell(workcellId, gitRoot) + return + } + + // Reset the workcell if requested (default: true) + if (options.reset !== false) { + workcell.status = "cleaning" + pool.updateWorkcell(workcell) + + try { + await git.resetWorktree(workcell.directory) + } catch (error) { + // If reset fails, destroy the workcell + await destroyWorkcell(workcellId, gitRoot) + throw error + } + } + + // Return to pool as warm + const updatedWorkcell: WorkcellInfo = { + ...workcell, + status: "warm", + lastUsedAt: Date.now(), + } + + pool.updateWorkcell(updatedWorkcell) + await writeMetadata(workcell.directory, updatedWorkcell) +} + +/** + * Destroy a workcell + */ +export async function destroyWorkcell( + workcellId: string, + gitRoot: string +): Promise<void> { + const workcell = pool.getWorkcell(workcellId) + if (!workcell) { + return // Already destroyed or doesn't exist + } + + // Update status + workcell.status = "destroyed" + pool.updateWorkcell(workcell) + + try { + // Remove git worktree + await git.removeWorktree(gitRoot, workcell.directory, { force: true }) + } catch { + // Try direct removal if git worktree remove fails + try { + await rm(workcell.directory, { recursive: true, force: true }) + } catch { + // Ignore + } + } + + // Remove from pool + pool.removeFromPool(workcellId) +} + +/** + * Destroy all workcells for a project + */ +export async function destroyAllWorkcells( + projectId: string, + gitRoot: string +): Promise<void> { + const workcells = pool.getProjectWorkcells(projectId) + + await Promise.all( + workcells.map((wc) => destroyWorkcell(wc.id, gitRoot).catch(() => {})) + ) + + pool.deletePool(projectId) +} + +/** + * Garbage collect expired workcells + */ +export async function gcWorkcells( + projectId: string, + gitRoot: string +): Promise<{ destroyed: number; remaining: number; duration: number }> { + const startTime = Date.now() + const expired = pool.getExpiredWorkcells(projectId) + + let destroyed = 0 + for (const workcell of expired) { + try { + await destroyWorkcell(workcell.id, gitRoot) + destroyed++ + } catch { + // Continue with next + } + } + + const remaining = pool.countByStatus(projectId).total + + return { + destroyed, + remaining, + duration: Date.now() - startTime, + } +} + +/** + * Pre-warm pool to minimum size + */ +export async function preWarmPool( + projectId: string, + gitRoot: string, + config: PoolConfig +): Promise<void> { + pool.initPool(projectId, gitRoot, config) + + const counts = pool.countByStatus(projectId) + const needed = config.minSize - counts.warm + + if (needed <= 0) return + + // Create workcells in parallel + const promises: Promise<WorkcellInfo>[] = [] + for (let i = 0; i < needed; i++) { + promises.push(createWorkcell(projectId, gitRoot)) + } + + await Promise.all(promises) +} + +/** + * Restore existing workcells from disk + */ +export async function restoreWorkcells( + projectId: string, + gitRoot: string, + config: PoolConfig +): Promise<void> { + // Initialize pool (ignore return value) + pool.initPool(projectId, gitRoot, config) + + // List existing worktrees + const worktrees = await git.listWorktrees(gitRoot) + const workcellsDir = git.getWorkcellsDir(gitRoot) + + for (const worktree of worktrees) { + // Check if this is a clawdstrike workcell + if (!worktree.path.startsWith(workcellsDir)) { + continue + } + + // Try to read metadata + const metadata = await readMetadata(worktree.path) + if (metadata && metadata.projectId === projectId) { + // Restore to pool as warm (reset it first) + try { + await git.resetWorktree(worktree.path) + metadata.status = "warm" + metadata.lastUsedAt = Date.now() + pool.addToPool(metadata) + } catch { + // If reset fails, try to remove + try { + await git.removeWorktree(gitRoot, worktree.path, { force: true }) + } catch { + // Ignore + } + } + } + } +} diff --git a/apps/terminal/src/workcell/pool.ts b/apps/terminal/src/workcell/pool.ts new file mode 100644 index 000000000..77af2edfd --- /dev/null +++ b/apps/terminal/src/workcell/pool.ts @@ -0,0 +1,303 @@ +/** + * Workcell Pool Management + * + * Manages pools of warm workcells per project for fast acquisition. + */ + +import type { WorkcellInfo, Toolchain } from "../types" +import type { PoolConfig } from "./index" + +/** + * Internal pool state for a project + */ +export interface PoolState { + projectId: string + gitRoot: string + config: PoolConfig + workcells: Map<string, WorkcellInfo> + cleanupTimer?: ReturnType<typeof setInterval> +} + +/** + * Global pool registry keyed by projectId + */ +const pools: Map<string, PoolState> = new Map() + +/** + * Get or create pool for a project + */ +export function getPool(projectId: string): PoolState | undefined { + return pools.get(projectId) +} + +/** + * Initialize a pool for a project + */ +export function initPool( + projectId: string, + gitRoot: string, + config: PoolConfig +): PoolState { + // Check if pool already exists + let pool = pools.get(projectId) + if (pool) { + // Update config if provided + pool.config = { ...pool.config, ...config } + return pool + } + + // Create new pool + pool = { + projectId, + gitRoot, + config, + workcells: new Map(), + } + + pools.set(projectId, pool) + return pool +} + +/** + * Get pool config with defaults + */ +export function getPoolConfig(projectId: string): PoolConfig { + const pool = pools.get(projectId) + if (pool) { + return pool.config + } + + // Return defaults + return { + minSize: 2, + maxSize: 10, + ttl: 3600000, + preWarm: true, + cleanupInterval: 300000, + } +} + +/** + * Update pool config + */ +export function updatePoolConfig( + projectId: string, + config: Partial<PoolConfig> +): void { + const pool = pools.get(projectId) + if (pool) { + pool.config = { ...pool.config, ...config } + } +} + +/** + * Add workcell to pool + */ +export function addToPool(workcell: WorkcellInfo): void { + const pool = pools.get(workcell.projectId) + if (pool) { + pool.workcells.set(workcell.id, workcell) + } +} + +/** + * Remove workcell from pool + */ +export function removeFromPool(workcellId: string): WorkcellInfo | undefined { + for (const pool of pools.values()) { + const workcell = pool.workcells.get(workcellId) + if (workcell) { + pool.workcells.delete(workcellId) + return workcell + } + } + return undefined +} + +/** + * Get workcell by ID from any pool + */ +export function getWorkcell(workcellId: string): WorkcellInfo | undefined { + for (const pool of pools.values()) { + const workcell = pool.workcells.get(workcellId) + if (workcell) { + return workcell + } + } + return undefined +} + +/** + * Update workcell in pool + */ +export function updateWorkcell(workcell: WorkcellInfo): void { + const pool = pools.get(workcell.projectId) + if (pool && pool.workcells.has(workcell.id)) { + pool.workcells.set(workcell.id, workcell) + } +} + +/** + * Find an available warm workcell in pool + */ +export function findWarmWorkcell( + projectId: string, + toolchain?: Toolchain +): WorkcellInfo | undefined { + const pool = pools.get(projectId) + if (!pool) return undefined + + for (const workcell of pool.workcells.values()) { + if (workcell.status === "warm") { + // If toolchain specified, prefer matching workcells + if (toolchain && workcell.toolchain === toolchain) { + return workcell + } + // If no toolchain or no match, return any warm workcell + if (!toolchain) { + return workcell + } + } + } + + // Second pass: return any warm workcell if toolchain didn't match + if (toolchain) { + for (const workcell of pool.workcells.values()) { + if (workcell.status === "warm") { + return workcell + } + } + } + + return undefined +} + +/** + * Count workcells in pool by status + */ +export function countByStatus( + projectId: string +): { total: number; warm: number; inUse: number; creating: number } { + const pool = pools.get(projectId) + if (!pool) { + return { total: 0, warm: 0, inUse: 0, creating: 0 } + } + + let warm = 0 + let inUse = 0 + let creating = 0 + + for (const workcell of pool.workcells.values()) { + switch (workcell.status) { + case "warm": + warm++ + break + case "in_use": + inUse++ + break + case "creating": + creating++ + break + } + } + + return { + total: pool.workcells.size, + warm, + inUse, + creating, + } +} + +/** + * Check if pool can create more workcells + */ +export function canCreateMore(projectId: string): boolean { + const pool = pools.get(projectId) + if (!pool) return true + + const counts = countByStatus(projectId) + return counts.total < pool.config.maxSize +} + +/** + * Get expired workcells (past TTL and warm) + */ +export function getExpiredWorkcells(projectId: string): WorkcellInfo[] { + const pool = pools.get(projectId) + if (!pool) return [] + + const now = Date.now() + const expired: WorkcellInfo[] = [] + + for (const workcell of pool.workcells.values()) { + if (workcell.status === "warm") { + const lastUsed = workcell.lastUsedAt || workcell.createdAt + if (now - lastUsed > pool.config.ttl) { + // Don't expire if we're at minSize + const counts = countByStatus(projectId) + if (counts.warm > pool.config.minSize) { + expired.push(workcell) + } + } + } + } + + return expired +} + +/** + * Get all workcells across all pools + */ +export function getAllWorkcells(): WorkcellInfo[] { + const all: WorkcellInfo[] = [] + for (const pool of pools.values()) { + for (const workcell of pool.workcells.values()) { + all.push(workcell) + } + } + return all +} + +/** + * Get all workcells for a project + */ +export function getProjectWorkcells(projectId: string): WorkcellInfo[] { + const pool = pools.get(projectId) + if (!pool) return [] + return Array.from(pool.workcells.values()) +} + +/** + * Clear all pools (for shutdown) + */ +export function clearAllPools(): void { + for (const pool of pools.values()) { + if (pool.cleanupTimer) { + clearInterval(pool.cleanupTimer) + } + pool.workcells.clear() + } + pools.clear() +} + +/** + * Get all pool IDs + */ +export function getPoolIds(): string[] { + return Array.from(pools.keys()) +} + +/** + * Delete a pool + */ +export function deletePool(projectId: string): void { + const pool = pools.get(projectId) + if (pool) { + if (pool.cleanupTimer) { + clearInterval(pool.cleanupTimer) + } + pool.workcells.clear() + pools.delete(projectId) + } +} diff --git a/apps/terminal/test/adapters.test.ts b/apps/terminal/test/adapters.test.ts new file mode 100644 index 000000000..f3e142dc0 --- /dev/null +++ b/apps/terminal/test/adapters.test.ts @@ -0,0 +1,274 @@ +/** + * Adapter integration tests + * + * Tests for CLI adapter implementations. + */ + +import { describe, test, expect } from "bun:test" +import { Dispatcher } from "../src/dispatcher" +import { CodexAdapter } from "../src/dispatcher/adapters/codex" +import { ClaudeAdapter } from "../src/dispatcher/adapters/claude" +import { OpenCodeAdapter } from "../src/dispatcher/adapters/opencode" +import { CrushAdapter } from "../src/dispatcher/adapters/crush" +import type { WorkcellInfo, TaskInput } from "../src/types" + +// Mock workcell for testing +const mockWorkcell: WorkcellInfo = { + id: "123e4567-e89b-12d3-a456-426614174000", + name: "wc-test", + directory: "/tmp/test-workcell", + branch: "wc-test", + status: "warm", + projectId: "test-project", + createdAt: Date.now(), + useCount: 0, +} + +// Mock task for testing +const mockTask: TaskInput = { + prompt: "Test prompt", + context: { + cwd: "/tmp/test-workcell", + projectId: "test-project", + branch: "main", + }, +} + +describe("Adapter info", () => { + test("CodexAdapter has correct info", () => { + expect(CodexAdapter.info.id).toBe("codex") + expect(CodexAdapter.info.authType).toBe("oauth") + expect(CodexAdapter.info.requiresInstall).toBe(true) + }) + + test("ClaudeAdapter has correct info", () => { + expect(ClaudeAdapter.info.id).toBe("claude") + expect(ClaudeAdapter.info.authType).toBe("oauth") + expect(ClaudeAdapter.info.requiresInstall).toBe(true) + }) + + test("OpenCodeAdapter has correct info", () => { + expect(OpenCodeAdapter.info.id).toBe("opencode") + expect(OpenCodeAdapter.info.authType).toBe("api_key") + expect(OpenCodeAdapter.info.requiresInstall).toBe(false) + }) + + test("CrushAdapter has correct info", () => { + expect(CrushAdapter.info.id).toBe("crush") + expect(CrushAdapter.info.authType).toBe("api_key") + expect(CrushAdapter.info.requiresInstall).toBe(true) + }) +}) + +describe("Dispatcher adapter registry", () => { + test("getAdapter returns correct adapter for each toolchain", () => { + expect(Dispatcher.getAdapter("codex")?.info.id).toBe("codex") + expect(Dispatcher.getAdapter("claude")?.info.id).toBe("claude") + expect(Dispatcher.getAdapter("opencode")?.info.id).toBe("opencode") + expect(Dispatcher.getAdapter("crush")?.info.id).toBe("crush") + }) + + test("getAllAdapters returns all adapters", () => { + const adapters = Dispatcher.getAllAdapters() + expect(adapters.length).toBe(4) + expect(adapters.map((a) => a.info.id)).toEqual( + expect.arrayContaining(["codex", "claude", "opencode", "crush"]) + ) + }) +}) + +describe("Adapter availability", () => { + // These tests verify the availability check logic works + // Some may be slow if CLI tools exist but need to check auth status + + test("CodexAdapter.isAvailable returns boolean", async () => { + const result = await Promise.race([ + CodexAdapter.isAvailable(), + new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 2000)), + ]) + expect(typeof result).toBe("boolean") + }) + + test("ClaudeAdapter.isAvailable returns boolean", async () => { + // Skip slow auth check by just testing the type + const result = await Promise.race([ + ClaudeAdapter.isAvailable(), + new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 2000)), + ]) + expect(typeof result).toBe("boolean") + }) + + test("OpenCodeAdapter.isAvailable returns boolean", async () => { + const result = await OpenCodeAdapter.isAvailable() + expect(typeof result).toBe("boolean") + }) + + test("CrushAdapter.isAvailable returns boolean", async () => { + const result = await CrushAdapter.isAvailable() + expect(typeof result).toBe("boolean") + }) +}) + +describe("Adapter telemetry parsing", () => { + test("CodexAdapter parses telemetry from JSON output", () => { + const output = JSON.stringify({ + model: "gpt-4o", + usage: { + prompt_tokens: 100, + completion_tokens: 50, + }, + cost: 0.01, + }) + + const telemetry = CodexAdapter.parseTelemetry(output) + expect(telemetry!.model).toBe("gpt-4o") + expect(telemetry!.tokens?.input).toBe(100) + expect(telemetry!.tokens?.output).toBe(50) + expect(telemetry!.cost).toBe(0.01) + }) + + test("ClaudeAdapter parses telemetry from JSON output", () => { + const output = JSON.stringify({ + model: "claude-3-opus-20240229", + usage: { + input_tokens: 200, + output_tokens: 100, + }, + cost: 0.02, + }) + + const telemetry = ClaudeAdapter.parseTelemetry(output) + expect(telemetry!.model).toBe("claude-3-opus-20240229") + expect(telemetry!.tokens?.input).toBe(200) + expect(telemetry!.tokens?.output).toBe(100) + expect(telemetry!.cost).toBe(0.02) + }) + + test("OpenCodeAdapter parses telemetry from JSON output", () => { + const output = JSON.stringify({ + model: "claude-sonnet-4-20250514", + usage: { + input_tokens: 150, + output_tokens: 75, + }, + }) + + const telemetry = OpenCodeAdapter.parseTelemetry(output) + expect(telemetry!.model).toBe("claude-sonnet-4-20250514") + expect(telemetry!.tokens?.input).toBe(150) + expect(telemetry!.tokens?.output).toBe(75) + }) + + test("CrushAdapter parses telemetry from JSON output", () => { + const output = JSON.stringify({ + model: "gemini-1.5-pro", + usage: { + input_tokens: 300, + output_tokens: 150, + }, + cost: 0.03, + }) + + const telemetry = CrushAdapter.parseTelemetry(output) + expect(telemetry!.model).toBe("gemini-1.5-pro") + expect(telemetry!.tokens?.input).toBe(300) + expect(telemetry!.tokens?.output).toBe(150) + expect(telemetry!.cost).toBe(0.03) + }) + + test("parseTelemetry handles multiline JSON output", () => { + const output = `Some text before +{"model": "gpt-4o", "usage": {"prompt_tokens": 100, "completion_tokens": 50}} +Some text after` + + const telemetry = CodexAdapter.parseTelemetry(output) + expect(telemetry!.model).toBe("gpt-4o") + }) + + test("parseTelemetry returns empty object for invalid input", () => { + const telemetry = CodexAdapter.parseTelemetry("not json") + expect(telemetry).toEqual({}) + }) +}) + +describe("Dispatcher execution", () => { + test("execute returns error when adapter unavailable", async () => { + const result = await Dispatcher.execute({ + task: mockTask, + workcell: mockWorkcell, + toolchain: "codex", + }) + + // Without proper CLI/auth, should return error + expect(result.taskId).toBeDefined() + expect(result.workcellId).toBe(mockWorkcell.id) + expect(result.toolchain).toBe("codex") + expect(result.telemetry).toBeDefined() + expect(result.telemetry.startedAt).toBeDefined() + expect(result.telemetry.completedAt).toBeDefined() + + // When adapter is not available + if (!result.success) { + expect(result.error).toBeDefined() + } + }) + + test("execute handles opencode toolchain", async () => { + // Test a single toolchain to avoid timeout + const result = await Dispatcher.execute({ + task: mockTask, + workcell: mockWorkcell, + toolchain: "opencode", + }) + + expect(result.toolchain).toBe("opencode") + expect(result.telemetry).toBeDefined() + }) + + test("execute generates taskId when not provided", async () => { + const result = await Dispatcher.execute({ + task: { ...mockTask, id: undefined }, + workcell: mockWorkcell, + toolchain: "codex", + }) + + // Should generate a UUID + expect(result.taskId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + ) + }) + + test("execute preserves taskId when provided", async () => { + const taskId = "550e8400-e29b-41d4-a716-446655440000" + const result = await Dispatcher.execute({ + task: { ...mockTask, id: taskId }, + workcell: mockWorkcell, + toolchain: "codex", + }) + + expect(result.taskId).toBe(taskId) + }) +}) + +describe("Adapter configuration", () => { + test("CodexAdapter supports approval mode configuration", async () => { + const { configure } = await import("../src/dispatcher/adapters/codex") + // Just verify it doesn't throw + expect(() => configure({ approvalMode: "full-auto" })).not.toThrow() + }) + + test("ClaudeAdapter supports model configuration", async () => { + const { configure } = await import("../src/dispatcher/adapters/claude") + expect(() => configure({ model: "claude-3-opus-20240229" })).not.toThrow() + }) + + test("OpenCodeAdapter supports provider configuration", async () => { + const { configure } = await import("../src/dispatcher/adapters/opencode") + expect(() => configure({ provider: "openai" })).not.toThrow() + }) + + test("CrushAdapter supports providers configuration", async () => { + const { configure } = await import("../src/dispatcher/adapters/crush") + expect(() => configure({ providers: ["anthropic", "openai"] })).not.toThrow() + }) +}) diff --git a/apps/terminal/test/beads.test.ts b/apps/terminal/test/beads.test.ts new file mode 100644 index 000000000..85c4350ea --- /dev/null +++ b/apps/terminal/test/beads.test.ts @@ -0,0 +1,344 @@ +/** + * Beads tests + * + * Tests for the Beads work graph integration. + */ + +import { describe, expect, test, beforeEach, afterEach } from "bun:test" +import * as fs from "node:fs/promises" +import * as path from "node:path" +import * as os from "node:os" +import { Beads, JSONL } from "../src/beads" +import type { Bead, BeadId } from "../src/types" + +// Create temp directory for tests +let tempDir: string + +beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdstrike-beads-test-")) + Beads.reset() +}) + +afterEach(async () => { + Beads.reset() + await fs.rm(tempDir, { recursive: true, force: true }) +}) + +// Helper to create a bead +function makeBead(overrides: Partial<Bead> = {}): Bead { + return { + id: "PROJ-1" as BeadId, + title: "Test Issue", + status: "open", + createdAt: Date.now(), + updatedAt: Date.now(), + ...overrides, + } +} + +describe("JSONL", () => { + describe("read/write", () => { + test("reads empty file", async () => { + await JSONL.init(tempDir) + const issues = await JSONL.read(tempDir) + expect(issues).toEqual([]) + }) + + test("writes and reads issues", async () => { + const issues = [ + makeBead({ id: "PROJ-1" as BeadId }), + makeBead({ id: "PROJ-2" as BeadId, title: "Second Issue" }), + ] + + await JSONL.write(tempDir, issues) + const read = await JSONL.read(tempDir) + + expect(read).toHaveLength(2) + expect(read[0].id).toBe("PROJ-1") + expect(read[1].id).toBe("PROJ-2") + }) + + test("appends issue", async () => { + await JSONL.init(tempDir) + + const issue1 = makeBead({ id: "PROJ-1" as BeadId }) + await JSONL.append(tempDir, issue1) + + const issue2 = makeBead({ id: "PROJ-2" as BeadId }) + await JSONL.append(tempDir, issue2) + + const read = await JSONL.read(tempDir) + expect(read).toHaveLength(2) + }) + + test("updates issue", async () => { + const issues = [makeBead({ id: "PROJ-1" as BeadId, title: "Original" })] + await JSONL.write(tempDir, issues) + + const updated = await JSONL.update(tempDir, "PROJ-1" as BeadId, (issue) => ({ + ...issue, + title: "Updated", + })) + + expect(updated.title).toBe("Updated") + + const read = await JSONL.read(tempDir) + expect(read[0].title).toBe("Updated") + }) + + test("update throws for non-existent issue", async () => { + await JSONL.init(tempDir) + + expect( + JSONL.update(tempDir, "PROJ-999" as BeadId, (i) => i) + ).rejects.toThrow("Issue not found") + }) + + test("removes issue", async () => { + const issues = [ + makeBead({ id: "PROJ-1" as BeadId }), + makeBead({ id: "PROJ-2" as BeadId }), + ] + await JSONL.write(tempDir, issues) + + const removed = await JSONL.remove(tempDir, "PROJ-1" as BeadId) + expect(removed).toBe(true) + + const read = await JSONL.read(tempDir) + expect(read).toHaveLength(1) + expect(read[0].id).toBe("PROJ-2") + }) + + test("remove returns false for non-existent issue", async () => { + await JSONL.init(tempDir) + + const removed = await JSONL.remove(tempDir, "PROJ-999" as BeadId) + expect(removed).toBe(false) + }) + }) +}) + +describe("Beads", () => { + beforeEach(async () => { + await Beads.init({ path: tempDir }) + }) + + describe("init", () => { + test("initializes beads", async () => { + expect(Beads.isInitialized()).toBe(true) + }) + + test("creates issues file", async () => { + const exists = await JSONL.exists(tempDir) + expect(exists).toBe(true) + }) + }) + + describe("query", () => { + test("returns empty for no issues", async () => { + const issues = await Beads.query() + expect(issues).toEqual([]) + }) + + test("returns all issues", async () => { + await JSONL.write(tempDir, [ + makeBead({ id: "PROJ-1" as BeadId }), + makeBead({ id: "PROJ-2" as BeadId }), + ]) + + const issues = await Beads.query() + expect(issues).toHaveLength(2) + }) + + test("filters by status", async () => { + await JSONL.write(tempDir, [ + makeBead({ id: "PROJ-1" as BeadId, status: "open" }), + makeBead({ id: "PROJ-2" as BeadId, status: "completed" }), + ]) + + const open = await Beads.query({ status: "open" }) + expect(open).toHaveLength(1) + expect(open[0].id).toBe("PROJ-1") + + const completed = await Beads.query({ status: "completed" }) + expect(completed).toHaveLength(1) + expect(completed[0].id).toBe("PROJ-2") + }) + + test("filters by multiple statuses", async () => { + await JSONL.write(tempDir, [ + makeBead({ id: "PROJ-1" as BeadId, status: "open" }), + makeBead({ id: "PROJ-2" as BeadId, status: "in_progress" }), + makeBead({ id: "PROJ-3" as BeadId, status: "completed" }), + ]) + + const active = await Beads.query({ status: ["open", "in_progress"] }) + expect(active).toHaveLength(2) + }) + + test("filters by priority", async () => { + await JSONL.write(tempDir, [ + makeBead({ id: "PROJ-1" as BeadId, priority: "p0" }), + makeBead({ id: "PROJ-2" as BeadId, priority: "p2" }), + ]) + + const urgent = await Beads.query({ priority: "p0" }) + expect(urgent).toHaveLength(1) + expect(urgent[0].id).toBe("PROJ-1") + }) + + test("filters by labels", async () => { + await JSONL.write(tempDir, [ + makeBead({ id: "PROJ-1" as BeadId, labels: ["bug", "urgent"] }), + makeBead({ id: "PROJ-2" as BeadId, labels: ["feature"] }), + ]) + + const bugs = await Beads.query({ labels: ["bug"] }) + expect(bugs).toHaveLength(1) + expect(bugs[0].id).toBe("PROJ-1") + }) + + test("applies limit and offset", async () => { + await JSONL.write(tempDir, [ + makeBead({ id: "PROJ-1" as BeadId }), + makeBead({ id: "PROJ-2" as BeadId }), + makeBead({ id: "PROJ-3" as BeadId }), + ]) + + const limited = await Beads.query({ limit: 2 }) + expect(limited).toHaveLength(2) + + const offset = await Beads.query({ offset: 1, limit: 2 }) + expect(offset).toHaveLength(2) + expect(offset[0].id).toBe("PROJ-2") + }) + }) + + describe("get", () => { + test("returns issue by ID", async () => { + await JSONL.write(tempDir, [ + makeBead({ id: "PROJ-1" as BeadId, title: "Found" }), + ]) + + const issue = await Beads.get("PROJ-1" as BeadId) + expect(issue).toBeDefined() + expect(issue!.title).toBe("Found") + }) + + test("returns undefined for non-existent ID", async () => { + const issue = await Beads.get("PROJ-999" as BeadId) + expect(issue).toBeUndefined() + }) + }) + + describe("create", () => { + test("creates new issue with auto-generated ID", async () => { + const issue = await Beads.create({ + title: "New Issue", + status: "open", + }) + + expect(issue.id).toBe("PROJ-1") + expect(issue.title).toBe("New Issue") + expect(issue.createdAt).toBeDefined() + }) + + test("increments ID from existing issues", async () => { + await JSONL.write(tempDir, [ + makeBead({ id: "TEST-5" as BeadId }), + ]) + + const issue = await Beads.create({ + title: "New Issue", + status: "open", + }) + + expect(issue.id).toBe("TEST-6") + }) + }) + + describe("updateStatus", () => { + test("updates issue status", async () => { + await JSONL.write(tempDir, [ + makeBead({ id: "PROJ-1" as BeadId, status: "open" }), + ]) + + const updated = await Beads.updateStatus("PROJ-1" as BeadId, "in_progress") + expect(updated.status).toBe("in_progress") + }) + + test("sets closedAt when completing", async () => { + await JSONL.write(tempDir, [ + makeBead({ id: "PROJ-1" as BeadId, status: "open" }), + ]) + + const updated = await Beads.updateStatus("PROJ-1" as BeadId, "completed") + expect(updated.closedAt).toBeDefined() + }) + }) + + describe("update", () => { + test("updates issue fields", async () => { + await JSONL.write(tempDir, [ + makeBead({ id: "PROJ-1" as BeadId, title: "Original" }), + ]) + + const updated = await Beads.update("PROJ-1" as BeadId, { + title: "Updated", + priority: "p0", + }) + + expect(updated.title).toBe("Updated") + expect(updated.priority).toBe("p0") + }) + + test("cannot change ID", async () => { + await JSONL.write(tempDir, [ + makeBead({ id: "PROJ-1" as BeadId }), + ]) + + const updated = await Beads.update("PROJ-1" as BeadId, { + id: "PROJ-999" as BeadId, + }) + + expect(updated.id).toBe("PROJ-1") + }) + }) + + describe("getReady", () => { + test("returns open issues", async () => { + await JSONL.write(tempDir, [ + makeBead({ id: "PROJ-1" as BeadId, status: "open" }), + makeBead({ id: "PROJ-2" as BeadId, status: "in_progress" }), + makeBead({ id: "PROJ-3" as BeadId, status: "open" }), + ]) + + const ready = await Beads.getReady() + expect(ready).toHaveLength(2) + expect(ready[0].reasoning).toContain("open") + }) + + test("infers toolchain from labels", async () => { + await JSONL.write(tempDir, [ + makeBead({ + id: "PROJ-1" as BeadId, + status: "open", + labels: ["dk_risk:high"], + }), + makeBead({ + id: "PROJ-2" as BeadId, + status: "open", + labels: ["dk_size:xs"], + }), + ]) + + const ready = await Beads.getReady() + + const highRisk = ready.find((r) => r.id === "PROJ-1") + expect(highRisk?.suggestedToolchain).toBe("codex") + + const small = ready.find((r) => r.id === "PROJ-2") + expect(small?.suggestedToolchain).toBe("opencode") + }) + }) +}) diff --git a/apps/terminal/test/cli.test.ts b/apps/terminal/test/cli.test.ts new file mode 100644 index 000000000..6feee8086 --- /dev/null +++ b/apps/terminal/test/cli.test.ts @@ -0,0 +1,373 @@ +/** + * CLI tests + * + * Tests for the clawdstrike command-line interface. + */ + +import { describe, expect, test, beforeEach, afterEach } from "bun:test" +import * as fs from "node:fs/promises" +import * as path from "node:path" +import * as os from "node:os" +import { parseCliArgs } from "../src/cli" + +// Create temp directory for tests +let tempDir: string + +beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdstrike-cli-test-")) +}) + +afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }) +}) + +describe("CLI Argument Parsing", () => { + // Save original argv + const originalArgv = process.argv + + afterEach(() => { + process.argv = originalArgv + }) + + test("parses help command", () => { + process.argv = ["bun", "clawdstrike", "help"] + const { command, args } = parseCliArgs() + expect(command).toBe("help") + expect(args).toEqual([]) + }) + + test("parses version flag", () => { + process.argv = ["bun", "clawdstrike", "--version"] + const result = parseCliArgs() + expect(result.options.version).toBe(true) + }) + + test("parses dispatch command with prompt", () => { + process.argv = ["bun", "clawdstrike", "dispatch", "Fix", "the", "bug"] + const { command, args } = parseCliArgs() + expect(command).toBe("dispatch") + expect(args).toEqual(["Fix", "the", "bug"]) + }) + + test("parses dispatch with toolchain option", () => { + process.argv = ["bun", "clawdstrike", "dispatch", "-t", "claude", "Fix bug"] + const { command, options, args } = parseCliArgs() + expect(command).toBe("dispatch") + expect(options.toolchain).toBe("claude") + expect(args).toContain("Fix bug") + }) + + test("parses gate command with gates", () => { + process.argv = ["bun", "clawdstrike", "gate", "pytest", "mypy", "ruff"] + const { command, args } = parseCliArgs() + expect(command).toBe("gate") + expect(args).toEqual(["pytest", "mypy", "ruff"]) + }) + + test("parses speculate with strategy", () => { + process.argv = ["bun", "clawdstrike", "speculate", "-s", "best_score", "Refactor"] + const { command, options, args } = parseCliArgs() + expect(command).toBe("speculate") + expect(options.strategy).toBe("best_score") + expect(args).toContain("Refactor") + }) + + test("parses beads subcommand", () => { + process.argv = ["bun", "clawdstrike", "beads", "list"] + const { command, args } = parseCliArgs() + expect(command).toBe("beads") + expect(args).toEqual(["list"]) + }) + + test("parses json flag", () => { + process.argv = ["bun", "clawdstrike", "status", "--json"] + const { command, options } = parseCliArgs() + expect(command).toBe("status") + expect(options.json).toBe(true) + }) + + test("parses short json flag", () => { + process.argv = ["bun", "clawdstrike", "status", "-j"] + const { options } = parseCliArgs() + expect(options.json).toBe(true) + }) + + test("parses cwd option", () => { + process.argv = ["bun", "clawdstrike", "gate", "--cwd", "/some/path"] + const { options } = parseCliArgs() + expect(options.cwd).toBe("/some/path") + }) + + test("parses project option", () => { + process.argv = ["bun", "clawdstrike", "dispatch", "-p", "my-project", "task"] + const { options } = parseCliArgs() + expect(options.project).toBe("my-project") + }) + + test("parses timeout option", () => { + process.argv = ["bun", "clawdstrike", "dispatch", "--timeout", "60000", "task"] + const { options } = parseCliArgs() + expect(options.timeout).toBe(60000) + }) + + test("defaults to empty command (TUI) when no args", () => { + process.argv = ["bun", "clawdstrike"] + const { command } = parseCliArgs() + expect(command).toBe("") + }) +}) + +describe("CLI Integration", () => { + test("help command runs without error", async () => { + const proc = Bun.spawn(["bun", "run", "./src/cli/index.ts", "help"], { + cwd: process.cwd(), + stdout: "pipe", + stderr: "pipe", + }) + + const stdout = await new Response(proc.stdout).text() + const exitCode = await proc.exited + + expect(exitCode).toBe(0) + expect(stdout).toContain("clawdstrike") + expect(stdout).toContain("dispatch") + expect(stdout).toContain("speculate") + }) + + test("version command outputs version", async () => { + const proc = Bun.spawn(["bun", "run", "./src/cli/index.ts", "version"], { + cwd: process.cwd(), + stdout: "pipe", + stderr: "pipe", + }) + + const stdout = await new Response(proc.stdout).text() + const exitCode = await proc.exited + + expect(exitCode).toBe(0) + expect(stdout).toContain("0.1.0") + }) + + test("--version flag outputs version", async () => { + const proc = Bun.spawn(["bun", "run", "./src/cli/index.ts", "--version"], { + cwd: process.cwd(), + stdout: "pipe", + stderr: "pipe", + }) + + const stdout = await new Response(proc.stdout).text() + const exitCode = await proc.exited + + expect(exitCode).toBe(0) + expect(stdout).toContain("0.1.0") + }) + + test("status command shows kernel status", async () => { + const proc = Bun.spawn( + ["bun", "run", "./src/cli/index.ts", "status", "--cwd", tempDir], + { + cwd: process.cwd(), + stdout: "pipe", + stderr: "pipe", + } + ) + + const stdout = await new Response(proc.stdout).text() + const exitCode = await proc.exited + + expect(exitCode).toBe(0) + expect(stdout).toContain("clawdstrike Status") + expect(stdout).toContain("Version") + expect(stdout).toContain("0.1.0") + }) + + test("init command initializes clawdstrike", async () => { + const proc = Bun.spawn( + ["bun", "run", "./src/cli/index.ts", "init", "--cwd", tempDir], + { + cwd: process.cwd(), + stdout: "pipe", + stderr: "pipe", + } + ) + + const stdout = await new Response(proc.stdout).text() + const exitCode = await proc.exited + + expect(exitCode).toBe(0) + expect(stdout).toContain("initialized") + + // Verify directories created + const beadsExists = await Bun.file(`${tempDir}/.beads/issues.jsonl`).exists() + expect(beadsExists).toBe(true) + }) + + test("beads list shows empty initially", async () => { + const proc = Bun.spawn( + ["bun", "run", "./src/cli/index.ts", "beads", "list", "--cwd", tempDir], + { + cwd: process.cwd(), + stdout: "pipe", + stderr: "pipe", + } + ) + + const stdout = await new Response(proc.stdout).text() + const exitCode = await proc.exited + + expect(exitCode).toBe(0) + expect(stdout).toContain("No issues found") + }) + + test("beads ready shows empty initially", async () => { + const proc = Bun.spawn( + ["bun", "run", "./src/cli/index.ts", "beads", "ready", "--cwd", tempDir], + { + cwd: process.cwd(), + stdout: "pipe", + stderr: "pipe", + } + ) + + const stdout = await new Response(proc.stdout).text() + const exitCode = await proc.exited + + expect(exitCode).toBe(0) + expect(stdout).toContain("No issues ready") + }) + + test("beads create creates issue", async () => { + const proc = Bun.spawn( + [ + "bun", + "run", + "./src/cli/index.ts", + "beads", + "create", + "Test issue", + "--cwd", + tempDir, + ], + { + cwd: process.cwd(), + stdout: "pipe", + stderr: "pipe", + } + ) + + const stdout = await new Response(proc.stdout).text() + const exitCode = await proc.exited + + expect(exitCode).toBe(0) + expect(stdout).toContain("Created issue") + expect(stdout).toContain("Test issue") + }) + + test("unknown command shows error", async () => { + const proc = Bun.spawn( + ["bun", "run", "./src/cli/index.ts", "unknown-command"], + { + cwd: process.cwd(), + stdout: "pipe", + stderr: "pipe", + } + ) + + const stderr = await new Response(proc.stderr).text() + const exitCode = await proc.exited + + expect(exitCode).toBe(1) + expect(stderr).toContain("Unknown command") + }) + + test("dispatch without prompt shows error", async () => { + const proc = Bun.spawn( + ["bun", "run", "./src/cli/index.ts", "dispatch", "--cwd", tempDir], + { + cwd: process.cwd(), + stdout: "pipe", + stderr: "pipe", + } + ) + + const stderr = await new Response(proc.stderr).text() + const exitCode = await proc.exited + + expect(exitCode).toBe(1) + expect(stderr).toContain("Missing prompt") + }) + + test("speculate without prompt shows error", async () => { + const proc = Bun.spawn( + ["bun", "run", "./src/cli/index.ts", "speculate", "--cwd", tempDir], + { + cwd: process.cwd(), + stdout: "pipe", + stderr: "pipe", + } + ) + + const stderr = await new Response(proc.stderr).text() + const exitCode = await proc.exited + + expect(exitCode).toBe(1) + expect(stderr).toContain("Missing prompt") + }) + + test("beads help shows subcommands", async () => { + const proc = Bun.spawn( + ["bun", "run", "./src/cli/index.ts", "beads", "--help"], + { + cwd: process.cwd(), + stdout: "pipe", + stderr: "pipe", + } + ) + + const stdout = await new Response(proc.stdout).text() + const exitCode = await proc.exited + + expect(exitCode).toBe(0) + expect(stdout).toContain("list") + expect(stdout).toContain("get") + expect(stdout).toContain("ready") + expect(stdout).toContain("create") + }) + + test("json output works for status", async () => { + const proc = Bun.spawn( + ["bun", "run", "./src/cli/index.ts", "status", "--json", "--cwd", tempDir], + { + cwd: process.cwd(), + stdout: "pipe", + stderr: "pipe", + } + ) + + const stdout = await new Response(proc.stdout).text() + const exitCode = await proc.exited + + expect(exitCode).toBe(0) + const parsed = JSON.parse(stdout) + expect(parsed).toHaveProperty("active") + expect(Array.isArray(parsed.active)).toBe(true) + }) + + test("no-color flag disables colors", async () => { + const proc = Bun.spawn( + ["bun", "run", "./src/cli/index.ts", "help", "--no-color"], + { + cwd: process.cwd(), + stdout: "pipe", + stderr: "pipe", + } + ) + + const stdout = await new Response(proc.stdout).text() + const exitCode = await proc.exited + + expect(exitCode).toBe(0) + // Should not contain ANSI escape codes + expect(stdout).not.toContain("\x1b[") + }) +}) diff --git a/apps/terminal/test/config.test.ts b/apps/terminal/test/config.test.ts new file mode 100644 index 000000000..e2bc96b2e --- /dev/null +++ b/apps/terminal/test/config.test.ts @@ -0,0 +1,169 @@ +/** + * Config module tests + * + * Tests for project configuration loading, saving, detection, and schema validation. + */ + +import { describe, expect, test, beforeEach, afterEach } from "bun:test" +import { join } from "path" +import { mkdtemp, rm, readFile } from "fs/promises" +import { tmpdir } from "os" +import { Config, type ProjectConfig } from "../src/config" + +let testDir: string + +beforeEach(async () => { + testDir = await mkdtemp(join(tmpdir(), "clawdstrike-config-test-")) +}) + +afterEach(async () => { + await rm(testDir, { recursive: true, force: true }) +}) + +describe("Config.exists", () => { + test("returns false when no config file exists", async () => { + expect(await Config.exists(testDir)).toBe(false) + }) + + test("returns true after config is saved", async () => { + const config: ProjectConfig = { + schema_version: "1.0.0", + sandbox: "inplace", + adapters: {}, + git_available: false, + project_id: "default", + } + await Config.save(testDir, config) + expect(await Config.exists(testDir)).toBe(true) + }) +}) + +describe("Config.save and Config.load", () => { + test("round-trips a minimal config", async () => { + const config: ProjectConfig = { + schema_version: "1.0.0", + sandbox: "inplace", + adapters: {}, + git_available: false, + project_id: "default", + } + await Config.save(testDir, config) + const loaded = await Config.load(testDir) + expect(loaded).not.toBeNull() + expect(loaded!.schema_version).toBe("1.0.0") + expect(loaded!.sandbox).toBe("inplace") + expect(loaded!.git_available).toBe(false) + expect(loaded!.project_id).toBe("default") + }) + + test("round-trips a full config", async () => { + const config: ProjectConfig = { + schema_version: "1.0.0", + sandbox: "worktree", + toolchain: "claude", + adapters: { + claude: { available: true, version: "1.0.0" }, + codex: { available: false }, + }, + git_available: true, + project_id: "my-project", + } + await Config.save(testDir, config) + const loaded = await Config.load(testDir) + expect(loaded).not.toBeNull() + expect(loaded!.sandbox).toBe("worktree") + expect(loaded!.toolchain).toBe("claude") + expect(loaded!.adapters.claude.available).toBe(true) + expect(loaded!.adapters.codex.available).toBe(false) + expect(loaded!.git_available).toBe(true) + expect(loaded!.project_id).toBe("my-project") + }) + + test("saves valid JSON to disk", async () => { + const config: ProjectConfig = { + schema_version: "1.0.0", + sandbox: "tmpdir", + adapters: {}, + git_available: false, + project_id: "default", + } + await Config.save(testDir, config) + const raw = await readFile( + join(testDir, ".clawdstrike", "config.json"), + "utf-8" + ) + const parsed = JSON.parse(raw) + expect(parsed.schema_version).toBe("1.0.0") + expect(parsed.sandbox).toBe("tmpdir") + }) + + test("creates .clawdstrike directory if missing", async () => { + const config: ProjectConfig = { + schema_version: "1.0.0", + sandbox: "inplace", + adapters: {}, + git_available: false, + project_id: "default", + } + await Config.save(testDir, config) + expect(await Config.exists(testDir)).toBe(true) + }) +}) + +describe("Config.load", () => { + test("returns null for missing config", async () => { + const loaded = await Config.load(testDir) + expect(loaded).toBeNull() + }) + + test("returns null for invalid JSON", async () => { + const { mkdir, writeFile } = await import("fs/promises") + await mkdir(join(testDir, ".clawdstrike"), { recursive: true }) + await writeFile( + join(testDir, ".clawdstrike", "config.json"), + "not json" + ) + const loaded = await Config.load(testDir) + expect(loaded).toBeNull() + }) + + test("returns null for invalid schema", async () => { + const { mkdir, writeFile } = await import("fs/promises") + await mkdir(join(testDir, ".clawdstrike"), { recursive: true }) + await writeFile( + join(testDir, ".clawdstrike", "config.json"), + JSON.stringify({ schema_version: "2.0.0", sandbox: "unknown" }) + ) + const loaded = await Config.load(testDir) + expect(loaded).toBeNull() + }) +}) + +describe("Config.detect", () => { + test("returns detection result with adapter info", async () => { + const result = await Config.detect(testDir) + expect(result).toBeDefined() + expect(typeof result.git_available).toBe("boolean") + expect(result.adapters).toBeDefined() + expect(typeof result.recommended_sandbox).toBe("string") + expect(["inplace", "worktree", "tmpdir"]).toContain( + result.recommended_sandbox + ) + }) + + test("recommends worktree when git is available", async () => { + // This test depends on whether we're in a git repo + const result = await Config.detect(process.cwd()) + if (result.git_available) { + expect(result.recommended_sandbox).toBe("worktree") + } + }) + + test("recommends inplace when git is not available", async () => { + // tmpdir is guaranteed to not be a git repo + const result = await Config.detect(testDir) + if (!result.git_available) { + expect(result.recommended_sandbox).toBe("inplace") + } + }) +}) diff --git a/apps/terminal/test/health.test.ts b/apps/terminal/test/health.test.ts new file mode 100644 index 000000000..e2b4ec477 --- /dev/null +++ b/apps/terminal/test/health.test.ts @@ -0,0 +1,226 @@ +/** + * Health module tests + * + * Tests for the integration healthcheck system. + */ + +import { describe, expect, test, beforeEach, afterEach } from "bun:test" +import { Health } from "../src/health" + +describe("Health", () => { + beforeEach(() => { + Health.clearCache() + }) + + afterEach(() => { + Health.clearCache() + }) + + describe("checkAll", () => { + test("returns health summary with all categories", async () => { + const summary = await Health.checkAll({ timeout: 1000 }) + + expect(summary).toHaveProperty("security") + expect(summary).toHaveProperty("ai") + expect(summary).toHaveProperty("infra") + expect(summary).toHaveProperty("mcp") + expect(summary).toHaveProperty("checkedAt") + + expect(Array.isArray(summary.security)).toBe(true) + expect(Array.isArray(summary.ai)).toBe(true) + expect(Array.isArray(summary.infra)).toBe(true) + expect(Array.isArray(summary.mcp)).toBe(true) + }) + + test("security category includes hushd and hush-cli", async () => { + const summary = await Health.checkAll({ timeout: 1000 }) + + const ids = summary.security.map((h) => h.id) + expect(ids).toContain("hushd") + expect(ids).toContain("hush-cli") + }) + + test("ai category includes claude, codex, opencode", async () => { + const summary = await Health.checkAll({ timeout: 1000 }) + + const ids = summary.ai.map((h) => h.id) + expect(ids).toContain("claude") + expect(ids).toContain("codex") + expect(ids).toContain("opencode") + }) + + test("infra category includes git, python, bun", async () => { + const summary = await Health.checkAll({ timeout: 1000 }) + + const ids = summary.infra.map((h) => h.id) + expect(ids).toContain("git") + expect(ids).toContain("python") + expect(ids).toContain("bun") + }) + + test("mcp category includes clawdstrike-mcp", async () => { + const summary = await Health.checkAll({ timeout: 1000 }) + + const ids = summary.mcp.map((h) => h.id) + expect(ids).toContain("clawdstrike-mcp") + }) + + test("caches results", async () => { + const first = await Health.checkAll({ timeout: 1000 }) + const second = await Health.checkAll({ timeout: 1000 }) + + // Should be the same cached result + expect(first.checkedAt).toBe(second.checkedAt) + }) + + test("force bypasses cache", async () => { + const first = await Health.checkAll({ timeout: 1000 }) + + // Small delay to ensure different timestamp + await new Promise((r) => setTimeout(r, 10)) + + const second = await Health.checkAll({ timeout: 1000, force: true }) + + // Should be different timestamps + expect(second.checkedAt).toBeGreaterThan(first.checkedAt) + }) + }) + + describe("check", () => { + test("checks single integration", async () => { + const result = await Health.check("git", { timeout: 1000 }) + + expect(result).toBeDefined() + expect(result?.id).toBe("git") + expect(result?.name).toBe("Git") + expect(result?.category).toBe("infra") + expect(typeof result?.available).toBe("boolean") + }) + + test("returns undefined for unknown integration", async () => { + const result = await Health.check("unknown-integration", { timeout: 1000 }) + expect(result).toBeUndefined() + }) + + test("git should be available", async () => { + const result = await Health.check("git", { timeout: 2000 }) + + expect(result?.available).toBe(true) + expect(result?.version).toBeDefined() + expect(result?.latency).toBeDefined() + }) + + test("bun should be available", async () => { + const result = await Health.check("bun", { timeout: 2000 }) + + expect(result?.available).toBe(true) + expect(result?.version).toBeDefined() + }) + }) + + describe("getSummary", () => { + test("returns empty categories before checkAll", () => { + const summary = Health.getSummary() + + expect(summary.security).toEqual([]) + expect(summary.ai).toEqual([]) + expect(summary.infra).toEqual([]) + // MCP is always present + expect(summary.mcp.length).toBe(1) + }) + + test("returns cached results after checkAll", async () => { + await Health.checkAll({ timeout: 1000 }) + const summary = Health.getSummary() + + expect(summary.security.length).toBeGreaterThan(0) + expect(summary.ai.length).toBeGreaterThan(0) + expect(summary.infra.length).toBeGreaterThan(0) + }) + }) + + describe("getStatus", () => { + test("returns undefined for uncached integration", () => { + const result = Health.getStatus("git") + expect(result).toBeUndefined() + }) + + test("returns cached result after check", async () => { + await Health.check("git", { timeout: 1000 }) + const result = Health.getStatus("git") + + expect(result).toBeDefined() + expect(result?.id).toBe("git") + }) + + test("returns MCP status", () => { + const result = Health.getStatus("clawdstrike-mcp") + + expect(result).toBeDefined() + expect(result?.id).toBe("clawdstrike-mcp") + expect(result?.category).toBe("mcp") + }) + }) + + describe("MCP status", () => { + test("setMcpStatus updates status", () => { + Health.setMcpStatus(true, 3141) + const status = Health.getMcpStatus() + + expect(status.running).toBe(true) + expect(status.port).toBe(3141) + }) + + test("getMcpStatus returns current status", () => { + Health.setMcpStatus(false) + const status = Health.getMcpStatus() + + expect(status.running).toBe(false) + expect(status.port).toBeUndefined() + }) + }) + + describe("clearCache", () => { + test("clears all cached results", async () => { + await Health.checkAll({ timeout: 1000 }) + expect(Health.getSummary()["security"].length).toBeGreaterThan(0) + + Health.clearCache() + + expect(Health.getSummary()["security"]).toEqual([]) + expect(Health.isCacheStale()).toBe(true) + }) + }) + + describe("getIntegrationIds", () => { + test("returns all integration IDs", () => { + const ids = Health.getIntegrationIds() + + expect(ids).toContain("hushd") + expect(ids).toContain("hush-cli") + expect(ids).toContain("claude") + expect(ids).toContain("codex") + expect(ids).toContain("opencode") + expect(ids).toContain("git") + expect(ids).toContain("python") + expect(ids).toContain("bun") + }) + }) + + describe("isCacheStale", () => { + test("returns true initially", () => { + expect(Health.isCacheStale()).toBe(true) + }) + + test("returns false after checkAll", async () => { + await Health.checkAll({ timeout: 1000 }) + expect(Health.isCacheStale()).toBe(false) + }) + + test("returns true after clearCache", async () => { + await Health.checkAll({ timeout: 1000 }) + Health.clearCache() + expect(Health.isCacheStale()).toBe(true) + }) + }) +}) diff --git a/apps/terminal/test/hushd.test.ts b/apps/terminal/test/hushd.test.ts new file mode 100644 index 000000000..3c1a3da2d --- /dev/null +++ b/apps/terminal/test/hushd.test.ts @@ -0,0 +1,320 @@ +/** + * Hushd Client Tests + * + * Tests for the hushd HTTP + SSE client with mocked fetch. + */ + +import { describe, test, expect, beforeEach, afterEach, mock } from "bun:test" +import { HushdClient } from "../src/hushd/client" +import { Hushd } from "../src/hushd/index" +import type { CheckRequest, CheckResponse, PolicyResponse, AuditResponse, AuditStats } from "../src/hushd/types" + +// ============================================================================= +// MOCK HELPERS +// ============================================================================= + +const originalFetch = globalThis.fetch + +function mockFetch(responses: Map<string, { status: number; body: unknown }>) { + globalThis.fetch = mock(async (input: string | URL | Request) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url + + for (const [pattern, response] of responses) { + if (url.includes(pattern)) { + return new Response(JSON.stringify(response.body), { + status: response.status, + headers: { "Content-Type": "application/json" }, + }) + } + } + + return new Response("Not Found", { status: 404 }) + }) as unknown as typeof fetch +} + +function mockFetchError() { + globalThis.fetch = mock(async () => { + throw new Error("Connection refused") + }) as unknown as typeof fetch +} + +// ============================================================================= +// CLIENT TESTS +// ============================================================================= + +describe("HushdClient", () => { + let client: HushdClient + + beforeEach(() => { + client = new HushdClient("http://127.0.0.1:8080") + }) + + afterEach(() => { + client.disconnectSSE() + globalThis.fetch = originalFetch + }) + + describe("probe", () => { + test("returns true when hushd is reachable", async () => { + mockFetch(new Map([ + ["/health", { status: 200, body: { status: "ok" } }], + ])) + + const result = await client.probe() + expect(result).toBe(true) + }) + + test("returns false when hushd is unreachable", async () => { + mockFetchError() + + const result = await client.probe() + expect(result).toBe(false) + }) + + test("returns false on non-200 response", async () => { + mockFetch(new Map([ + ["/health", { status: 503, body: { status: "unavailable" } }], + ])) + + const result = await client.probe() + expect(result).toBe(false) + }) + }) + + describe("check", () => { + test("submits check request and returns response", async () => { + const mockResponse: CheckResponse = { + decision: "allow", + policy: "default", + policy_version: "1.2.0", + guards: [ + { + guard: "ForbiddenPathGuard", + decision: "allow", + severity: "info", + }, + ], + receipt_id: "test-receipt", + timestamp: new Date().toISOString(), + } + + mockFetch(new Map([ + ["/api/v1/check", { status: 200, body: mockResponse }], + ])) + + const req: CheckRequest = { + action_type: "file", + target: "/tmp/safe-file.txt", + } + + const result = await client.check(req) + expect(result).not.toBeNull() + expect(result!.decision).toBe("allow") + expect(result!.guards).toHaveLength(1) + }) + + test("returns null on connectivity error", async () => { + mockFetchError() + + const result = await client.check({ + action_type: "file", + target: "/etc/passwd", + }) + expect(result).toBeNull() + }) + + test("returns null on non-200 response", async () => { + mockFetch(new Map([ + ["/api/v1/check", { status: 500, body: { error: "internal" } }], + ])) + + const result = await client.check({ + action_type: "file", + target: "/tmp/test", + }) + expect(result).toBeNull() + }) + }) + + describe("getPolicy", () => { + test("fetches active policy", async () => { + const mockPolicy: PolicyResponse = { + name: "default", + version: "1.2.0", + hash: "abc123", + schema_version: "1.2.0", + guards: [ + { id: "ForbiddenPathGuard", enabled: true }, + { id: "SecretLeakGuard", enabled: true }, + ], + loaded_at: new Date().toISOString(), + } + + mockFetch(new Map([ + ["/api/v1/policy", { status: 200, body: mockPolicy }], + ])) + + const result = await client.getPolicy() + expect(result).not.toBeNull() + expect(result!.name).toBe("default") + expect(result!.guards).toHaveLength(2) + }) + + test("returns null on error", async () => { + mockFetchError() + const result = await client.getPolicy() + expect(result).toBeNull() + }) + }) + + describe("getAudit", () => { + test("queries audit log with parameters", async () => { + const mockAudit: AuditResponse = { + events: [ + { + id: "evt-1", + timestamp: new Date().toISOString(), + action_type: "file", + target: "/etc/passwd", + decision: "deny", + guard: "ForbiddenPathGuard", + severity: "critical", + reason: "Path is forbidden", + }, + ], + total: 1, + offset: 0, + limit: 50, + } + + mockFetch(new Map([ + ["/api/v1/audit", { status: 200, body: mockAudit }], + ])) + + const result = await client.getAudit({ limit: 10, decision: "deny" }) + expect(result).not.toBeNull() + expect(result!.events).toHaveLength(1) + expect(result!.events[0].decision).toBe("deny") + }) + + test("returns null on error", async () => { + mockFetchError() + const result = await client.getAudit() + expect(result).toBeNull() + }) + }) + + describe("getAuditStats", () => { + test("fetches audit statistics", async () => { + const mockStats: AuditStats = { + total_checks: 100, + allowed: 95, + denied: 5, + by_guard: { + ForbiddenPathGuard: { allowed: 45, denied: 3 }, + SecretLeakGuard: { allowed: 50, denied: 2 }, + }, + by_action_type: { + file: { allowed: 60, denied: 4 }, + shell: { allowed: 35, denied: 1 }, + }, + since: new Date().toISOString(), + } + + mockFetch(new Map([ + ["/api/v1/audit/stats", { status: 200, body: mockStats }], + ])) + + const result = await client.getAuditStats() + expect(result).not.toBeNull() + expect(result!.total_checks).toBe(100) + expect(result!.denied).toBe(5) + }) + + test("returns null on error", async () => { + mockFetchError() + const result = await client.getAuditStats() + expect(result).toBeNull() + }) + }) + + describe("auth token", () => { + test("includes Authorization header when token provided", async () => { + const tokenClient = new HushdClient("http://127.0.0.1:8080", "test-token") + + let capturedHeaders: Record<string, string> = {} + globalThis.fetch = mock(async (_input: string | URL | Request, init?: RequestInit) => { + const headers = init?.headers as Record<string, string> | undefined + if (headers) { + capturedHeaders = headers + } + return new Response(JSON.stringify({ status: "ok" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + }) as unknown as typeof fetch + + await tokenClient.getPolicy() + expect(capturedHeaders["Authorization"]).toBe("Bearer test-token") + + tokenClient.disconnectSSE() + }) + }) + + describe("SSE", () => { + test("isSSEConnected returns false when not connected", () => { + expect(client.isSSEConnected()).toBe(false) + }) + + test("disconnectSSE is safe to call when not connected", () => { + // Should not throw + client.disconnectSSE() + expect(client.isSSEConnected()).toBe(false) + }) + }) +}) + +// ============================================================================= +// NAMESPACE TESTS +// ============================================================================= + +describe("Hushd namespace", () => { + afterEach(() => { + Hushd.reset() + globalThis.fetch = originalFetch + }) + + test("isInitialized returns false before init", () => { + expect(Hushd.isInitialized()).toBe(false) + }) + + test("init creates client", () => { + Hushd.init() + expect(Hushd.isInitialized()).toBe(true) + }) + + test("init with custom URL", () => { + Hushd.init({ url: "http://custom:9090" }) + expect(Hushd.isInitialized()).toBe(true) + }) + + test("getClient auto-initializes", () => { + expect(Hushd.isInitialized()).toBe(false) + const client = Hushd.getClient() + expect(client).toBeDefined() + expect(Hushd.isInitialized()).toBe(true) + }) + + test("reset clears client", () => { + Hushd.init() + expect(Hushd.isInitialized()).toBe(true) + Hushd.reset() + expect(Hushd.isInitialized()).toBe(false) + }) + + test("getClient returns same instance", () => { + const a = Hushd.getClient() + const b = Hushd.getClient() + expect(a).toBe(b) + }) +}) diff --git a/apps/terminal/test/index.test.ts b/apps/terminal/test/index.test.ts new file mode 100644 index 000000000..ad8c8973f --- /dev/null +++ b/apps/terminal/test/index.test.ts @@ -0,0 +1,118 @@ +/** + * Main entry point tests + * + * Verifies that the main index exports everything correctly. + */ + +import { describe, expect, test } from "bun:test" + +// Test main exports +import { + // Types + TaskId, + WorkcellId, + BeadId, + Toolchain, + TaskInput, + TaskStatus, + ExecutionResult, + GateResult, + GateResults, + WorkcellInfo, + WorkcellStatus, + SpeculationConfig, + RoutingDecision, + Patch, + Bead, + // Modules + Router, + Dispatcher, + Workcell, + Verifier, + Speculate, + PatchLifecycle, + Beads, + Telemetry, + // Tools + tools, + getTool, + registerTools, + // Version and init + VERSION, + init, + shutdown, +} from "../src" + +describe("Main exports", () => { + test("VERSION is defined", () => { + expect(VERSION).toBe("0.1.0") + }) + + test("Type schemas are exported", () => { + expect(TaskId).toBeDefined() + expect(WorkcellId).toBeDefined() + expect(BeadId).toBeDefined() + expect(Toolchain).toBeDefined() + expect(TaskInput).toBeDefined() + expect(TaskStatus).toBeDefined() + expect(ExecutionResult).toBeDefined() + expect(GateResult).toBeDefined() + expect(GateResults).toBeDefined() + expect(WorkcellInfo).toBeDefined() + expect(WorkcellStatus).toBeDefined() + expect(SpeculationConfig).toBeDefined() + expect(RoutingDecision).toBeDefined() + expect(Patch).toBeDefined() + expect(Bead).toBeDefined() + }) + + test("Namespace modules are exported", () => { + expect(Router).toBeDefined() + expect(Dispatcher).toBeDefined() + expect(Workcell).toBeDefined() + expect(Verifier).toBeDefined() + expect(Speculate).toBeDefined() + expect(PatchLifecycle).toBeDefined() + expect(Beads).toBeDefined() + expect(Telemetry).toBeDefined() + }) + + test("Tools are exported", () => { + expect(tools).toBeDefined() + expect(tools).toHaveLength(3) + expect(getTool).toBeFunction() + expect(registerTools).toBeFunction() + }) + + test("init and shutdown are exported", () => { + expect(init).toBeFunction() + expect(shutdown).toBeFunction() + }) + + test("init and shutdown work", async () => { + // init should complete without error + await expect(init()).resolves.toBeUndefined() + // shutdown should complete without error + await expect(shutdown()).resolves.toBeUndefined() + }) +}) + +describe("Type inference", () => { + test("TaskInput type can be inferred", () => { + const task: TaskInput = { + prompt: "Test task", + context: { + cwd: "/project", + projectId: "test", + }, + } + + // This test verifies TypeScript types work correctly + expect(task.prompt).toBe("Test task") + }) + + test("Toolchain type is correct", () => { + const toolchain: Toolchain = "codex" + expect(["codex", "claude", "opencode", "crush"]).toContain(toolchain) + }) +}) diff --git a/apps/terminal/test/mcp.test.ts b/apps/terminal/test/mcp.test.ts new file mode 100644 index 000000000..3b2551eec --- /dev/null +++ b/apps/terminal/test/mcp.test.ts @@ -0,0 +1,272 @@ +/** + * MCP module tests + * + * Tests for the Model Context Protocol server. + */ + +import { describe, expect, test, beforeEach, afterEach } from "bun:test" +import { MCP } from "../src/mcp" +import { Health } from "../src/health" +import * as fs from "node:fs/promises" +import * as path from "node:path" +import * as os from "node:os" +import { Socket } from "node:net" + +// Create temp directory for tests +let tempDir: string + +beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdstrike-mcp-test-")) + // Ensure MCP is stopped + try { + await MCP.stop() + } catch { + // Ignore if not running + } +}) + +afterEach(async () => { + try { + await MCP.stop() + } catch { + // Ignore if not running + } + await fs.rm(tempDir, { recursive: true, force: true }) +}) + +describe("MCP Server", () => { + describe("start", () => { + test("starts server and returns port", async () => { + const port = await MCP.start({ cwd: tempDir }) + + expect(typeof port).toBe("number") + expect(port).toBeGreaterThan(0) + }) + + test("creates discovery file", async () => { + await MCP.start({ cwd: tempDir }) + + const discoveryPath = path.join(tempDir, ".clawdstrike", "mcp.json") + const exists = await Bun.file(discoveryPath).exists() + expect(exists).toBe(true) + + const content = await Bun.file(discoveryPath).json() + expect(content).toHaveProperty("port") + expect(content).toHaveProperty("host") + expect(content).toHaveProperty("pid") + expect(content).toHaveProperty("tools") + expect(Array.isArray(content.tools)).toBe(true) + }) + + test("updates Health MCP status", async () => { + const port = await MCP.start({ cwd: tempDir }) + + const status = Health.getMcpStatus() + expect(status.running).toBe(true) + expect(status.port).toBe(port) + }) + + test("throws if already running", async () => { + await MCP.start({ cwd: tempDir }) + + await expect(MCP.start({ cwd: tempDir })).rejects.toThrow("already running") + }) + }) + + describe("stop", () => { + test("stops running server", async () => { + await MCP.start({ cwd: tempDir }) + expect(MCP.isRunning()).toBe(true) + + await MCP.stop() + expect(MCP.isRunning()).toBe(false) + }) + + test("updates Health MCP status", async () => { + await MCP.start({ cwd: tempDir }) + await MCP.stop() + + const status = Health.getMcpStatus() + expect(status.running).toBe(false) + }) + + test("does not throw if not running", async () => { + await expect(MCP.stop()).resolves.toBeUndefined() + }) + }) + + describe("isRunning", () => { + test("returns false when not started", () => { + expect(MCP.isRunning()).toBe(false) + }) + + test("returns true when running", async () => { + await MCP.start({ cwd: tempDir }) + expect(MCP.isRunning()).toBe(true) + }) + + test("returns false after stop", async () => { + await MCP.start({ cwd: tempDir }) + await MCP.stop() + expect(MCP.isRunning()).toBe(false) + }) + }) + + describe("getPort", () => { + test("returns undefined when not running", () => { + expect(MCP.getPort()).toBeUndefined() + }) + + test("returns port when running", async () => { + const port = await MCP.start({ cwd: tempDir }) + expect(MCP.getPort()).toBe(port) + }) + }) + + describe("getClientCount", () => { + test("returns 0 when no clients connected", async () => { + await MCP.start({ cwd: tempDir }) + expect(MCP.getClientCount()).toBe(0) + }) + }) + + describe("getStatus", () => { + test("returns status summary", async () => { + await MCP.start({ cwd: tempDir }) + + const status = MCP.getStatus() + expect(status.server.running).toBe(true) + expect(typeof status.server.port).toBe("number") + expect(status.server.clients).toBe(0) + expect(Array.isArray(status.connectedServers)).toBe(true) + }) + }) +}) + +describe("MCP Protocol", () => { + test("responds to initialize request", async () => { + const port = await MCP.start({ cwd: tempDir }) + + const response = await sendJsonRpc(port, { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2024-11-05", + clientInfo: { name: "test", version: "1.0.0" }, + capabilities: {}, + }, + }) + + expect(response.result).toBeDefined() + expect(response.result.protocolVersion).toBe("2024-11-05") + expect(response.result.serverInfo.name).toBe("clawdstrike") + }) + + test("responds to tools/list request", async () => { + const port = await MCP.start({ cwd: tempDir }) + + const response = await sendJsonRpc(port, { + jsonrpc: "2.0", + id: 2, + method: "tools/list", + params: {}, + }) + + expect(response.result).toBeDefined() + expect(Array.isArray(response.result.tools)).toBe(true) + + const toolNames = response.result.tools.map((t: { name: string }) => t.name) + expect(toolNames).toContain("dispatch") + expect(toolNames).toContain("speculate") + expect(toolNames).toContain("gate") + }) + + test("responds to ping request", async () => { + const port = await MCP.start({ cwd: tempDir }) + + const response = await sendJsonRpc(port, { + jsonrpc: "2.0", + id: 3, + method: "ping", + params: {}, + }) + + expect(response.result).toEqual({ pong: true }) + }) + + test("returns error for unknown method", async () => { + const port = await MCP.start({ cwd: tempDir }) + + const response = await sendJsonRpc(port, { + jsonrpc: "2.0", + id: 4, + method: "unknown/method", + params: {}, + }) + + expect(response.error).toBeDefined() + expect(response.error.code).toBe(-32601) + expect(response.error.message).toContain("not found") + }) + + test("returns error for unknown tool", async () => { + const port = await MCP.start({ cwd: tempDir }) + + const response = await sendJsonRpc(port, { + jsonrpc: "2.0", + id: 5, + method: "tools/call", + params: { + name: "nonexistent-tool", + arguments: {}, + }, + }) + + expect(response.error).toBeDefined() + expect(response.error.code).toBe(-32602) + expect(response.error.message).toContain("Unknown tool") + }) +}) + +/** + * Helper to send JSON-RPC request and get response + */ +function sendJsonRpc(port: number, request: object): Promise<any> { + return new Promise((resolve, reject) => { + const socket = new Socket() + let buffer = "" + + socket.connect(port, "127.0.0.1", () => { + socket.write(JSON.stringify(request) + "\n") + }) + + socket.on("data", (data) => { + buffer += data.toString() + const lines = buffer.split("\n") + buffer = lines.pop() ?? "" + + for (const line of lines) { + if (line.trim()) { + try { + const response = JSON.parse(line) + socket.destroy() + resolve(response) + } catch { + // Continue waiting + } + } + } + }) + + socket.on("error", (err) => { + reject(err) + }) + + // Timeout after 5 seconds + setTimeout(() => { + socket.destroy() + reject(new Error("Timeout")) + }, 5000) + }) +} diff --git a/apps/terminal/test/modules.test.ts b/apps/terminal/test/modules.test.ts new file mode 100644 index 000000000..cb00fc14d --- /dev/null +++ b/apps/terminal/test/modules.test.ts @@ -0,0 +1,222 @@ +/** + * Module stub tests + * + * Verifies that all modules export correctly and stubs throw appropriate errors. + */ + +import { describe, expect, test } from "bun:test" + +// Import all modules to verify they compile +import { Router } from "../src/router" +import { Dispatcher } from "../src/dispatcher" +import { Workcell, PoolConfig } from "../src/workcell" +import { Verifier } from "../src/verifier" +import { Speculate } from "../src/speculate" +import { PatchLifecycle } from "../src/patch" +import { Beads } from "../src/beads" +import { Telemetry } from "../src/telemetry" +import { tools, getTool, dispatchTool, speculateTool, gateTool } from "../src/tools" + +// Import adapters and gates +import { adapters, getAdapter } from "../src/dispatcher/adapters" +import { gates, getGate } from "../src/verifier/gates" + +describe("Module exports", () => { + test("Router namespace exists", () => { + expect(Router).toBeDefined() + expect(Router.route).toBeFunction() + expect(Router.reroute).toBeFunction() + expect(Router.evaluateRules).toBeFunction() + }) + + test("Dispatcher namespace exists", () => { + expect(Dispatcher).toBeDefined() + expect(Dispatcher.execute).toBeFunction() + expect(Dispatcher.executeWithRetry).toBeFunction() + expect(Dispatcher.getAvailableAdapters).toBeFunction() + }) + + test("Workcell namespace exists", () => { + expect(Workcell).toBeDefined() + expect(Workcell.acquire).toBeFunction() + expect(Workcell.release).toBeFunction() + expect(Workcell.status).toBeFunction() + expect(Workcell.gc).toBeFunction() + expect(Workcell.destroyAll).toBeFunction() + }) + + test("PoolConfig schema works", () => { + const config = PoolConfig.parse({}) + expect(config.minSize).toBe(2) + expect(config.maxSize).toBe(10) + expect(config.preWarm).toBe(true) + }) + + test("Verifier namespace exists", () => { + expect(Verifier).toBeDefined() + expect(Verifier.run).toBeFunction() + expect(Verifier.runGate).toBeFunction() + expect(Verifier.getAvailableGates).toBeFunction() + expect(Verifier.calculateScore).toBeFunction() + }) + + test("Speculate namespace exists", () => { + expect(Speculate).toBeDefined() + expect(Speculate.run).toBeFunction() + expect(Speculate.vote).toBeFunction() + expect(Speculate.voteFirstPass).toBeFunction() + expect(Speculate.voteBestScore).toBeFunction() + }) + + test("PatchLifecycle namespace exists", () => { + expect(PatchLifecycle).toBeDefined() + expect(PatchLifecycle.capture).toBeFunction() + expect(PatchLifecycle.stage).toBeFunction() + expect(PatchLifecycle.approve).toBeFunction() + expect(PatchLifecycle.merge).toBeFunction() + }) + + test("Beads namespace exists", () => { + expect(Beads).toBeDefined() + expect(Beads.init).toBeFunction() + expect(Beads.query).toBeFunction() + expect(Beads.getReady).toBeFunction() + expect(Beads.create).toBeFunction() + expect(Beads.updateStatus).toBeFunction() + }) + + test("Telemetry namespace exists", () => { + expect(Telemetry).toBeDefined() + expect(Telemetry.init).toBeFunction() + expect(Telemetry.startRollout).toBeFunction() + expect(Telemetry.recordEvent).toBeFunction() + expect(Telemetry.completeRollout).toBeFunction() + }) +}) + +describe("Tools", () => { + test("tools array contains all tools", () => { + expect(tools).toHaveLength(3) + expect(tools.map((t) => t.name)).toEqual(["dispatch", "speculate", "gate"]) + }) + + test("getTool returns correct tool", () => { + expect(getTool("dispatch")).toBe(dispatchTool) + expect(getTool("speculate")).toBe(speculateTool) + expect(getTool("gate")).toBe(gateTool) + expect(getTool("nonexistent")).toBeUndefined() + }) + + test("dispatchTool has correct schema", () => { + expect(dispatchTool.name).toBe("dispatch") + expect(dispatchTool.parameters.required).toContain("prompt") + expect(dispatchTool.parameters.properties).toHaveProperty("toolchain") + }) + + test("speculateTool has correct schema", () => { + expect(speculateTool.name).toBe("speculate") + expect(speculateTool.parameters.required).toContain("prompt") + expect(speculateTool.parameters.properties).toHaveProperty("voteStrategy") + }) + + test("gateTool has correct schema", () => { + expect(gateTool.name).toBe("gate") + expect(gateTool.parameters.required).toEqual([]) + expect(gateTool.parameters.properties).toHaveProperty("gates") + }) +}) + +describe("Adapters", () => { + test("all adapters registered", () => { + expect(Object.keys(adapters)).toEqual(["codex", "claude", "opencode", "crush"]) + }) + + test("getAdapter returns correct adapter", () => { + expect(getAdapter("codex")?.info.id).toBe("codex") + expect(getAdapter("claude")?.info.id).toBe("claude") + expect(getAdapter("opencode")?.info.id).toBe("opencode") + expect(getAdapter("crush")?.info.id).toBe("crush") + expect(getAdapter("nonexistent")).toBeUndefined() + }) + + test("adapters have correct auth types", () => { + expect(getAdapter("codex")?.info.authType).toBe("oauth") + expect(getAdapter("claude")?.info.authType).toBe("oauth") + expect(getAdapter("opencode")?.info.authType).toBe("api_key") + expect(getAdapter("crush")?.info.authType).toBe("api_key") + }) +}) + +describe("Gates", () => { + test("all gates registered", () => { + expect(Object.keys(gates)).toEqual(["pytest", "mypy", "ruff", "clawdstrike"]) + }) + + test("getGate returns correct gate", () => { + expect(getGate("pytest")?.info.id).toBe("pytest") + expect(getGate("mypy")?.info.id).toBe("mypy") + expect(getGate("ruff")?.info.id).toBe("ruff") + expect(getGate("nonexistent")).toBeUndefined() + }) + + test("gates have correct critical flags", () => { + expect(getGate("pytest")?.info.critical).toBe(true) + expect(getGate("mypy")?.info.critical).toBe(false) + expect(getGate("ruff")?.info.critical).toBe(false) + }) +}) + +describe("Implemented modules", () => { + test("Router.route returns routing decision", async () => { + const task = { + prompt: "test", + context: { cwd: "/", projectId: "p" }, + } + const decision = await Router.route(task) + expect(decision.toolchain).toBeDefined() + expect(decision.strategy).toBeDefined() + expect(decision.gates).toBeDefined() + }) + + test("Verifier.runGate returns result for unknown gate", async () => { + const workcell = { + id: "123e4567-e89b-12d3-a456-426614174000", + name: "wc", + directory: "/", + branch: "main", + status: "warm" as const, + projectId: "p", + createdAt: 0, + useCount: 0, + } + const result = await Verifier.runGate(workcell, "unknown") + expect(result.passed).toBe(false) + expect(result.output).toContain("not found") + }) +}) + +describe("Stub errors", () => { + test("Dispatcher.execute returns error when adapter unavailable", async () => { + const result = await Dispatcher.execute({ + task: { prompt: "test", context: { cwd: "/", projectId: "p" } }, + workcell: { + id: "123e4567-e89b-12d3-a456-426614174000", + name: "wc", + directory: "/", + branch: "main", + status: "warm", + projectId: "p", + createdAt: 0, + useCount: 0, + }, + toolchain: "codex", + }) + // When adapter is not available (no CLI/auth), it should return error + expect(result.success).toBe(false) + expect(result.error).toBeDefined() + }) + + test("Workcell.acquire throws when git root not found", async () => { + await expect(Workcell.acquire("project", undefined, { cwd: "/nonexistent" })).rejects.toThrow() + }) +}) diff --git a/apps/terminal/test/router.test.ts b/apps/terminal/test/router.test.ts new file mode 100644 index 000000000..27b7dcb33 --- /dev/null +++ b/apps/terminal/test/router.test.ts @@ -0,0 +1,337 @@ +/** + * Router tests + * + * Tests for the routing rules engine and Router namespace. + */ + +import { describe, expect, test } from "bun:test" +import { Router } from "../src/router" +import { DEFAULT_RULES, matchesRule, evaluateRules, mergeActions, isValidToolchain } from "../src/router/rules" +import type { TaskInput } from "../src/types" +import type { RoutingRule } from "../src/router" + +// Helper to create minimal task input +function makeTask(overrides: Partial<TaskInput> = {}): TaskInput { + return { + prompt: "test prompt", + context: { + cwd: "/test", + projectId: "test-project", + }, + ...overrides, + } +} + +describe("Router Rules", () => { + describe("matchesRule", () => { + test("matches hint rule with wildcard", () => { + const rule: RoutingRule = { + name: "hint-test", + priority: 100, + match: { hint: "*" }, + action: {}, + } + + const taskWithHint = makeTask({ hint: "codex" }) + const taskWithoutHint = makeTask({}) + + expect(matchesRule(taskWithHint, rule)).toBe(true) + expect(matchesRule(taskWithoutHint, rule)).toBe(false) + }) + + test("matches specific hint", () => { + const rule: RoutingRule = { + name: "claude-hint", + priority: 100, + match: { hint: "claude" }, + action: {}, + } + + expect(matchesRule(makeTask({ hint: "claude" }), rule)).toBe(true) + expect(matchesRule(makeTask({ hint: "codex" }), rule)).toBe(false) + expect(matchesRule(makeTask({}), rule)).toBe(false) + }) + + test("matches labels", () => { + const rule: RoutingRule = { + name: "high-risk", + priority: 100, + match: { labels: ["dk_risk:high"] }, + action: {}, + } + + expect(matchesRule(makeTask({ labels: ["dk_risk:high"] }), rule)).toBe(true) + expect(matchesRule(makeTask({ labels: ["dk_risk:high", "other"] }), rule)).toBe(true) + expect(matchesRule(makeTask({ labels: ["dk_risk:low"] }), rule)).toBe(false) + expect(matchesRule(makeTask({}), rule)).toBe(false) + }) + + test("matches multiple labels (all must match)", () => { + const rule: RoutingRule = { + name: "multi-label", + priority: 100, + match: { labels: ["dk_risk:high", "dk_size:xs"] }, + action: {}, + } + + expect(matchesRule(makeTask({ labels: ["dk_risk:high", "dk_size:xs"] }), rule)).toBe(true) + expect(matchesRule(makeTask({ labels: ["dk_risk:high"] }), rule)).toBe(false) + expect(matchesRule(makeTask({ labels: ["dk_size:xs"] }), rule)).toBe(false) + }) + + test("matches file patterns", () => { + const rule: RoutingRule = { + name: "python-files", + priority: 100, + match: { filePatterns: ["**/*.py"] }, + action: {}, + } + + expect( + matchesRule(makeTask({ context: { cwd: "/", projectId: "p", files: ["foo.py"] } }), rule) + ).toBe(true) + expect( + matchesRule(makeTask({ context: { cwd: "/", projectId: "p", files: ["src/main.py"] } }), rule) + ).toBe(true) + expect( + matchesRule(makeTask({ context: { cwd: "/", projectId: "p", files: ["foo.ts"] } }), rule) + ).toBe(false) + expect(matchesRule(makeTask({}), rule)).toBe(false) + }) + + test("matches prompt patterns", () => { + const rule: RoutingRule = { + name: "refactor-prompt", + priority: 100, + match: { promptPatterns: ["refactor", "rewrite"] }, + action: {}, + } + + expect(matchesRule(makeTask({ prompt: "Please refactor this code" }), rule)).toBe(true) + expect(matchesRule(makeTask({ prompt: "Rewrite the function" }), rule)).toBe(true) + expect(matchesRule(makeTask({ prompt: "Add a new feature" }), rule)).toBe(false) + }) + + test("matches context size", () => { + const rule: RoutingRule = { + name: "small-prompt", + priority: 100, + match: { contextSize: { min: 10, max: 50 } }, + action: {}, + } + + expect(matchesRule(makeTask({ prompt: "a".repeat(30) }), rule)).toBe(true) + expect(matchesRule(makeTask({ prompt: "a".repeat(5) }), rule)).toBe(false) + expect(matchesRule(makeTask({ prompt: "a".repeat(100) }), rule)).toBe(false) + }) + }) + + describe("mergeActions", () => { + test("higher priority takes precedence", () => { + const actions = [ + { priority: 50, action: { toolchain: "claude" } }, + { priority: 100, action: { toolchain: "codex" } }, + ] + + const merged = mergeActions(actions) + expect(merged.toolchain).toBe("codex") + }) + + test("lower priority fills missing fields", () => { + const actions = [ + { priority: 100, action: { toolchain: "codex" } }, + { priority: 50, action: { toolchain: "claude", retries: 3 } }, + ] + + const merged = mergeActions(actions) + expect(merged.toolchain).toBe("codex") + expect(merged.retries).toBe(3) + }) + + test("gates accumulate", () => { + const actions = [ + { priority: 100, action: { gates: ["pytest"] } }, + { priority: 50, action: { gatesAdd: ["mypy", "ruff"] } }, + ] + + const merged = mergeActions(actions) + expect(merged.gates).toContain("pytest") + expect(merged.gates).toContain("mypy") + expect(merged.gates).toContain("ruff") + }) + + test("gatesRemove removes gates", () => { + const actions = [ + { priority: 100, action: { gates: ["pytest", "mypy", "ruff"] } }, + { priority: 50, action: { gatesRemove: ["ruff"] } }, + ] + + const merged = mergeActions(actions) + expect(merged.gates).toContain("pytest") + expect(merged.gates).toContain("mypy") + expect(merged.gates).not.toContain("ruff") + }) + }) + + describe("evaluateRules", () => { + test("applies matching rules in priority order", () => { + const task = makeTask({ labels: ["dk_risk:high"] }) + const rules: RoutingRule[] = [ + { + name: "default", + priority: 10, + match: {}, + action: { toolchain: "claude", retries: 1 }, + }, + { + name: "high-risk", + priority: 100, + match: { labels: ["dk_risk:high"] }, + action: { toolchain: "codex", retries: 3 }, + }, + ] + + const result = evaluateRules(task, rules) + expect(result.toolchain).toBe("codex") + expect(result.retries).toBe(3) + }) + + test("hint-override rule uses task hint as toolchain", () => { + const task = makeTask({ hint: "opencode" }) + const result = evaluateRules(task, DEFAULT_RULES) + expect(result.toolchain).toBe("opencode") + }) + }) + + describe("isValidToolchain", () => { + test("validates known toolchains", () => { + expect(isValidToolchain("codex")).toBe(true) + expect(isValidToolchain("claude")).toBe(true) + expect(isValidToolchain("opencode")).toBe(true) + expect(isValidToolchain("crush")).toBe(true) + expect(isValidToolchain("invalid")).toBe(false) + expect(isValidToolchain("")).toBe(false) + }) + }) +}) + +describe("Router namespace", () => { + describe("route", () => { + test("returns routing decision with default config", async () => { + const task = makeTask() + const decision = await Router.route(task) + + expect(decision.taskId).toBeDefined() + expect(decision.toolchain).toBe("claude") // default + expect(decision.strategy).toBe("single") + expect(decision.gates).toEqual(["pytest", "mypy", "ruff"]) + expect(decision.retries).toBe(2) + expect(decision.priority).toBe(50) + }) + + test("uses task ID if provided", async () => { + const taskId = crypto.randomUUID() + const task = makeTask({ id: taskId }) + const decision = await Router.route(task) + + expect(decision.taskId).toBe(taskId) + }) + + test("respects hint override", async () => { + const task = makeTask({ hint: "opencode" }) + const decision = await Router.route(task) + + expect(decision.toolchain).toBe("opencode") + }) + + test("applies high-risk speculate rule", async () => { + const task = makeTask({ labels: ["dk_risk:high"] }) + const decision = await Router.route(task) + + expect(decision.toolchain).toBe("codex") + expect(decision.strategy).toBe("speculate") + expect(decision.speculation).toBeDefined() + expect(decision.speculation?.count).toBe(3) + expect(decision.speculation?.toolchains).toContain("codex") + expect(decision.speculation?.voteStrategy).toBe("first_pass") + }) + + test("applies small-fast-path rule", async () => { + const task = makeTask({ labels: ["dk_size:xs"] }) + const decision = await Router.route(task) + + expect(decision.toolchain).toBe("opencode") + expect(decision.strategy).toBe("single") + expect(decision.gates).toContain("ruff") + expect(decision.retries).toBe(1) + }) + }) + + describe("reroute", () => { + test("returns null when no reroute possible", async () => { + const task = makeTask() + // Use a custom config with only critical gates to ensure gate reduction is exhausted + const config = { + rules: [], + defaults: { + toolchain: "claude" as const, + gates: ["pytest"], // Only critical gate, can't reduce further + retries: 1, + }, + } + const previousResult = { + taskId: "test-task", + workcellId: "test-workcell", + success: false, + toolchain: "crush" as const, // Last in fallback order + output: "failed", + telemetry: { startedAt: 0, completedAt: 0 }, + } + + const decision = await Router.reroute(task, previousResult, config) + expect(decision).toBeNull() + }) + + test("tries next toolchain on toolchain error", async () => { + const task = makeTask() + const previousResult = { + taskId: "test-task", + workcellId: "test-workcell", + success: false, + toolchain: "codex" as const, + output: "", + error: "Toolchain not available", + telemetry: { startedAt: 0, completedAt: 0 }, + } + + const decision = await Router.reroute(task, previousResult) + expect(decision).not.toBeNull() + expect(decision?.toolchain).toBe("claude") // next in fallback order + }) + + test("reduces gates on gate failure", async () => { + const task = makeTask() + const previousResult = { + taskId: "test-task", + workcellId: "test-workcell", + success: false, + toolchain: "claude" as const, + output: "mypy failed", + telemetry: { startedAt: 0, completedAt: 0 }, + } + + const decision = await Router.reroute(task, previousResult) + expect(decision).not.toBeNull() + expect(decision?.gates).toEqual(["pytest"]) // only critical gates + }) + }) + + describe("getDefaultConfig", () => { + test("returns default configuration", () => { + const config = Router.getDefaultConfig() + expect(config.defaults.toolchain).toBe("claude") + expect(config.defaults.gates).toEqual(["pytest", "mypy", "ruff"]) + expect(config.defaults.retries).toBe(2) + }) + }) +}) diff --git a/apps/terminal/test/speculate.test.ts b/apps/terminal/test/speculate.test.ts new file mode 100644 index 000000000..6439ee980 --- /dev/null +++ b/apps/terminal/test/speculate.test.ts @@ -0,0 +1,339 @@ +/** + * Speculate tests + * + * Tests for the Speculate+Vote module including voting strategies + * and orchestrator functionality. + */ + +import { describe, expect, test } from "bun:test" +import { Speculate, Voter } from "../src/speculate" +import type { CandidateResult } from "../src/speculate/voter" +import type { ExecutionResult, GateResults, WorkcellId } from "../src/types" + +// Helper to create mock execution result +function makeExecutionResult( + overrides: Partial<ExecutionResult> = {} +): ExecutionResult { + return { + taskId: crypto.randomUUID(), + workcellId: crypto.randomUUID() as WorkcellId, + toolchain: "claude", + success: true, + output: "success", + telemetry: { + startedAt: Date.now() - 1000, + completedAt: Date.now(), + }, + ...overrides, + } +} + +// Helper to create mock gate results +function makeGateResults( + overrides: Partial<GateResults> = {} +): GateResults { + return { + allPassed: true, + criticalPassed: true, + results: [], + score: 100, + summary: "All gates passed", + ...overrides, + } +} + +// Helper to create mock candidate +function makeCandidate( + overrides: Partial<{ + workcellId: string + toolchain: string + success: boolean + allPassed: boolean + score: number + completedAt: number + patch: string + }> = {} +): CandidateResult { + const workcellId = (overrides.workcellId || crypto.randomUUID()) as WorkcellId + return { + workcellId, + toolchain: (overrides.toolchain || "claude") as CandidateResult["toolchain"], + result: makeExecutionResult({ + workcellId, + toolchain: (overrides.toolchain || "claude") as CandidateResult["toolchain"], + success: overrides.success ?? true, + patch: overrides.patch, + telemetry: { + startedAt: Date.now() - 1000, + completedAt: overrides.completedAt ?? Date.now(), + }, + }), + gateResults: makeGateResults({ + allPassed: overrides.allPassed ?? true, + score: overrides.score ?? 100, + }), + } +} + +describe("Voter", () => { + describe("select", () => { + test("returns undefined for empty candidates", () => { + expect(Voter.select([], "first_pass")).toBeUndefined() + }) + + test("returns single candidate", () => { + const candidate = makeCandidate() + const result = Voter.select([candidate], "first_pass") + expect(result).toBe(candidate) + }) + + test("filters out failed executions", () => { + const failed = makeCandidate({ success: false }) + const passed = makeCandidate({ success: true }) + + const result = Voter.select([failed, passed], "first_pass") + expect(result?.workcellId).toBe(passed.workcellId) + }) + + test("filters out failed gates", () => { + const failedGates = makeCandidate({ allPassed: false }) + const passedGates = makeCandidate({ allPassed: true }) + + const result = Voter.select([failedGates, passedGates], "first_pass") + expect(result?.workcellId).toBe(passedGates.workcellId) + }) + + test("returns undefined when all fail", () => { + const failed1 = makeCandidate({ success: false }) + const failed2 = makeCandidate({ allPassed: false }) + + expect(Voter.select([failed1, failed2], "first_pass")).toBeUndefined() + }) + }) + + describe("selectFirstPass", () => { + test("selects earliest completed candidate", () => { + const early = makeCandidate({ completedAt: 1000 }) + const late = makeCandidate({ completedAt: 2000 }) + + const result = Voter.selectFirstPass([late, early]) + expect(result.workcellId).toBe(early.workcellId) + }) + }) + + describe("selectBestScore", () => { + test("selects highest scoring candidate", () => { + const lowScore = makeCandidate({ score: 80 }) + const highScore = makeCandidate({ score: 100 }) + const midScore = makeCandidate({ score: 90 }) + + const result = Voter.selectBestScore([lowScore, highScore, midScore]) + expect(result.workcellId).toBe(highScore.workcellId) + }) + }) + + describe("selectConsensus", () => { + test("selects candidate with most similar patches", () => { + const patchA = "line 1\nline 2\nline 3" + const patchB = "line 1\nline 2\nline 3" // Same as A + const patchC = "completely different\ncontent" + + const candidateA = makeCandidate({ patch: patchA }) + const candidateB = makeCandidate({ patch: patchB }) + const candidateC = makeCandidate({ patch: patchC }) + + const result = Voter.selectConsensus([candidateA, candidateB, candidateC]) + // A and B are identical, so they should have higher similarity + expect([candidateA.workcellId, candidateB.workcellId]).toContain( + result.workcellId + ) + }) + }) + + describe("calculateSimilarity", () => { + test("returns 1 for identical strings", () => { + expect(Voter.calculateSimilarity("abc", "abc")).toBe(1) + }) + + test("returns 0 for completely different strings", () => { + expect(Voter.calculateSimilarity("abc", "xyz")).toBe(0) + }) + + test("returns 0 for empty strings", () => { + expect(Voter.calculateSimilarity("", "abc")).toBe(0) + expect(Voter.calculateSimilarity("abc", "")).toBe(0) + }) + + test("returns 1 for both empty", () => { + expect(Voter.calculateSimilarity("", "")).toBe(1) + }) + + test("calculates partial similarity", () => { + const a = "line1\nline2\nline3" + const b = "line1\nline2\ndifferent" + const sim = Voter.calculateSimilarity(a, b) + // 2 common lines out of 4 unique = 0.5 + expect(sim).toBeCloseTo(0.5, 1) + }) + }) + + describe("getVoteTally", () => { + test("returns scores for each candidate", () => { + const candidates = [ + makeCandidate({ score: 100 }), + makeCandidate({ score: 80 }), + ] + + const tally = Voter.getVoteTally(candidates, "best_score") + + expect(tally.size).toBe(2) + for (const candidate of candidates) { + expect(tally.has(candidate.workcellId)).toBe(true) + } + }) + + test("marks failed candidates", () => { + const failed = makeCandidate({ success: false }) + const tally = Voter.getVoteTally([failed], "first_pass") + + expect(tally.get(failed.workcellId)?.reason).toBe("execution failed") + }) + }) +}) + +describe("Speculate namespace", () => { + describe("vote", () => { + test("votes using specified strategy", () => { + const candidates = [ + { + workcellId: "wc1" as WorkcellId, + toolchain: "claude" as const, + result: makeExecutionResult({ success: true }), + gateResults: makeGateResults({ allPassed: true, score: 80 }), + }, + { + workcellId: "wc2" as WorkcellId, + toolchain: "codex" as const, + result: makeExecutionResult({ success: true }), + gateResults: makeGateResults({ allPassed: true, score: 100 }), + }, + ] + + const winner = Speculate.vote(candidates, "best_score") + expect(winner?.workcellId).toBe("wc2") + }) + + test("returns undefined for no passing candidates", () => { + const candidates = [ + { + workcellId: "wc1" as WorkcellId, + toolchain: "claude" as const, + result: makeExecutionResult({ success: false }), + gateResults: makeGateResults({ allPassed: false }), + }, + ] + + expect(Speculate.vote(candidates, "first_pass")).toBeUndefined() + }) + }) + + describe("voteFirstPass", () => { + test("returns first passing candidate", () => { + const candidates = [ + { + workcellId: "wc1" as WorkcellId, + toolchain: "claude" as const, + result: makeExecutionResult({ + success: true, + telemetry: { startedAt: 0, completedAt: 1000 }, + }), + gateResults: makeGateResults({ allPassed: true }), + }, + { + workcellId: "wc2" as WorkcellId, + toolchain: "codex" as const, + result: makeExecutionResult({ + success: true, + telemetry: { startedAt: 0, completedAt: 500 }, + }), + gateResults: makeGateResults({ allPassed: true }), + }, + ] + + const winner = Speculate.voteFirstPass(candidates) + expect(winner?.workcellId).toBe("wc2") // Earlier completion + }) + }) + + describe("voteBestScore", () => { + test("returns highest scoring candidate", () => { + const candidates = [ + { + workcellId: "wc1" as WorkcellId, + toolchain: "claude" as const, + result: makeExecutionResult({ success: true }), + gateResults: makeGateResults({ allPassed: true, score: 90 }), + }, + { + workcellId: "wc2" as WorkcellId, + toolchain: "codex" as const, + result: makeExecutionResult({ success: true }), + gateResults: makeGateResults({ allPassed: true, score: 100 }), + }, + ] + + const winner = Speculate.voteBestScore(candidates) + expect(winner?.workcellId).toBe("wc2") + }) + }) + + describe("voteConsensus", () => { + test("returns most similar candidate", () => { + const samePatch = "same content" + const candidates = [ + { + workcellId: "wc1" as WorkcellId, + toolchain: "claude" as const, + result: makeExecutionResult({ success: true, patch: samePatch }), + gateResults: makeGateResults({ allPassed: true }), + }, + { + workcellId: "wc2" as WorkcellId, + toolchain: "codex" as const, + result: makeExecutionResult({ success: true, patch: samePatch }), + gateResults: makeGateResults({ allPassed: true }), + }, + { + workcellId: "wc3" as WorkcellId, + toolchain: "opencode" as const, + result: makeExecutionResult({ success: true, patch: "different" }), + gateResults: makeGateResults({ allPassed: true }), + }, + ] + + const winner = Speculate.voteConsensus(candidates) + // wc1 and wc2 have the same patch, so one of them should win + expect(winner).toBeDefined() + expect(["wc1", "wc2"]).toContain(winner!.workcellId) + }) + }) + + describe("calculateSimilarity", () => { + test("calculates similarity correctly", () => { + expect(Speculate.calculateSimilarity("a\nb", "a\nb")).toBe(1) + expect(Speculate.calculateSimilarity("a", "b")).toBe(0) + }) + }) + + describe("cancel", () => { + test("returns false for non-existent task", () => { + expect(Speculate.cancel("non-existent")).toBe(false) + }) + }) + + describe("getActive", () => { + test("returns empty array when no active speculations", () => { + expect(Speculate.getActive()).toEqual([]) + }) + }) +}) diff --git a/apps/terminal/test/telemetry.test.ts b/apps/terminal/test/telemetry.test.ts new file mode 100644 index 000000000..927401ca6 --- /dev/null +++ b/apps/terminal/test/telemetry.test.ts @@ -0,0 +1,332 @@ +/** + * Telemetry tests + * + * Tests for the Telemetry execution tracking system. + */ + +import { describe, expect, test, beforeEach, afterEach } from "bun:test" +import * as fs from "node:fs/promises" +import * as path from "node:path" +import * as os from "node:os" +import { Telemetry } from "../src/telemetry" +import type { TaskId, RoutingDecision, ExecutionResult, GateResults } from "../src/types" + +// Create temp directory for tests +let tempDir: string + +beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdstrike-telemetry-test-")) + Telemetry.reset() + Telemetry.init({ outputDir: tempDir, enabled: true }) +}) + +afterEach(async () => { + Telemetry.reset() + await fs.rm(tempDir, { recursive: true, force: true }) +}) + +// Helper to create mock task ID +function makeTaskId(): TaskId { + return crypto.randomUUID() as TaskId +} + +// Helper to create mock routing decision +function makeRoutingDecision(taskId: TaskId): RoutingDecision { + return { + taskId, + toolchain: "claude", + strategy: "single", + gates: ["pytest", "mypy"], + retries: 2, + priority: 50, + } +} + +// Helper to create mock execution result +function makeExecutionResult(taskId: TaskId): ExecutionResult { + return { + taskId, + workcellId: crypto.randomUUID(), + toolchain: "claude", + success: true, + output: "Task completed successfully", + telemetry: { + startedAt: Date.now() - 1000, + completedAt: Date.now(), + model: "claude-3-opus", + tokens: { input: 100, output: 50 }, + cost: 0.01, + }, + } +} + +// Helper to create mock gate results +function makeGateResults(): GateResults { + return { + allPassed: true, + criticalPassed: true, + results: [], + score: 100, + summary: "All gates passed", + } +} + +describe("Telemetry", () => { + describe("init", () => { + test("initializes telemetry", () => { + expect(Telemetry.isInitialized()).toBe(true) + }) + }) + + describe("startRollout", () => { + test("creates new rollout", () => { + const taskId = makeTaskId() + const rollout = Telemetry.startRollout(taskId) + + expect(rollout.id).toBeDefined() + expect(rollout.taskId).toBe(taskId) + expect(rollout.status).toBe("pending") + expect(rollout.events).toEqual([]) + }) + + test("tracks active rollout", () => { + const taskId = makeTaskId() + const rollout = Telemetry.startRollout(taskId) + + expect(Telemetry.getActive()).toContain(rollout.id) + }) + }) + + describe("recordEvent", () => { + test("adds event to rollout", () => { + const taskId = makeTaskId() + const rollout = Telemetry.startRollout(taskId) + + Telemetry.recordEvent(rollout.id, { + type: "routing_started", + taskId, + }) + + expect(rollout.events).toHaveLength(1) + expect(rollout.events[0].type).toBe("routing_started") + expect(rollout.events[0].timestamp).toBeDefined() + }) + + test("silently ignores unknown rollout", () => { + // Should not throw + Telemetry.recordEvent("unknown-id", { type: "test" }) + }) + }) + + describe("updateStatus", () => { + test("updates rollout status", () => { + const taskId = makeTaskId() + const rollout = Telemetry.startRollout(taskId) + + Telemetry.updateStatus(rollout.id, "executing") + + expect(rollout.status).toBe("executing") + }) + }) + + describe("setRouting", () => { + test("sets routing decision", () => { + const taskId = makeTaskId() + const rollout = Telemetry.startRollout(taskId) + const routing = makeRoutingDecision(taskId) + + Telemetry.setRouting(rollout.id, routing) + + expect(rollout.routing).toBe(routing) + }) + }) + + describe("setExecution", () => { + test("sets execution result", () => { + const taskId = makeTaskId() + const rollout = Telemetry.startRollout(taskId) + const execution = makeExecutionResult(taskId) + + Telemetry.setExecution(rollout.id, execution) + + expect(rollout.execution).toBe(execution) + }) + }) + + describe("setVerification", () => { + test("sets verification results", () => { + const taskId = makeTaskId() + const rollout = Telemetry.startRollout(taskId) + const verification = makeGateResults() + + Telemetry.setVerification(rollout.id, verification) + + expect(rollout.verification).toBe(verification) + }) + }) + + describe("completeRollout", () => { + test("completes and saves rollout", async () => { + const taskId = makeTaskId() + const rollout = Telemetry.startRollout(taskId) + + const filePath = await Telemetry.completeRollout(rollout.id) + + expect(filePath).toContain(rollout.id) + expect(Telemetry.getActive()).not.toContain(rollout.id) + + // Verify file exists + const exists = await Bun.file(filePath).exists() + expect(exists).toBe(true) + }) + + test("throws for unknown rollout", async () => { + expect(Telemetry.completeRollout("unknown-id")).rejects.toThrow( + "Rollout not found" + ) + }) + + test("sets completedAt", async () => { + const taskId = makeTaskId() + const rollout = Telemetry.startRollout(taskId) + + await Telemetry.completeRollout(rollout.id) + + // Read from disk + const saved = await Telemetry.getRollout(rollout.id) + expect(saved?.completedAt).toBeDefined() + }) + }) + + describe("getRollout", () => { + test("returns active rollout", async () => { + const taskId = makeTaskId() + const rollout = Telemetry.startRollout(taskId) + + const found = await Telemetry.getRollout(rollout.id) + expect(found?.id).toBe(rollout.id) + }) + + test("returns saved rollout", async () => { + const taskId = makeTaskId() + const rollout = Telemetry.startRollout(taskId) + Telemetry.setRouting(rollout.id, makeRoutingDecision(taskId)) + + await Telemetry.completeRollout(rollout.id) + + const found = await Telemetry.getRollout(rollout.id) + expect(found?.id).toBe(rollout.id) + expect(found?.routing).toBeDefined() + }) + + test("returns undefined for unknown rollout", async () => { + const found = await Telemetry.getRollout("unknown-id") + expect(found).toBeUndefined() + }) + }) + + describe("listRollouts", () => { + test("returns empty for no rollouts", async () => { + const rollouts = await Telemetry.listRollouts() + expect(rollouts).toEqual([]) + }) + + test("returns all saved rollouts", async () => { + const taskId1 = makeTaskId() + const taskId2 = makeTaskId() + + const r1 = Telemetry.startRollout(taskId1) + await Telemetry.completeRollout(r1.id) + + const r2 = Telemetry.startRollout(taskId2) + await Telemetry.completeRollout(r2.id) + + const rollouts = await Telemetry.listRollouts() + expect(rollouts).toHaveLength(2) + }) + + test("filters by taskId", async () => { + const taskId1 = makeTaskId() + const taskId2 = makeTaskId() + + const r1 = Telemetry.startRollout(taskId1) + await Telemetry.completeRollout(r1.id) + + const r2 = Telemetry.startRollout(taskId2) + await Telemetry.completeRollout(r2.id) + + const filtered = await Telemetry.listRollouts(taskId1) + expect(filtered).toHaveLength(1) + expect(filtered[0].taskId).toBe(taskId1) + }) + + test("sorts by startedAt descending", async () => { + const taskId = makeTaskId() + + const r1 = Telemetry.startRollout(taskId) + await Telemetry.completeRollout(r1.id) + + // Small delay to ensure different timestamps + await new Promise((r) => setTimeout(r, 10)) + + const r2 = Telemetry.startRollout(taskId) + await Telemetry.completeRollout(r2.id) + + const rollouts = await Telemetry.listRollouts() + expect(rollouts[0].id).toBe(r2.id) // Most recent first + }) + }) + + describe("toAnalytics", () => { + test("converts rollout to analytics event", () => { + const taskId = makeTaskId() + const rollout = Telemetry.startRollout(taskId) + + Telemetry.setRouting(rollout.id, makeRoutingDecision(taskId)) + Telemetry.setExecution(rollout.id, makeExecutionResult(taskId)) + Telemetry.setVerification(rollout.id, makeGateResults()) + Telemetry.updateStatus(rollout.id, "completed") + rollout.completedAt = Date.now() + + const event = Telemetry.toAnalytics(rollout) + + expect(event.event).toBe("clawdstrike_execution") + expect(event.properties.taskId).toBe(taskId) + expect(event.properties.toolchain).toBe("claude") + expect(event.properties.strategy).toBe("single") + expect(event.properties.outcome).toBe("completed") + expect(event.properties.gateScore).toBe(100) + expect(event.properties.tokensUsed).toBe(150) + expect(event.properties.cost).toBe(0.01) + }) + }) + + describe("exportAnalytics", () => { + test("converts multiple rollouts", () => { + const taskId1 = makeTaskId() + const taskId2 = makeTaskId() + + const r1 = Telemetry.startRollout(taskId1) + const r2 = Telemetry.startRollout(taskId2) + + const events = Telemetry.exportAnalytics([r1, r2]) + + expect(events).toHaveLength(2) + expect(events[0].properties.taskId).toBe(taskId1) + expect(events[1].properties.taskId).toBe(taskId2) + }) + }) + + describe("disabled telemetry", () => { + test("returns empty path when disabled", async () => { + Telemetry.reset() + Telemetry.init({ outputDir: tempDir, enabled: false }) + + const taskId = makeTaskId() + const rollout = Telemetry.startRollout(taskId) + const filePath = await Telemetry.completeRollout(rollout.id) + + expect(filePath).toBe("") + }) + }) +}) diff --git a/apps/terminal/test/tui.test.ts b/apps/terminal/test/tui.test.ts new file mode 100644 index 000000000..c5808676d --- /dev/null +++ b/apps/terminal/test/tui.test.ts @@ -0,0 +1,295 @@ +/** + * TUI tests + * + * Tests for the Terminal User Interface formatting. + */ + +import { describe, expect, test, beforeEach } from "bun:test" +import { TUI } from "../src/tui" +import type { GateResult, GateResults, ExecutionResult, RoutingDecision } from "../src/types" + +describe("TUI", () => { + beforeEach(() => { + TUI.setColors(false) // Disable colors for testing + }) + + describe("formatStatus", () => { + test("formats pending status", () => { + expect(TUI.formatStatus("pending")).toContain("pending") + }) + + test("formats completed status", () => { + expect(TUI.formatStatus("completed")).toContain("completed") + expect(TUI.formatStatus("completed")).toContain("✓") + }) + + test("formats failed status", () => { + expect(TUI.formatStatus("failed")).toContain("failed") + expect(TUI.formatStatus("failed")).toContain("✗") + }) + + test("formats all statuses", () => { + const statuses = ["pending", "routing", "executing", "verifying", "completed", "failed", "cancelled"] as const + for (const status of statuses) { + expect(TUI.formatStatus(status)).toContain(status) + } + }) + }) + + describe("formatToolchain", () => { + test("formats codex", () => { + expect(TUI.formatToolchain("codex")).toBe("codex") + }) + + test("formats claude", () => { + expect(TUI.formatToolchain("claude")).toBe("claude") + }) + + test("formats opencode", () => { + expect(TUI.formatToolchain("opencode")).toBe("opencode") + }) + + test("formats crush", () => { + expect(TUI.formatToolchain("crush")).toBe("crush") + }) + }) + + describe("formatGateResult", () => { + test("formats passed gate", () => { + const result: GateResult = { + gate: "pytest", + passed: true, + critical: true, + output: "", + timing: { startedAt: 0, completedAt: 100 }, + } + + const formatted = TUI.formatGateResult(result) + expect(formatted).toContain("✓") + expect(formatted).toContain("pytest") + expect(formatted).toContain("100ms") + }) + + test("formats failed gate", () => { + const result: GateResult = { + gate: "mypy", + passed: false, + critical: true, + output: "", + timing: { startedAt: 0, completedAt: 500 }, + } + + const formatted = TUI.formatGateResult(result) + expect(formatted).toContain("✗") + expect(formatted).toContain("mypy") + }) + + test("includes error count", () => { + const result: GateResult = { + gate: "ruff", + passed: false, + critical: false, + output: "", + diagnostics: [ + { severity: "error", message: "Error 1" }, + { severity: "error", message: "Error 2" }, + { severity: "warning", message: "Warning 1" }, + ], + timing: { startedAt: 0, completedAt: 200 }, + } + + const formatted = TUI.formatGateResult(result) + expect(formatted).toContain("2 errors") + expect(formatted).toContain("1 warning") + }) + }) + + describe("formatGateResults", () => { + test("formats passing results", () => { + const results: GateResults = { + allPassed: true, + criticalPassed: true, + results: [ + { gate: "pytest", passed: true, critical: true, output: "", timing: { startedAt: 0, completedAt: 100 } }, + { gate: "mypy", passed: true, critical: true, output: "", timing: { startedAt: 0, completedAt: 200 } }, + ], + score: 100, + summary: "All gates passed", + } + + const formatted = TUI.formatGateResults(results) + expect(formatted).toContain("Gates:") + expect(formatted).toContain("100/100") + expect(formatted).toContain("pytest") + expect(formatted).toContain("mypy") + }) + + test("formats failing results", () => { + const results: GateResults = { + allPassed: false, + criticalPassed: false, + results: [ + { gate: "pytest", passed: false, critical: true, output: "", timing: { startedAt: 0, completedAt: 100 } }, + ], + score: 0, + summary: "Critical gate failed: pytest", + } + + const formatted = TUI.formatGateResults(results) + expect(formatted).toContain("0/100") + }) + }) + + describe("formatExecutionResult", () => { + test("formats successful execution", () => { + const result: ExecutionResult = { + taskId: "task-123", + workcellId: "wc-456", + toolchain: "claude", + success: true, + output: "Done", + telemetry: { + startedAt: 0, + completedAt: 5000, + model: "claude-3-opus", + tokens: { input: 100, output: 50 }, + cost: 0.01, + }, + } + + const formatted = TUI.formatExecutionResult(result) + expect(formatted).toContain("✓") + expect(formatted).toContain("claude") + expect(formatted).toContain("claude-3-opus") + expect(formatted).toContain("100 in / 50 out") + expect(formatted).toContain("$0.01") + }) + + test("formats failed execution", () => { + const result: ExecutionResult = { + taskId: "task-123", + workcellId: "wc-456", + toolchain: "codex", + success: false, + output: "", + error: "Timeout exceeded", + telemetry: { + startedAt: 0, + completedAt: 60000, + }, + } + + const formatted = TUI.formatExecutionResult(result) + expect(formatted).toContain("✗") + expect(formatted).toContain("Timeout exceeded") + }) + }) + + describe("formatRouting", () => { + test("formats single strategy", () => { + const decision: RoutingDecision = { + taskId: "task-123", + toolchain: "claude", + strategy: "single", + gates: ["pytest", "mypy"], + retries: 2, + priority: 50, + } + + const formatted = TUI.formatRouting(decision) + expect(formatted).toContain("claude") + expect(formatted).toContain("single") + expect(formatted).toContain("pytest, mypy") + }) + + test("formats speculate strategy", () => { + const decision: RoutingDecision = { + taskId: "task-123", + toolchain: "codex", + strategy: "speculate", + gates: ["pytest"], + retries: 1, + priority: 100, + speculation: { + count: 3, + toolchains: ["codex", "claude", "opencode"], + voteStrategy: "first_pass", + timeout: 300000, + }, + } + + const formatted = TUI.formatRouting(decision) + expect(formatted).toContain("speculate") + expect(formatted).toContain("codex") + expect(formatted).toContain("claude") + expect(formatted).toContain("first_pass") + }) + }) + + describe("formatDuration", () => { + test("formats milliseconds", () => { + expect(TUI.formatDuration(500)).toBe("500ms") + }) + + test("formats seconds", () => { + expect(TUI.formatDuration(5000)).toBe("5.0s") + }) + + test("formats minutes", () => { + expect(TUI.formatDuration(125000)).toBe("2m 5s") + }) + }) + + describe("message helpers", () => { + test("success includes checkmark", () => { + expect(TUI.success("Done")).toContain("✓") + expect(TUI.success("Done")).toContain("Done") + }) + + test("error includes cross", () => { + expect(TUI.error("Failed")).toContain("✗") + expect(TUI.error("Failed")).toContain("Failed") + }) + + test("warning includes warning icon", () => { + expect(TUI.warning("Caution")).toContain("⚠") + }) + + test("info includes info icon", () => { + expect(TUI.info("Note")).toContain("ℹ") + }) + }) + + describe("formatTable", () => { + test("formats key-value pairs", () => { + const rows: Array<[string, string]> = [ + ["Name", "Test"], + ["Status", "Active"], + ] + + const formatted = TUI.formatTable(rows) + expect(formatted).toContain("Name") + expect(formatted).toContain("Test") + expect(formatted).toContain("Status") + expect(formatted).toContain("Active") + }) + + test("supports indent", () => { + const rows: Array<[string, string]> = [["Key", "Value"]] + const formatted = TUI.formatTable(rows, { indent: 2 }) + expect(formatted.startsWith(" ")).toBe(true) + }) + }) + + describe("colors", () => { + test("can enable colors", () => { + TUI.setColors(true) + expect(TUI.colorsEnabled()).toBe(true) + }) + + test("can disable colors", () => { + TUI.setColors(false) + expect(TUI.colorsEnabled()).toBe(false) + }) + }) +}) diff --git a/apps/terminal/test/types.test.ts b/apps/terminal/test/types.test.ts new file mode 100644 index 000000000..74957816b --- /dev/null +++ b/apps/terminal/test/types.test.ts @@ -0,0 +1,353 @@ +/** + * Type validation tests + * + * Verifies that Zod schemas validate correctly and type exports work. + */ + +import { describe, expect, test } from "bun:test" +import { + TaskId, + WorkcellId, + BeadId, + Toolchain, + TaskInput, + TaskStatus, + GateResult, + GateResults, + WorkcellInfo, + WorkcellStatus, + SpeculationConfig, + RoutingDecision, + Patch, + PatchStatus, + Bead, + BeadStatus, +} from "../src/types" + +describe("Identifiers", () => { + test("TaskId validates UUIDs", () => { + const valid = "123e4567-e89b-12d3-a456-426614174000" + expect(TaskId.parse(valid)).toBe(valid) + + expect(() => TaskId.parse("not-a-uuid")).toThrow() + }) + + test("WorkcellId validates UUIDs", () => { + const valid = "550e8400-e29b-41d4-a716-446655440000" + expect(WorkcellId.parse(valid)).toBe(valid) + }) + + test("BeadId validates project-number format", () => { + expect(BeadId.parse("PROJ-123")).toBe("PROJ-123") + expect(BeadId.parse("ABC-1")).toBe("ABC-1") + + expect(() => BeadId.parse("invalid")).toThrow() + expect(() => BeadId.parse("proj-123")).toThrow() // lowercase + expect(() => BeadId.parse("PROJ123")).toThrow() // missing dash + }) +}) + +describe("Toolchain", () => { + test("validates valid toolchains", () => { + expect(Toolchain.parse("codex")).toBe("codex") + expect(Toolchain.parse("claude")).toBe("claude") + expect(Toolchain.parse("opencode")).toBe("opencode") + expect(Toolchain.parse("crush")).toBe("crush") + }) + + test("rejects invalid toolchains", () => { + expect(() => Toolchain.parse("invalid")).toThrow() + expect(() => Toolchain.parse("")).toThrow() + }) +}) + +describe("TaskInput", () => { + test("validates minimal task input", () => { + const task = { + prompt: "Fix the bug", + context: { + cwd: "/project", + projectId: "my-project", + }, + } + + const parsed = TaskInput.parse(task) + expect(parsed.prompt).toBe("Fix the bug") + expect(parsed.context.cwd).toBe("/project") + }) + + test("validates full task input", () => { + const task = { + id: "123e4567-e89b-12d3-a456-426614174000", + prompt: "Implement feature X", + context: { + cwd: "/project", + projectId: "my-project", + branch: "feature/x", + files: ["src/main.ts"], + env: { NODE_ENV: "test" }, + }, + labels: ["dk_risk:high", "dk_size:m"], + hint: "codex", + gates: ["pytest", "mypy"], + beadId: "PROJ-42", + timeout: 300000, + } + + const parsed = TaskInput.parse(task) + expect(parsed.labels).toEqual(["dk_risk:high", "dk_size:m"]) + expect(parsed.hint).toBe("codex") + }) + + test("rejects empty prompt", () => { + const task = { + prompt: "", + context: { cwd: "/", projectId: "p" }, + } + expect(() => TaskInput.parse(task)).toThrow() + }) +}) + +describe("TaskStatus", () => { + test("validates all status values", () => { + const statuses = [ + "pending", + "routing", + "executing", + "verifying", + "completed", + "failed", + "cancelled", + ] as const + + for (const status of statuses) { + expect(TaskStatus.parse(status)).toBe(status) + } + }) +}) + +describe("WorkcellInfo", () => { + test("validates workcell info", () => { + const info = { + id: "550e8400-e29b-41d4-a716-446655440000", + name: "workcell-1", + directory: "/tmp/workcells/wc-1", + branch: "main", + status: "warm", + projectId: "my-project", + createdAt: Date.now(), + } + + const parsed = WorkcellInfo.parse(info) + expect(parsed.status).toBe("warm") + expect(parsed.useCount).toBe(0) // default + }) + + test("validates all workcell statuses", () => { + const statuses = ["creating", "warm", "in_use", "cleaning", "destroyed"] as const + + for (const status of statuses) { + expect(WorkcellStatus.parse(status)).toBe(status) + } + }) +}) + +describe("SpeculationConfig", () => { + test("validates speculation config", () => { + const config = { + count: 3, + toolchains: ["codex", "claude", "opencode"], + voteStrategy: "first_pass", + } + + const parsed = SpeculationConfig.parse(config) + expect(parsed.count).toBe(3) + expect(parsed.timeout).toBe(300000) // default + }) + + test("rejects invalid count", () => { + const config = { + count: 1, // min is 2 + toolchains: ["codex"], + voteStrategy: "first_pass", + } + + expect(() => SpeculationConfig.parse(config)).toThrow() + }) +}) + +describe("GateResult", () => { + test("validates gate result", () => { + const result = { + gate: "pytest", + passed: true, + critical: true, + output: "All tests passed", + timing: { + startedAt: Date.now() - 1000, + completedAt: Date.now(), + }, + } + + const parsed = GateResult.parse(result) + expect(parsed.passed).toBe(true) + }) + + test("validates gate result with diagnostics", () => { + const result = { + gate: "mypy", + passed: false, + critical: false, + output: "Found 2 errors", + diagnostics: [ + { + file: "src/main.py", + line: 42, + column: 10, + severity: "error", + message: "Incompatible types", + source: "mypy", + }, + ], + timing: { + startedAt: Date.now() - 1000, + completedAt: Date.now(), + }, + } + + const parsed = GateResult.parse(result) + expect(parsed.diagnostics).toHaveLength(1) + expect(parsed.diagnostics![0].severity).toBe("error") + }) +}) + +describe("GateResults", () => { + test("validates combined gate results", () => { + const results = { + allPassed: false, + criticalPassed: true, + results: [ + { + gate: "pytest", + passed: true, + critical: true, + output: "OK", + timing: { startedAt: 0, completedAt: 1 }, + }, + { + gate: "mypy", + passed: false, + critical: false, + output: "Errors", + timing: { startedAt: 1, completedAt: 2 }, + }, + ], + score: 80, + summary: "1/2 gates passed", + } + + const parsed = GateResults.parse(results) + expect(parsed.score).toBe(80) + expect(parsed.criticalPassed).toBe(true) + }) +}) + +describe("Patch", () => { + test("validates patch", () => { + const patch = { + id: "123e4567-e89b-12d3-a456-426614174000", + workcellId: "550e8400-e29b-41d4-a716-446655440000", + taskId: "660e8400-e29b-41d4-a716-446655440000", + diff: "--- a/file.ts\n+++ b/file.ts\n@@ -1 +1 @@\n-old\n+new", + stats: { + filesChanged: 1, + insertions: 1, + deletions: 1, + }, + files: ["file.ts"], + status: "captured", + createdAt: Date.now(), + } + + const parsed = Patch.parse(patch) + expect(parsed.status).toBe("captured") + }) + + test("validates all patch statuses", () => { + const statuses = [ + "captured", + "validating", + "validated", + "rejected", + "staged", + "approved", + "merging", + "merged", + "failed", + ] as const + + for (const status of statuses) { + expect(PatchStatus.parse(status)).toBe(status) + } + }) +}) + +describe("Bead", () => { + test("validates bead (issue)", () => { + const bead = { + id: "PROJ-42", + title: "Fix authentication bug", + description: "Users can't log in", + status: "open", + priority: "p1", + labels: ["bug", "auth"], + createdAt: Date.now(), + updatedAt: Date.now(), + } + + const parsed = Bead.parse(bead) + expect(parsed.priority).toBe("p1") + }) + + test("validates all bead statuses", () => { + const statuses = ["open", "in_progress", "blocked", "completed", "cancelled"] as const + + for (const status of statuses) { + expect(BeadStatus.parse(status)).toBe(status) + } + }) +}) + +describe("RoutingDecision", () => { + test("validates routing decision", () => { + const decision = { + taskId: "123e4567-e89b-12d3-a456-426614174000", + toolchain: "codex", + strategy: "single", + gates: ["pytest", "mypy", "ruff"], + } + + const parsed = RoutingDecision.parse(decision) + expect(parsed.retries).toBe(1) // default + expect(parsed.priority).toBe(50) // default + }) + + test("validates speculate decision", () => { + const decision = { + taskId: "123e4567-e89b-12d3-a456-426614174000", + toolchain: "codex", + strategy: "speculate", + speculation: { + count: 3, + toolchains: ["codex", "claude", "opencode"], + voteStrategy: "first_pass", + }, + gates: ["pytest"], + reasoning: "High risk task requires parallel execution", + } + + const parsed = RoutingDecision.parse(decision) + expect(parsed.strategy).toBe("speculate") + expect(parsed.speculation?.count).toBe(3) + }) +}) diff --git a/apps/terminal/test/verifier.test.ts b/apps/terminal/test/verifier.test.ts new file mode 100644 index 000000000..3cf3e01dd --- /dev/null +++ b/apps/terminal/test/verifier.test.ts @@ -0,0 +1,577 @@ +/** + * Verifier and Gates tests + * + * Tests for the Verifier namespace and individual gate implementations. + */ + +import { describe, expect, test } from "bun:test" +import { Verifier } from "../src/verifier" +import { parseDiagnostics as parsePytestDiagnostics } from "../src/verifier/gates/pytest" +import { parseDiagnostics as parseMypyDiagnostics } from "../src/verifier/gates/mypy" +import { parseDiagnostics as parseRuffDiagnostics } from "../src/verifier/gates/ruff" +import type { GateResult, WorkcellInfo } from "../src/types" + +// Helper to create minimal workcell info +function makeWorkcell(overrides: Partial<WorkcellInfo> = {}): WorkcellInfo { + return { + id: crypto.randomUUID(), + name: "test-workcell", + directory: "/tmp/test-workcell", + branch: "test-branch", + status: "warm", + projectId: "test-project", + createdAt: Date.now(), + useCount: 0, + ...overrides, + } +} + +describe("Gate Diagnostic Parsers", () => { + describe("pytest parseDiagnostics", () => { + test("parses FAILED lines", () => { + const output = `FAILED tests/test_foo.py::test_bar - AssertionError +FAILED tests/test_baz.py::test_qux - ValueError: invalid` + + const diagnostics = parsePytestDiagnostics(output) + + expect(diagnostics).toHaveLength(2) + expect(diagnostics[0].file).toBe("tests/test_foo.py") + expect(diagnostics[0].severity).toBe("error") + expect(diagnostics[0].message).toBe("AssertionError") + expect(diagnostics[0].source).toBe("pytest") + + expect(diagnostics[1].file).toBe("tests/test_baz.py") + expect(diagnostics[1].message).toBe("ValueError: invalid") + }) + + test("parses ERROR lines", () => { + const output = `ERROR tests/test_foo.py::test_bar - ModuleNotFoundError +ERROR tests/conftest.py - SyntaxError` + + const diagnostics = parsePytestDiagnostics(output) + + expect(diagnostics).toHaveLength(2) + expect(diagnostics[0].file).toBe("tests/test_foo.py") + expect(diagnostics[0].severity).toBe("error") + expect(diagnostics[0].message).toBe("ModuleNotFoundError") + + expect(diagnostics[1].file).toBe("tests/conftest.py") + expect(diagnostics[1].message).toBe("SyntaxError") + }) + + test("parses file:line: error format", () => { + const output = `tests/test_foo.py:25: AssertionError: expected True` + + const diagnostics = parsePytestDiagnostics(output) + + expect(diagnostics).toHaveLength(1) + expect(diagnostics[0].file).toBe("tests/test_foo.py") + expect(diagnostics[0].line).toBe(25) + expect(diagnostics[0].severity).toBe("error") + }) + + test("returns empty array for clean output", () => { + const output = ` +============================= test session starts ============================== +collected 5 items +tests/test_foo.py ..... +============================== 5 passed in 0.05s ===============================` + + const diagnostics = parsePytestDiagnostics(output) + expect(diagnostics).toHaveLength(0) + }) + }) + + describe("mypy parseDiagnostics", () => { + test("parses error with column", () => { + const output = `src/foo.py:10:5: error: Incompatible types in assignment [assignment] +src/bar.py:20:10: error: Missing return statement [return]` + + const diagnostics = parseMypyDiagnostics(output) + + expect(diagnostics).toHaveLength(2) + expect(diagnostics[0].file).toBe("src/foo.py") + expect(diagnostics[0].line).toBe(10) + expect(diagnostics[0].column).toBe(5) + expect(diagnostics[0].severity).toBe("error") + expect(diagnostics[0].message).toBe("Incompatible types in assignment") + expect(diagnostics[0].code).toBe("assignment") + expect(diagnostics[0].source).toBe("mypy") + }) + + test("parses warnings and notes", () => { + const output = `src/foo.py:5:1: warning: Unused variable [var-annotated] +src/foo.py:10:1: note: See https://docs.python.org` + + const diagnostics = parseMypyDiagnostics(output) + + expect(diagnostics).toHaveLength(2) + expect(diagnostics[0].severity).toBe("warning") + expect(diagnostics[1].severity).toBe("info") + }) + + test("parses simple format without column", () => { + const output = `src/foo.py:10: error: Something wrong [error-code]` + + const diagnostics = parseMypyDiagnostics(output) + + expect(diagnostics).toHaveLength(1) + expect(diagnostics[0].file).toBe("src/foo.py") + expect(diagnostics[0].line).toBe(10) + expect(diagnostics[0].column).toBeUndefined() + }) + + test("returns empty array for success output", () => { + const output = `Success: no issues found in 10 source files` + + const diagnostics = parseMypyDiagnostics(output) + expect(diagnostics).toHaveLength(0) + }) + }) + + describe("ruff parseDiagnostics", () => { + test("parses JSON format", () => { + const output = JSON.stringify([ + { + code: "E501", + message: "Line too long", + filename: "src/foo.py", + location: { row: 10, column: 80 }, + end_location: { row: 10, column: 150 }, + }, + { + code: "F401", + message: "Unused import", + filename: "src/bar.py", + location: { row: 1, column: 1 }, + fix: { applicability: "safe", message: "Remove import", edits: [] }, + }, + ]) + + const diagnostics = parseRuffDiagnostics(output) + + expect(diagnostics).toHaveLength(2) + expect(diagnostics[0].file).toBe("src/foo.py") + expect(diagnostics[0].line).toBe(10) + expect(diagnostics[0].column).toBe(80) + expect(diagnostics[0].severity).toBe("error") // no fix + expect(diagnostics[0].message).toBe("Line too long") + expect(diagnostics[0].code).toBe("E501") + expect(diagnostics[0].source).toBe("ruff") + + expect(diagnostics[1].severity).toBe("warning") // has fix + }) + + test("falls back to text format on invalid JSON", () => { + const output = `src/foo.py:10:80: E501 Line too long +src/bar.py:1:1: F401 Unused import` + + const diagnostics = parseRuffDiagnostics(output) + + expect(diagnostics).toHaveLength(2) + expect(diagnostics[0].file).toBe("src/foo.py") + expect(diagnostics[0].line).toBe(10) + expect(diagnostics[0].column).toBe(80) + expect(diagnostics[0].code).toBe("E501") + expect(diagnostics[0].message).toBe("Line too long") + }) + + test("returns empty array for empty JSON array", () => { + const output = "[]" + const diagnostics = parseRuffDiagnostics(output) + expect(diagnostics).toHaveLength(0) + }) + }) +}) + +describe("Verifier namespace", () => { + describe("listGates", () => { + test("lists all registered gates", () => { + const gates = Verifier.listGates() + + expect(gates).toHaveLength(4) + expect(gates.map((g) => g.id)).toContain("pytest") + expect(gates.map((g) => g.id)).toContain("mypy") + expect(gates.map((g) => g.id)).toContain("ruff") + expect(gates.map((g) => g.id)).toContain("clawdstrike") + }) + + test("includes gate metadata", () => { + const gates = Verifier.listGates() + const pytest = gates.find((g) => g.id === "pytest") + + expect(pytest).toBeDefined() + expect(pytest?.name).toBe("Pytest") + expect(pytest?.description).toBeDefined() + expect(pytest?.critical).toBe(true) + }) + }) + + describe("getGate", () => { + test("returns gate by ID", () => { + const gate = Verifier.getGate("pytest") + expect(gate).toBeDefined() + expect(gate?.info.id).toBe("pytest") + }) + + test("returns undefined for unknown gate", () => { + const gate = Verifier.getGate("unknown") + expect(gate).toBeUndefined() + }) + }) + + describe("registerGate / unregisterGate", () => { + test("can register and unregister custom gate", () => { + const customGate = { + info: { + id: "custom-test", + name: "Custom Test", + description: "A custom test gate", + critical: false, + }, + isAvailable: async () => true, + run: async () => ({ + gate: "custom-test", + passed: true, + critical: false, + output: "OK", + timing: { startedAt: Date.now(), completedAt: Date.now() }, + }), + } + + Verifier.registerGate(customGate) + expect(Verifier.getGate("custom-test")).toBeDefined() + + Verifier.unregisterGate("custom-test") + expect(Verifier.getGate("custom-test")).toBeUndefined() + }) + }) + + describe("calculateScore", () => { + test("returns 100 for all passing gates", () => { + const results: GateResult[] = [ + { + gate: "pytest", + passed: true, + critical: true, + output: "OK", + timing: { startedAt: 0, completedAt: 0 }, + }, + { + gate: "mypy", + passed: true, + critical: false, + output: "OK", + timing: { startedAt: 0, completedAt: 0 }, + }, + ] + + expect(Verifier.calculateScore(results)).toBe(100) + }) + + test("deducts 10 points per error", () => { + const results: GateResult[] = [ + { + gate: "mypy", + passed: false, + critical: false, + output: "errors", + diagnostics: [ + { file: "a.py", line: 1, severity: "error", message: "error 1" }, + { file: "b.py", line: 2, severity: "error", message: "error 2" }, + ], + timing: { startedAt: 0, completedAt: 0 }, + }, + ] + + expect(Verifier.calculateScore(results)).toBe(80) // 100 - 2*10 + }) + + test("deducts 2 points per warning", () => { + const results: GateResult[] = [ + { + gate: "ruff", + passed: false, + critical: false, + output: "warnings", + diagnostics: [ + { file: "a.py", line: 1, severity: "warning", message: "warning 1" }, + { file: "b.py", line: 2, severity: "warning", message: "warning 2" }, + { file: "c.py", line: 3, severity: "warning", message: "warning 3" }, + ], + timing: { startedAt: 0, completedAt: 0 }, + }, + ] + + expect(Verifier.calculateScore(results)).toBe(94) // 100 - 3*2 + }) + + test("penalizes failed gates without diagnostics", () => { + const results: GateResult[] = [ + { + gate: "pytest", + passed: false, + critical: true, + output: "failed", + timing: { startedAt: 0, completedAt: 0 }, + }, + ] + + expect(Verifier.calculateScore(results)).toBe(50) // 100 - 50 (critical) + }) + + test("minimum score is 0", () => { + const results: GateResult[] = [ + { + gate: "mypy", + passed: false, + critical: false, + output: "many errors", + diagnostics: Array(20) + .fill(null) + .map((_, i) => ({ file: `f${i}.py`, line: i, severity: "error" as const, message: `error ${i}` })), + timing: { startedAt: 0, completedAt: 0 }, + }, + ] + + expect(Verifier.calculateScore(results)).toBe(0) // 100 - 20*10 = -100, capped at 0 + }) + }) + + describe("generateSummary", () => { + test("generates summary for all passed", () => { + const summary = Verifier.generateSummary({ + allPassed: true, + criticalPassed: true, + results: [ + { + gate: "pytest", + passed: true, + critical: true, + output: "OK", + timing: { startedAt: 0, completedAt: 100 }, + }, + ], + score: 100, + summary: "", + }) + + expect(summary).toContain("All gates passed") + expect(summary).toContain("Score: 100/100") + expect(summary).toContain("pytest") + }) + + test("generates summary for critical failure", () => { + const summary = Verifier.generateSummary({ + allPassed: false, + criticalPassed: false, + results: [ + { + gate: "pytest", + passed: false, + critical: true, + output: "failed", + timing: { startedAt: 0, completedAt: 100 }, + }, + ], + score: 50, + summary: "", + }) + + expect(summary).toContain("Critical gate(s) failed") + expect(summary).toContain("Score: 50/100") + }) + + test("generates summary with diagnostic counts", () => { + const summary = Verifier.generateSummary({ + allPassed: false, + criticalPassed: true, + results: [ + { + gate: "mypy", + passed: false, + critical: false, + output: "errors", + diagnostics: [ + { file: "a.py", line: 1, severity: "error", message: "err" }, + { file: "b.py", line: 2, severity: "warning", message: "warn" }, + { file: "c.py", line: 3, severity: "warning", message: "warn" }, + ], + timing: { startedAt: 0, completedAt: 100 }, + }, + ], + score: 86, + summary: "", + }) + + expect(summary).toContain("1 error") + expect(summary).toContain("2 warnings") + }) + }) + + describe("runGate", () => { + test("returns skipped result for unknown gate", async () => { + const workcell = makeWorkcell() + const result = await Verifier.runGate(workcell, "unknown-gate") + + expect(result.passed).toBe(false) + expect(result.output).toContain("not found") + }) + + test("returns skipped result for unavailable gate", async () => { + // Register a gate that's never available + const unavailableGate = { + info: { + id: "unavailable-test", + name: "Unavailable", + description: "Never available", + critical: false, + }, + isAvailable: async () => false, + run: async () => ({ + gate: "unavailable-test", + passed: false, + critical: false, + output: "should not run", + timing: { startedAt: 0, completedAt: 0 }, + }), + } + + Verifier.registerGate(unavailableGate) + const result = await Verifier.runGate(makeWorkcell(), "unavailable-test") + Verifier.unregisterGate("unavailable-test") + + expect(result.passed).toBe(true) // skipped gates pass + expect(result.output).toContain("skipped") + }) + }) + + describe("run", () => { + test("runs multiple gates and aggregates results", async () => { + // Register mock gates for testing + const mockGate1 = { + info: { id: "mock1", name: "Mock 1", description: "test", critical: false }, + isAvailable: async () => true, + run: async () => ({ + gate: "mock1", + passed: true, + critical: false, + output: "OK", + timing: { startedAt: Date.now(), completedAt: Date.now() }, + }), + } + const mockGate2 = { + info: { id: "mock2", name: "Mock 2", description: "test", critical: false }, + isAvailable: async () => true, + run: async () => ({ + gate: "mock2", + passed: true, + critical: false, + output: "OK", + timing: { startedAt: Date.now(), completedAt: Date.now() }, + }), + } + + Verifier.registerGate(mockGate1) + Verifier.registerGate(mockGate2) + + const results = await Verifier.run(makeWorkcell(), { + gates: ["mock1", "mock2"], + }) + + Verifier.unregisterGate("mock1") + Verifier.unregisterGate("mock2") + + expect(results.allPassed).toBe(true) + expect(results.criticalPassed).toBe(true) + expect(results.results).toHaveLength(2) + expect(results.score).toBe(100) + expect(results.summary).toContain("All gates passed") + }) + + test("fail-fast stops on critical failure", async () => { + let gate2Ran = false + + const failingGate = { + info: { id: "failing", name: "Failing", description: "test", critical: true }, + isAvailable: async () => true, + run: async () => ({ + gate: "failing", + passed: false, + critical: true, + output: "FAIL", + timing: { startedAt: Date.now(), completedAt: Date.now() }, + }), + } + const secondGate = { + info: { id: "second", name: "Second", description: "test", critical: false }, + isAvailable: async () => true, + run: async () => { + gate2Ran = true + return { + gate: "second", + passed: true, + critical: false, + output: "OK", + timing: { startedAt: Date.now(), completedAt: Date.now() }, + } + }, + } + + Verifier.registerGate(failingGate) + Verifier.registerGate(secondGate) + + const results = await Verifier.run(makeWorkcell(), { + gates: ["failing", "second"], + failFast: true, + }) + + Verifier.unregisterGate("failing") + Verifier.unregisterGate("second") + + expect(results.allPassed).toBe(false) + expect(results.criticalPassed).toBe(false) + expect(results.results).toHaveLength(1) // only first gate ran + expect(gate2Ran).toBe(false) + }) + + test("continues on non-critical failure with fail-fast", async () => { + const nonCriticalFail = { + info: { id: "noncrit", name: "NonCrit", description: "test", critical: false }, + isAvailable: async () => true, + run: async () => ({ + gate: "noncrit", + passed: false, + critical: false, + output: "FAIL", + timing: { startedAt: Date.now(), completedAt: Date.now() }, + }), + } + const secondGate = { + info: { id: "second2", name: "Second", description: "test", critical: false }, + isAvailable: async () => true, + run: async () => ({ + gate: "second2", + passed: true, + critical: false, + output: "OK", + timing: { startedAt: Date.now(), completedAt: Date.now() }, + }), + } + + Verifier.registerGate(nonCriticalFail) + Verifier.registerGate(secondGate) + + const results = await Verifier.run(makeWorkcell(), { + gates: ["noncrit", "second2"], + failFast: true, + }) + + Verifier.unregisterGate("noncrit") + Verifier.unregisterGate("second2") + + expect(results.results).toHaveLength(2) // both gates ran + expect(results.criticalPassed).toBe(true) // no critical gates + }) + }) +}) diff --git a/apps/terminal/test/workcell.test.ts b/apps/terminal/test/workcell.test.ts new file mode 100644 index 000000000..dcae5ce5a --- /dev/null +++ b/apps/terminal/test/workcell.test.ts @@ -0,0 +1,338 @@ +/** + * Workcell unit tests + * + * Tests for git worktree operations, pool management, and lifecycle. + */ + +import { describe, test, expect, beforeEach } from "bun:test" +import * as pool from "../src/workcell/pool" +import * as git from "../src/workcell/git" +import type { WorkcellInfo } from "../src/types" + +describe("Git utilities", () => { + test("getWorkcellsDir returns correct path", () => { + expect(git.getWorkcellsDir("/project")).toBe("/project/.clawdstrike/workcells") + expect(git.getWorkcellsDir("/home/user/repo")).toBe( + "/home/user/repo/.clawdstrike/workcells" + ) + }) + + test("generateWorktreeBranch generates correct format", () => { + const branch = git.generateWorktreeBranch("wc", "abc12345") + expect(branch).toBe("clawdstrike/wc/abc12345") + }) +}) + +describe("Pool state management", () => { + const projectId = "test-project" + const gitRoot = "/test/repo" + + beforeEach(() => { + // Clear all pools before each test + pool.clearAllPools() + }) + + test("initPool creates pool with default config", () => { + const config = { minSize: 2, maxSize: 10, ttl: 3600000, preWarm: true, cleanupInterval: 300000 } + const state = pool.initPool(projectId, gitRoot, config) + + expect(state).toBeDefined() + expect(state.projectId).toBe(projectId) + expect(state.gitRoot).toBe(gitRoot) + expect(state.config).toEqual(config) + expect(state.workcells.size).toBe(0) + }) + + test("getPool returns undefined for non-existent pool", () => { + expect(pool.getPool("nonexistent")).toBeUndefined() + }) + + test("getPool returns existing pool", () => { + const config = { minSize: 2, maxSize: 10, ttl: 3600000, preWarm: true, cleanupInterval: 300000 } + pool.initPool(projectId, gitRoot, config) + + const state = pool.getPool(projectId) + expect(state).toBeDefined() + expect(state?.projectId).toBe(projectId) + }) + + test("addWorkcell adds to pool", () => { + const config = { minSize: 2, maxSize: 10, ttl: 3600000, preWarm: true, cleanupInterval: 300000 } + pool.initPool(projectId, gitRoot, config) + + const workcell: WorkcellInfo = { + id: "123e4567-e89b-12d3-a456-426614174000", + name: "wc-test", + directory: "/test/repo/.clawdstrike/workcells/wc-test", + branch: "wc-test", + status: "warm", + projectId, + createdAt: Date.now(), + useCount: 0, + } + + pool.addToPool(workcell) + const counts = pool.countByStatus(projectId) + + expect(counts.total).toBe(1) + expect(counts.warm).toBe(1) + expect(counts.inUse).toBe(0) + }) + + test("findWarmWorkcell returns warm workcell", () => { + const config = { minSize: 2, maxSize: 10, ttl: 3600000, preWarm: true, cleanupInterval: 300000 } + pool.initPool(projectId, gitRoot, config) + + const workcell: WorkcellInfo = { + id: "123e4567-e89b-12d3-a456-426614174000", + name: "wc-test", + directory: "/test/repo/.clawdstrike/workcells/wc-test", + branch: "wc-test", + status: "warm", + projectId, + createdAt: Date.now(), + useCount: 0, + } + + pool.addToPool(workcell) + const found = pool.findWarmWorkcell(projectId) + + expect(found).toBeDefined() + expect(found?.id).toBe(workcell.id) + }) + + test("findWarmWorkcell returns undefined when none warm", () => { + const config = { minSize: 2, maxSize: 10, ttl: 3600000, preWarm: true, cleanupInterval: 300000 } + pool.initPool(projectId, gitRoot, config) + + const workcell: WorkcellInfo = { + id: "123e4567-e89b-12d3-a456-426614174000", + name: "wc-test", + directory: "/test/repo/.clawdstrike/workcells/wc-test", + branch: "wc-test", + status: "in_use", // Not warm + projectId, + createdAt: Date.now(), + useCount: 1, + } + + pool.addToPool(workcell) + const found = pool.findWarmWorkcell(projectId) + + expect(found).toBeUndefined() + }) + + test("updateWorkcellStatus updates status correctly", () => { + const config = { minSize: 2, maxSize: 10, ttl: 3600000, preWarm: true, cleanupInterval: 300000 } + pool.initPool(projectId, gitRoot, config) + + const workcell: WorkcellInfo = { + id: "123e4567-e89b-12d3-a456-426614174000", + name: "wc-test", + directory: "/test/repo/.clawdstrike/workcells/wc-test", + branch: "wc-test", + status: "warm", + projectId, + createdAt: Date.now(), + useCount: 0, + } + + pool.addToPool(workcell) + pool.updateWorkcell({ ...workcell, status: "in_use" }) + + const updated = pool.getWorkcell(workcell.id) + expect(updated?.status).toBe("in_use") + }) + + test("removeWorkcell removes from pool", () => { + const config = { minSize: 2, maxSize: 10, ttl: 3600000, preWarm: true, cleanupInterval: 300000 } + pool.initPool(projectId, gitRoot, config) + + const workcell: WorkcellInfo = { + id: "123e4567-e89b-12d3-a456-426614174000", + name: "wc-test", + directory: "/test/repo/.clawdstrike/workcells/wc-test", + branch: "wc-test", + status: "warm", + projectId, + createdAt: Date.now(), + useCount: 0, + } + + pool.addToPool(workcell) + expect(pool.countByStatus(projectId).total).toBe(1) + + pool.removeFromPool(workcell.id) + expect(pool.countByStatus(projectId).total).toBe(0) + }) + + test("countByStatus returns correct counts", () => { + const config = { minSize: 2, maxSize: 10, ttl: 3600000, preWarm: true, cleanupInterval: 300000 } + pool.initPool(projectId, gitRoot, config) + + const workcells: WorkcellInfo[] = [ + { + id: "123e4567-e89b-12d3-a456-426614174001", + name: "wc-warm1", + directory: "/test/repo/.clawdstrike/workcells/wc-warm1", + branch: "wc-warm1", + status: "warm", + projectId, + createdAt: Date.now(), + useCount: 0, + }, + { + id: "123e4567-e89b-12d3-a456-426614174002", + name: "wc-warm2", + directory: "/test/repo/.clawdstrike/workcells/wc-warm2", + branch: "wc-warm2", + status: "warm", + projectId, + createdAt: Date.now(), + useCount: 0, + }, + { + id: "123e4567-e89b-12d3-a456-426614174003", + name: "wc-inuse", + directory: "/test/repo/.clawdstrike/workcells/wc-inuse", + branch: "wc-inuse", + status: "in_use", + projectId, + createdAt: Date.now(), + useCount: 1, + }, + { + id: "123e4567-e89b-12d3-a456-426614174004", + name: "wc-creating", + directory: "/test/repo/.clawdstrike/workcells/wc-creating", + branch: "wc-creating", + status: "creating", + projectId, + createdAt: Date.now(), + useCount: 0, + }, + ] + + for (const wc of workcells) { + pool.addToPool(wc) + } + + const counts = pool.countByStatus(projectId) + expect(counts.total).toBe(4) + expect(counts.warm).toBe(2) + expect(counts.inUse).toBe(1) + expect(counts.creating).toBe(1) + }) + + test("getExpiredWorkcells returns old workcells", () => { + const config = { minSize: 0, maxSize: 10, ttl: 1000, preWarm: false, cleanupInterval: 300000 } // 1 second TTL + pool.initPool(projectId, gitRoot, config) + + const oldWorkcell: WorkcellInfo = { + id: "123e4567-e89b-12d3-a456-426614174001", + name: "wc-old", + directory: "/test/repo/.clawdstrike/workcells/wc-old", + branch: "wc-old", + status: "warm", + projectId, + createdAt: Date.now() - 10000, // 10 seconds ago + useCount: 0, + } + + const newWorkcell: WorkcellInfo = { + id: "123e4567-e89b-12d3-a456-426614174002", + name: "wc-new", + directory: "/test/repo/.clawdstrike/workcells/wc-new", + branch: "wc-new", + status: "warm", + projectId, + createdAt: Date.now(), // Just now + useCount: 0, + } + + pool.addToPool(oldWorkcell) + pool.addToPool(newWorkcell) + + const expired = pool.getExpiredWorkcells(projectId) + expect(expired.length).toBe(1) + expect(expired[0].id).toBe(oldWorkcell.id) + }) + + test("getPoolIds returns all project IDs", () => { + const config = { minSize: 2, maxSize: 10, ttl: 3600000, preWarm: true, cleanupInterval: 300000 } + pool.initPool("project1", "/repo1", config) + pool.initPool("project2", "/repo2", config) + pool.initPool("project3", "/repo3", config) + + const ids = pool.getPoolIds() + expect(ids.length).toBe(3) + expect(ids).toContain("project1") + expect(ids).toContain("project2") + expect(ids).toContain("project3") + }) + + test("clearAllPools removes all pools", () => { + const config = { minSize: 2, maxSize: 10, ttl: 3600000, preWarm: true, cleanupInterval: 300000 } + pool.initPool("project1", "/repo1", config) + pool.initPool("project2", "/repo2", config) + + expect(pool.getPoolIds().length).toBe(2) + + pool.clearAllPools() + expect(pool.getPoolIds().length).toBe(0) + }) + + test("updatePoolConfig updates config", () => { + const config = { minSize: 2, maxSize: 10, ttl: 3600000, preWarm: true, cleanupInterval: 300000 } + pool.initPool(projectId, gitRoot, config) + + pool.updatePoolConfig(projectId, { maxSize: 20, ttl: 7200000 }) + + const state = pool.getPool(projectId) + expect(state?.config.maxSize).toBe(20) + expect(state?.config.ttl).toBe(7200000) + expect(state?.config.minSize).toBe(2) // Unchanged + }) + + test("findWarmWorkcell filters by toolchain", () => { + const config = { minSize: 2, maxSize: 10, ttl: 3600000, preWarm: true, cleanupInterval: 300000 } + pool.initPool(projectId, gitRoot, config) + + const workcell1: WorkcellInfo = { + id: "123e4567-e89b-12d3-a456-426614174001", + name: "wc-codex", + directory: "/test/repo/.clawdstrike/workcells/wc-codex", + branch: "wc-codex", + status: "warm", + toolchain: "codex", + projectId, + createdAt: Date.now(), + useCount: 0, + } + + const workcell2: WorkcellInfo = { + id: "123e4567-e89b-12d3-a456-426614174002", + name: "wc-claude", + directory: "/test/repo/.clawdstrike/workcells/wc-claude", + branch: "wc-claude", + status: "warm", + toolchain: "claude", + projectId, + createdAt: Date.now(), + useCount: 0, + } + + pool.addToPool(workcell1) + pool.addToPool(workcell2) + + const foundCodex = pool.findWarmWorkcell(projectId, "codex") + expect(foundCodex?.id).toBe(workcell1.id) + + const foundClaude = pool.findWarmWorkcell(projectId, "claude") + expect(foundClaude?.id).toBe(workcell2.id) + + // Should find any when no toolchain specified + const foundAny = pool.findWarmWorkcell(projectId) + expect(foundAny).toBeDefined() + }) +}) diff --git a/apps/terminal/tsconfig.json b/apps/terminal/tsconfig.json new file mode 100644 index 000000000..75dfd4217 --- /dev/null +++ b/apps/terminal/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "lib": ["ESNext"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "allowJs": true, + + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + }, + + "types": ["bun-types"] + }, + "include": ["src/**/*.ts", "test/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/clawdstrike-plugin/.claude-plugin/plugin.json b/clawdstrike-plugin/.claude-plugin/plugin.json new file mode 100644 index 000000000..61f38dcf0 --- /dev/null +++ b/clawdstrike-plugin/.claude-plugin/plugin.json @@ -0,0 +1,13 @@ +{ + "name": "clawdstrike", + "version": "0.1.0", + "description": "Runtime security enforcement for AI coding agents — policy hooks, audit receipts, threat hunting, and security tools for Claude Code.", + "author": { + "name": "Backbay Labs", + "email": "hello@backbay.io" + }, + "homepage": "https://github.com/backbay-labs/clawdstrike", + "repository": "https://github.com/backbay-labs/clawdstrike", + "license": "Apache-2.0", + "keywords": ["security", "policy", "audit", "edr", "agent-security", "mcp"] +} diff --git a/clawdstrike-plugin/.mcp.json b/clawdstrike-plugin/.mcp.json new file mode 100644 index 000000000..6019fd278 --- /dev/null +++ b/clawdstrike-plugin/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "clawdstrike": { + "command": "bun", + "args": ["run", "${CLAUDE_PLUGIN_ROOT}/scripts/mcp-server.ts"] + } + } +} diff --git a/clawdstrike-plugin/README.md b/clawdstrike-plugin/README.md new file mode 100644 index 000000000..ebf5387b0 --- /dev/null +++ b/clawdstrike-plugin/README.md @@ -0,0 +1,150 @@ +# ClawdStrike Claude Code Plugin + +Runtime security enforcement for AI coding agents. This plugin integrates ClawdStrike's policy engine, threat hunting, and audit system directly into Claude Code. + +## What It Does + +- **Pre-tool policy checks** -- every tool call is evaluated against your security policy before execution +- **Post-tool audit receipts** -- signed attestations of every action for compliance and forensics +- **10 MCP tools** -- security scanning, threat hunting, event correlation, and policy management +- **3 auto-triggering skills** -- contextual security guidance that activates when relevant +- **5 slash commands** -- quick access to scanning, auditing, and posture assessment +- **1 specialist agent** -- deep security review with OWASP and policy compliance checks + +## Installation + +### From the marketplace (recommended) + +```shell +# Add the ClawdStrike marketplace +/plugin marketplace add backbay-labs/clawdstrike + +# Install the plugin +/plugin install clawdstrike@clawdstrike +``` + +### From a local clone + +```bash +claude --plugin-dir ./clawdstrike-plugin +``` + +## Quick Reference + +### Slash Commands + +| Command | Description | +|---------|-------------| +| `/clawdstrike:scan` | Scan MCP server configurations for security issues | +| `/clawdstrike:audit` | Show security audit trail for the current session | +| `/clawdstrike:posture` | Assess overall security posture (A-F grade) | +| `/clawdstrike:policy` | Show active security policy and guard details | +| `/clawdstrike:tui` | Launch the ClawdStrike TUI dashboard | + +### MCP Tools + +| Tool | Description | +|------|-------------| +| `clawdstrike_check` | Evaluate an action against the active policy | +| `clawdstrike_scan` | Scan MCP server configurations | +| `clawdstrike_query` | Query security events with filters | +| `clawdstrike_timeline` | Get chronological event timeline | +| `clawdstrike_correlate` | Correlate events to detect attack patterns | +| `clawdstrike_ioc` | Check indicators of compromise | +| `clawdstrike_policy_show` | Show policy and guard configuration | +| `clawdstrike_policy_eval` | Evaluate a hypothetical action against policy | +| `clawdstrike_hunt_diff` | Diff security state between two points in time | +| `clawdstrike_report` | Generate a structured security report | + +### Skills (Auto-Triggering) + +| Skill | Triggers On | +|-------|-------------| +| Security Review | Edits to sensitive paths, shell commands, dependency changes | +| Threat Hunt | Security events, suspicious activity, IOC investigation | +| Policy Guide | Questions about what's allowed, guard behavior, rulesets | + +### Agent + +| Agent | Purpose | +|-------|---------| +| `security-reviewer` | Deep code security review with OWASP checks and policy verification | + +## Architecture + +``` +claude code + | + v ++-------------------+ +| clawdstrike-plugin| ++-------------------+ +| | +| hooks/ |--- pre-tool-check.sh ----> ClawdStrike CLI +| hooks.json |<-- allow/deny verdict ----/ +| | +| |--- post-tool-receipt.sh -> ClawdStrike CLI +| |<-- signed receipt --------/ +| | +| scripts/ |--- session-start.sh ----> Initialize session +| mcp-server.ts |--- session-end.sh ------> Finalize + report +| | +| skills/ |--- security-review/ (auto-trigger on risky edits) +| |--- threat-hunt/ (auto-trigger on investigations) +| |--- policy-guide/ (auto-trigger on policy questions) +| | +| commands/ |--- /scan (MCP config scanning) +| |--- /audit (session audit trail) +| |--- /posture (security posture grade) +| |--- /policy (active policy details) +| |--- /tui (interactive dashboard) +| | +| agents/ |--- security-reviewer (deep code review agent) ++-------------------+ + | + v + MCP Server (stdio) + 10 tools via @modelcontextprotocol/sdk + | + v + ClawdStrike CLI / API +``` + +## Configuration + +### Environment Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `CLAWDSTRIKE_ENDPOINT` | ClawdStrike API endpoint (CLI path or HTTP URL) | `clawdstrike` (PATH lookup) | +| `CLAWDSTRIKE_SESSION_ID` | Session identifier for audit trail continuity | Auto-generated UUID | +| `CLAWDSTRIKE_HOOK_FAIL_OPEN` | Allow actions when ClawdStrike is unavailable. Accepts `true`, `1`, or `yes` | `false` (fail-closed) | + +### Fail-Closed by Default + +The plugin follows ClawdStrike's fail-closed design philosophy: +- If the ClawdStrike CLI is unavailable, tool calls are **blocked** (unless `CLAWDSTRIKE_HOOK_FAIL_OPEN=true`) +- If a policy fails to parse, the session **refuses to start** +- If a guard errors during evaluation, the verdict is **deny** + +Set `CLAWDSTRIKE_HOOK_FAIL_OPEN=true` only in development environments where security enforcement is not required. + +## Development + +```bash +# Install dependencies +bun install + +# Run MCP server standalone +bun run mcp + +# Type check +bun run typecheck + +# Run tests +bun test +``` + +## License + +MIT diff --git a/clawdstrike-plugin/agents/security-reviewer.md b/clawdstrike-plugin/agents/security-reviewer.md new file mode 100644 index 000000000..e88f7a54e --- /dev/null +++ b/clawdstrike-plugin/agents/security-reviewer.md @@ -0,0 +1,171 @@ +--- +name: "security-reviewer" +description: "Specialized agent for comprehensive code security reviews" +tools: + - Read + - Grep + - Glob + - mcp: clawdstrike_check + - mcp: clawdstrike_policy_show + - mcp: clawdstrike_policy_eval + - mcp: clawdstrike_policy_lint + - mcp: clawdstrike_scan + - mcp: clawdstrike_correlate + - mcp: clawdstrike_timeline + - mcp: clawdstrike_query + - mcp: clawdstrike_ioc +--- + +# Security Reviewer Agent + +You are a specialized security review agent with access to ClawdStrike security tools. Your role is to perform thorough code security reviews, identify vulnerabilities, and verify policy compliance. + +## Your Role + +- Review code changes for security vulnerabilities +- Verify that actions comply with the active ClawdStrike security policy +- Detect potential secret leaks, injection flaws, and unsafe patterns +- Provide actionable findings with severity ratings and file:line references + +## Review Process + +### 1. Scope Discovery + +Use Glob to find relevant files: +- Identify changed or new files in the working directory +- Focus on security-sensitive file types: config files, shell scripts, auth modules, API routes + +### 2. Code Analysis + +Use Read and Grep to examine code for: + +**OWASP Top 10:** +- Injection (SQL, command, LDAP, XPath) +- Broken authentication and session management +- Sensitive data exposure (hardcoded secrets, unencrypted storage) +- XML External Entities (XXE) +- Broken access control +- Security misconfiguration +- Cross-Site Scripting (XSS) +- Insecure deserialization +- Using components with known vulnerabilities +- Insufficient logging and monitoring + +**Secret Detection:** +- API keys, tokens, passwords in source code +- Private keys or certificates +- Connection strings with embedded credentials +- Environment variables with sensitive defaults + +**Input Validation:** +- User input passed to shell commands without sanitization +- File paths constructed from user input (path traversal) +- Unvalidated redirects and forwards + +### 3. Policy Verification + +Use ClawdStrike MCP tools to verify compliance: +- Call `clawdstrike_check` for each file path being modified +- Call `clawdstrike_check` for any shell commands in the code +- Call `clawdstrike_check` for any egress URLs or domains referenced +- Use `clawdstrike_policy_eval` to test hypothetical attack scenarios +- Use `clawdstrike_policy_lint` to validate any policy YAML files in the review scope + +### 4. Correlation + +Use `clawdstrike_correlate` to check if findings match known attack patterns. + +## Relevant MCP Tools + +### Enforcement Tools (use during policy verification) + +| Tool | Purpose | When to Use | +|------|---------|-------------| +| `clawdstrike_check` | Evaluate an action against the active policy | For each file path, shell command, or egress domain in the code under review | +| `clawdstrike_policy_show` | Display the active policy and guard config | At the start of a review to understand the security baseline | +| `clawdstrike_policy_eval` | Simulate an action without executing it | To test hypothetical attack paths or edge cases | +| `clawdstrike_policy_lint` | Validate policy YAML for syntax/schema errors | When reviewing policy configuration files | + +### Investigation Tools (use during correlation and threat analysis) + +| Tool | Purpose | When to Use | +|------|---------|-------------| +| `clawdstrike_scan` | Scan MCP server configurations for issues | To check if MCP servers involved in the review have misconfigurations | +| `clawdstrike_timeline` | Chronological view of security events | To check if the code under review was involved in recent security events | +| `clawdstrike_correlate` | Detect attack patterns across events | When findings suggest coordinated or multi-stage attacks | +| `clawdstrike_query` | Filter events by criteria | To search for specific actions, guards, or verdicts related to the review | +| `clawdstrike_ioc` | Check indicators against threat intel | When code references suspicious domains, IPs, or file hashes | + +## Batching Guidance + +When reviewing large numbers of files: + +- **Over 20 files**: Do not review every file exhaustively. Sample strategically using this priority order: + 1. **Config files** (*.yaml, *.yml, *.json, *.toml, *.ini, .env*) -- highest risk for secret exposure and misconfiguration + 2. **Auth modules** (files with "auth", "login", "session", "token", "credential" in the name or path) + 3. **Shell scripts** (*.sh, *.bash, Makefile, Dockerfile) -- command injection risk + 4. **API routes** (files with "route", "handler", "controller", "api" in the name or path) + 5. **Database/ORM files** (files with "model", "migration", "query", "schema" in the name or path) + 6. **Skip**: Test files (*.test.*, *.spec.*), documentation (*.md), and generated files unless they appear in a security-sensitive path +- **Under 20 files**: Review all files, prioritizing the categories above + +## Output Format + +Structure findings as follows: + +``` +## Security Review Results + +### Critical +- [C-1] **Issue title** (file.ts:42) + Description of the vulnerability. + **Impact**: What an attacker could do. + **Remediation**: How to fix it. + +### High +- [H-1] ... + +### Medium +- [M-1] ... + +### Low +- [L-1] ... + +### Summary +- Files reviewed: N +- Total findings: X (C critical, H high, M medium, L low) +- Policy compliance: PASS/FAIL +``` + +Always include specific file paths and line numbers. Reference the relevant ClawdStrike guard that would catch or prevent each issue. + +## Remediation Examples + +Provide concrete before/after code fixes, not just descriptions. Examples: + +### Command Injection (ShellCommandGuard) +```diff +- const result = exec(`git log --author=${userInput}`); ++ const result = execFile('git', ['log', `--author=${userInput}`]); +``` + +### Secret in Source (SecretLeakGuard) +```diff +- const API_KEY = "sk-live-abc123def456"; ++ const API_KEY = process.env.API_KEY; +``` + +### Path Traversal (ForbiddenPathGuard) +```diff +- const filePath = path.join(baseDir, req.params.filename); ++ const filePath = path.join(baseDir, path.basename(req.params.filename)); ++ if (!filePath.startsWith(baseDir)) throw new Error("Path traversal blocked"); +``` + +### Unsafe Egress (EgressAllowlistGuard) +```diff +- const response = await fetch(userProvidedUrl); ++ const url = new URL(userProvidedUrl); ++ if (!ALLOWED_DOMAINS.includes(url.hostname)) throw new Error("Domain not allowed"); ++ const response = await fetch(url.toString()); +``` diff --git a/clawdstrike-plugin/bun.lockb b/clawdstrike-plugin/bun.lockb new file mode 100755 index 000000000..0b59aa701 Binary files /dev/null and b/clawdstrike-plugin/bun.lockb differ diff --git a/clawdstrike-plugin/commands/audit.md b/clawdstrike-plugin/commands/audit.md new file mode 100644 index 000000000..b68fbfe2f --- /dev/null +++ b/clawdstrike-plugin/commands/audit.md @@ -0,0 +1,55 @@ +--- +description: "Show security audit trail for the current session" +--- + +# ClawdStrike Audit + +Display a chronological security audit trail for the current session. + +## Arguments + +- `$ARGUMENTS` (optional): Session ID to filter audit events. If provided, only show events for that session. If omitted, show events for the current session. + +## Steps + +1. Call `clawdstrike_timeline` MCP tool with the current session context (or the session ID passed as `$ARGUMENTS` if provided) +2. Format the results as a chronological table with these columns: + +| Timestamp | Action | Target | Verdict | Guard | Details | +|-----------|--------|--------|---------|-------|---------| + +### Column Meanings + +| Column | Description | +|--------|-------------| +| **Timestamp** | ISO-8601 time when the action was evaluated | +| **Action** | The action_type that was checked (file, shell, egress, mcp_tool, prompt, computer_use) | +| **Target** | The specific resource: file path, command string, domain, tool name | +| **Verdict** | The enforcement decision: ALLOW, DENY, or AUDIT | +| **Guard** | The guard that produced the verdict (or "all" if multiple agreed) | +| **Details** | Additional context such as matched pattern, denial reason, or evidence snippet | + +3. Use these verdict indicators: + - `ALLOW` for permitted actions + - `DENY` for blocked actions + - `AUDIT` for logged-but-allowed actions + +4. After the table, provide a summary: + - Total actions evaluated + - Number of allows, denies, and audits + - Most frequently triggered guards + - Any patterns worth noting (e.g., repeated denials on the same target) + +If the session has no events yet, indicate that no actions have been evaluated in this session. + +## Common Audit Queries + +These are common patterns to look for when reviewing audit trails: + +| Query | How to Investigate | +|-------|-------------------| +| All denied actions | Filter the table to `Verdict = DENY` rows. Look for patterns in targets. | +| Actions on a specific file | Search the Target column for the file path. Note the sequence of actions. | +| Guard-specific activity | Filter by Guard column to see all evaluations for a single guard (e.g., SecretLeakGuard). | +| Escalation attempts | Look for sequences: file reads of credentials followed by shell commands or egress. | +| High-frequency targets | Count occurrences per Target. Repeated denials on the same target suggest misconfiguration or probing. | diff --git a/clawdstrike-plugin/commands/policy.md b/clawdstrike-plugin/commands/policy.md new file mode 100644 index 000000000..71bcebd9f --- /dev/null +++ b/clawdstrike-plugin/commands/policy.md @@ -0,0 +1,62 @@ +--- +description: "Show active security policy and guard details" +--- + +# ClawdStrike Policy + +Display the active security policy with guard configuration details. + +## Steps + +1. Call `clawdstrike_policy_show` MCP tool to retrieve the current policy +2. Present the policy metadata: + - Policy name and schema version + - Base ruleset (if using `extends`) + - Number of enabled/disabled guards + +3. Format guard configurations as a table: + +| Guard | Status | Key Settings | +|-------|--------|-------------| +| ForbiddenPathGuard | Enabled/Disabled | Blocked paths list | +| PathAllowlistGuard | Enabled/Disabled | Allowed paths list | +| EgressAllowlistGuard | Enabled/Disabled | Allowed domains | +| SecretLeakGuard | Enabled/Disabled | Detection patterns | +| PatchIntegrityGuard | Enabled/Disabled | Validation rules | +| ShellCommandGuard | Enabled/Disabled | Blocked commands | +| McpToolGuard | Enabled/Disabled | Tool allowlist/denylist | +| PromptInjectionGuard | Enabled/Disabled | Detection threshold | +| JailbreakGuard | Enabled/Disabled | Detection layers | +| ComputerUseGuard | Enabled/Disabled | Allowed actions | +| RemoteDesktopSideChannelGuard | Enabled/Disabled | Channel restrictions | +| InputInjectionCapabilityGuard | Enabled/Disabled | Capability restrictions | + +4. If any guards are disabled, note what action types are unprotected as a result. + +## Per-Guard Impact + +For each guard, explain what changes when it is enabled vs disabled: + +| Guard | When Enabled | When Disabled | +|-------|-------------|---------------| +| **ForbiddenPathGuard** | Access to sensitive paths (e.g., /etc/shadow, ~/.ssh) is blocked | All file paths accessible; rely on OS-level permissions only | +| **PathAllowlistGuard** | Only explicitly listed paths are accessible; everything else is denied | File access governed only by ForbiddenPathGuard (denylist) | +| **EgressAllowlistGuard** | Only listed domains can be contacted; all other egress is blocked | Unrestricted outbound network access | +| **SecretLeakGuard** | File writes are scanned for secrets; matches are denied | Secrets can be written to files without detection | +| **PatchIntegrityGuard** | Patches/diffs are validated for unsafe patterns before application | Patches applied without safety checks | +| **ShellCommandGuard** | Dangerous commands (rm -rf, sudo, curl\|bash) are blocked | All shell commands allowed without restriction | +| **McpToolGuard** | Only allowlisted MCP tools can be invoked | All MCP tools accessible | +| **PromptInjectionGuard** | Input text is scanned for injection patterns | Prompt injection attempts pass through undetected | +| **JailbreakGuard** | Multi-layer jailbreak detection active (heuristic + statistical + ML + LLM-judge) | No jailbreak detection | +| **ComputerUseGuard** | CUA actions restricted to allowed types | Unrestricted computer use actions | +| **RemoteDesktopSideChannelGuard** | Side channels (clipboard, audio, drives, file transfer) restricted | No side-channel controls | +| **InputInjectionCapabilityGuard** | Input injection capabilities restricted in CUA environments | Unrestricted input injection | + +## Guard Dependencies + +Some guards interact with or override each other: + +- **PathAllowlistGuard overrides ForbiddenPathGuard**: When PathAllowlistGuard is enabled, it acts as the primary file access control. ForbiddenPathGuard still runs as a second layer, but PathAllowlistGuard's allowlist is the first gate. A path must pass both guards. +- **ShellCommandGuard and ForbiddenPathGuard**: Shell commands that reference file paths are checked by ShellCommandGuard for the command itself, but the file paths within the command are not separately checked by ForbiddenPathGuard. Use both guards for defense-in-depth. +- **McpToolGuard and other guards**: McpToolGuard controls which tools can be called, but does not inspect the arguments. Other guards (e.g., ShellCommandGuard for a shell tool, EgressAllowlistGuard for a fetch tool) evaluate the action the tool performs. +- **PromptInjectionGuard and JailbreakGuard**: Both evaluate prompt/input content but use different detection strategies. JailbreakGuard is more comprehensive (4 layers) but higher latency. They can run independently or together for layered defense. diff --git a/clawdstrike-plugin/commands/posture.md b/clawdstrike-plugin/commands/posture.md new file mode 100644 index 000000000..d7888ab8b --- /dev/null +++ b/clawdstrike-plugin/commands/posture.md @@ -0,0 +1,80 @@ +--- +description: "Assess overall security posture with a letter grade" +--- + +# ClawdStrike Posture + +Perform a comprehensive security posture assessment and produce a letter grade (A-F). + +## Steps + +1. **Get active policy**: Call `clawdstrike_policy_show` to retrieve the current policy and guard configuration +2. **Scan MCP configs**: Call `clawdstrike_scan` to check all MCP server configurations for issues +3. **Check recent denials**: Call `clawdstrike_query` with `verdict=deny` to find recent policy violations + +## Scoring + +Assess posture across these categories and assign a sub-grade (A-F) to each: + +| Category | Weight | What to Check | +|----------|--------|---------------| +| **Policy Strength** | 25% | Ruleset level (strict > default > permissive), number of enabled guards | +| **MCP Config** | 25% | Scan findings -- critical/high issues lower the grade | +| **Violation Rate** | 25% | Frequency and severity of recent denials | +| **Coverage** | 25% | Guard coverage across action types (file, shell, egress, mcp_tool) | + +### Grade Scale + +| Grade | Score Range | Criteria | +|-------|-----------|----------| +| **A** | 90 - 100 | Strong security posture, strict policy, no critical findings | +| **B** | 80 - 89 | Good posture, minor gaps or medium findings | +| **C** | 70 - 79 | Moderate posture, some high findings or significant gaps | +| **D** | 60 - 69 | Weak posture, critical findings or major gaps in coverage | +| **F** | 0 - 59 | Minimal security, permissive policy with unaddressed critical issues | + +### Scoring Examples + +**Policy Strength** (25 points max): +- `strict` ruleset with all guards enabled: 25 +- `default` ruleset with most guards enabled: 20 +- `ai-agent` ruleset with standard config: 18 +- `permissive` ruleset: 5 +- No policy loaded: 0 + +**MCP Config** (25 points max): +- Zero findings across all servers: 25 +- Low findings only: 20 +- Medium findings present: 15 +- High findings present: 8 +- Critical findings present: 0 + +**Violation Rate** (25 points max): +- No denials in session: 25 +- Occasional denials, no pattern: 20 +- Repeated denials on same target (misconfiguration): 15 +- Frequent denials with escalation patterns: 5 +- Active exploit attempts detected: 0 + +**Coverage** (25 points max): +- All 4 action types covered (file, shell, egress, mcp_tool): 25 +- 3 of 4 action types covered: 18 +- 2 of 4 action types covered: 12 +- 1 action type covered: 6 +- No guard coverage: 0 + +## Output Format + +``` +Security Posture: [GRADE] + +Policy Strength: [grade] - [details] +MCP Config: [grade] - [details] +Violation Rate: [grade] - [details] +Guard Coverage: [grade] - [details] + +Recommendations: +1. [Most impactful improvement] +2. [Second priority] +3. [Third priority] +``` diff --git a/clawdstrike-plugin/commands/scan.md b/clawdstrike-plugin/commands/scan.md new file mode 100644 index 000000000..bf1725be6 --- /dev/null +++ b/clawdstrike-plugin/commands/scan.md @@ -0,0 +1,48 @@ +--- +description: "Scan MCP server configurations for security issues" +--- + +# ClawdStrike Scan + +Scan all configured MCP servers for security vulnerabilities and policy violations. + +## Steps + +1. Call the `clawdstrike_scan` MCP tool to analyze the current MCP server configurations +2. Group findings by severity level: Critical, High, Medium, Low +3. Present results in this format: + +### Output Format + +For each finding: +- **Severity**: Critical/High/Medium/Low +- **Guard**: Which guard detected the issue +- **Target**: The affected MCP server or configuration +- **Issue**: What was found +- **Remediation**: Specific steps to fix the issue + +### Severity to Risk Mapping + +| Severity | Risk Category | Examples | +|----------|--------------|---------| +| **Critical** | RCE / Arbitrary code execution | MCP server with unrestricted shell access, eval-based tool handlers, unsandboxed code interpreters | +| **High** | Auth bypass / Secret exposure | Missing authentication on MCP endpoints, credentials in server config, overprivileged tool permissions | +| **Medium** | Config drift / Overprivileged access | Egress allowlist too broad, file access beyond project scope, deprecated TLS versions | +| **Low** | Info disclosure / Best practice | Verbose error messages, missing rate limiting, no audit logging configured | + +### Remediation Priority + +Address findings in severity order: +1. **Critical**: Fix immediately. These represent active exploitation vectors. Block the affected MCP server until remediated. +2. **High**: Fix before next session. These could lead to credential compromise or unauthorized access. +3. **Medium**: Fix within the current work cycle. These represent security debt that increases risk over time. +4. **Low**: Track and fix opportunistically. These improve defense-in-depth but are not urgent. + +### Summary + +End with a summary line: +``` +Scan complete: X critical, Y high, Z medium, W low findings across N MCP servers. +``` + +If no issues are found, confirm that all configurations pass policy checks. diff --git a/clawdstrike-plugin/commands/selftest.md b/clawdstrike-plugin/commands/selftest.md new file mode 100644 index 000000000..5e5fe784e --- /dev/null +++ b/clawdstrike-plugin/commands/selftest.md @@ -0,0 +1,129 @@ +--- +description: "Run ClawdStrike self-test to verify all components are working" +--- + +# ClawdStrike Selftest + +Run a comprehensive self-test to verify that all ClawdStrike components are operational. Reports PASS/FAIL for each check with an overall score. + +## Steps + +Run each of the following 6 checks in order. For each check, report PASS or FAIL with details. + +### Check 1: CLI Binary + +**What it tests**: The `clawdstrike` CLI binary is installed and executable. + +**How to check**: Run `clawdstrike --version` using the Bash tool. + +**PASS**: Command exits 0 and prints a version string. +**FAIL**: Command not found, permission denied, or non-zero exit. + +**Remediation on failure**: +- Install the CLI: `cargo install --path crates/services/hush-cli` +- Verify it is on PATH: `which clawdstrike` +- If using a standalone binary, check file permissions: `chmod +x /path/to/clawdstrike` + +### Check 2: hushd Connectivity + +**What it tests**: The hushd daemon is reachable and responding. + +**How to check**: Call the `clawdstrike_policy_show` MCP tool with no arguments. A successful response means hushd is connected. + +**PASS**: Tool returns policy data (even if it is a default/empty policy). +**FAIL**: Connection refused, timeout, or error response. + +**Remediation on failure**: +- Check if hushd is running: `ps aux | grep hushd` +- Start hushd if needed: `cargo run -p hushd` +- Verify the MCP server configuration points to the correct hushd endpoint +- Check `HUSHD_URL` environment variable + +### Check 3: Policy Load + +**What it tests**: A valid security policy is loaded and active. + +**How to check**: Inspect the response from Check 2. Verify that it contains a `schema_version` field and at least one configured guard. + +**PASS**: Policy contains a valid schema version and one or more guards. +**FAIL**: No policy loaded, schema version missing, or zero guards configured. + +**Remediation on failure**: +- Load a policy: `clawdstrike check --ruleset default` +- Verify policy file exists and is valid YAML +- Check for syntax errors: use `clawdstrike_policy_lint` (Check 4) + +### Check 4: Policy Lint + +**What it tests**: The active policy passes schema validation with no errors. + +**How to check**: Call the `clawdstrike_policy_lint` MCP tool on the active policy. + +**PASS**: Lint returns zero errors (warnings are acceptable). +**FAIL**: Lint returns one or more errors. + +**Remediation on failure**: +- Review the lint errors -- they indicate specific schema violations +- Check `schema_version` matches the expected format (e.g., "1.2.0") +- Validate guard names are spelled correctly +- Ensure required fields are present for each guard configuration + +### Check 5: MCP Tool Ping + +**What it tests**: The ClawdStrike MCP tools are registered and responsive. + +**How to check**: Call `clawdstrike_policy_eval` with a simple test action: `action_type=file`, `target=/tmp/selftest-probe`. This tests that the MCP tool pipeline is working end-to-end. + +**PASS**: Tool returns a verdict (allow or deny) with guard evaluation results. +**FAIL**: Tool not found, connection error, or malformed response. + +**Remediation on failure**: +- Verify the MCP server is configured in your Claude settings +- Check that the clawdstrike MCP server process is running +- Restart the MCP server and retry +- Check MCP server logs for errors + +### Check 6: Receipt Directory Writable + +**What it tests**: The receipt storage directory exists and is writable (receipts are signed attestations of security decisions). + +**How to check**: Run the following using the Bash tool: +``` +test -d "${CLAWDSTRIKE_RECEIPT_DIR:-$HOME/.clawdstrike/receipts}" && \ + test -w "${CLAWDSTRIKE_RECEIPT_DIR:-$HOME/.clawdstrike/receipts}" && \ + echo "writable" || echo "not writable" +``` + +**PASS**: Directory exists and is writable. +**FAIL**: Directory does not exist or is not writable. + +**Remediation on failure**: +- Create the directory: `mkdir -p "${CLAWDSTRIKE_RECEIPT_DIR:-$HOME/.clawdstrike/receipts}"` +- Fix permissions: `chmod 700 "$HOME/.clawdstrike/receipts"` +- If using a custom path, verify `CLAWDSTRIKE_RECEIPT_DIR` is set correctly + +## Output Format + +After running all checks, present results in this format: + +``` +ClawdStrike Self-Test Results +============================= + +[PASS] CLI Binary - clawdstrike v0.1.x +[PASS] hushd Connectivity - Connected +[PASS] Policy Load - schema v1.2.0, 6 guards active +[FAIL] Policy Lint - 2 errors found +[PASS] MCP Tool Ping - policy_eval responded in Xms +[PASS] Receipt Directory - ~/.clawdstrike/receipts writable + +Overall: 5/6 checks passed + +Recommended actions: +1. [From failed check] Specific remediation step +``` + +If all 6 checks pass, end with: +``` +Overall: 6/6 checks passed -- ClawdStrike is fully operational. +``` diff --git a/clawdstrike-plugin/commands/tui.md b/clawdstrike-plugin/commands/tui.md new file mode 100644 index 000000000..260de1f7e --- /dev/null +++ b/clawdstrike-plugin/commands/tui.md @@ -0,0 +1,32 @@ +--- +description: "Launch the ClawdStrike TUI dashboard" +--- + +# ClawdStrike TUI + +Launch the interactive ClawdStrike terminal dashboard. + +## Steps + +1. Detect the plugin root directory using this resolution order: + - If `CLAWDSTRIKE_PLUGIN_DIR` environment variable is set, use that + - Otherwise, search ancestor directories from the current working directory for a `.claude-plugin/plugin.json` file + - Walk up from the cwd: check `$CWD/.claude-plugin/plugin.json`, then `$CWD/../.claude-plugin/plugin.json`, etc. + - If found, the plugin root is the directory containing `.claude-plugin/` + - If not found after reaching filesystem root, report an error and suggest setting `CLAWDSTRIKE_PLUGIN_DIR` + +2. Run the TUI using the Bash tool from the detected plugin root: + ``` + bun run --cwd "$PLUGIN_ROOT/../apps/terminal" cli + ``` + +3. The TUI provides an interactive dashboard with: + - Real-time event stream + - Policy status overview + - Guard activity metrics + - Session audit log + +If the command fails: +- Check that `CLAWDSTRIKE_PLUGIN_DIR` is set or that the plugin root could be found +- Check that dependencies are installed (`bun install` in the apps/terminal directory) +- Check that the required environment variables are set diff --git a/clawdstrike-plugin/hooks/hooks.json b/clawdstrike-plugin/hooks/hooks.json new file mode 100644 index 000000000..a7537155f --- /dev/null +++ b/clawdstrike-plugin/hooks/hooks.json @@ -0,0 +1,64 @@ +{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "bash ${CLAUDE_PLUGIN_ROOT}/scripts/pre-tool-check.sh" + } + ] + } + ], + "PostToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "bash ${CLAUDE_PLUGIN_ROOT}/scripts/post-tool-receipt.sh" + } + ] + } + ], + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "bash ${CLAUDE_PLUGIN_ROOT}/scripts/session-start.sh" + } + ] + } + ], + "SessionEnd": [ + { + "hooks": [ + { + "type": "command", + "command": "bash ${CLAUDE_PLUGIN_ROOT}/scripts/session-end.sh" + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "bash ${CLAUDE_PLUGIN_ROOT}/scripts/user-prompt-check.sh" + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "bash ${CLAUDE_PLUGIN_ROOT}/scripts/stop-handler.sh" + } + ] + } + ] + } +} diff --git a/clawdstrike-plugin/package.json b/clawdstrike-plugin/package.json new file mode 100644 index 000000000..fb50c43bc --- /dev/null +++ b/clawdstrike-plugin/package.json @@ -0,0 +1,18 @@ +{ + "name": "@clawdstrike/claude-plugin", + "version": "0.1.0", + "description": "ClawdStrike Claude Code plugin — policy enforcement, audit receipts, and security tools", + "type": "module", + "scripts": { + "mcp": "bun run scripts/mcp-server.ts", + "test": "bun test", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.1" + }, + "devDependencies": { + "@types/bun": "latest", + "typescript": "^5.7.0" + } +} diff --git a/clawdstrike-plugin/scripts/cli-bridge.ts b/clawdstrike-plugin/scripts/cli-bridge.ts new file mode 100644 index 000000000..a0587848a --- /dev/null +++ b/clawdstrike-plugin/scripts/cli-bridge.ts @@ -0,0 +1,182 @@ +/** + * CLI bridge — spawns the clawdstrike binary and returns structured results. + * + * Extracted from mcp-server.ts so it can be imported by both the server and tests. + */ + +export const CLI = process.env.CLAWDSTRIKE_CLI ?? "clawdstrike"; +export const DEFAULT_TIMEOUT = 30_000; + +export interface CliResult<T> { + ok: boolean; + data?: T; + error?: string; +} + +/** + * Run a CLI command, automatically appending `--json` for structured output. + * Parses stdout as JSON on success. + */ +export async function runCli<T>( + args: string[], + timeout = DEFAULT_TIMEOUT, +): Promise<CliResult<T>> { + const proc = Bun.spawn([CLI, ...args, "--json"], { + stdout: "pipe", + stderr: "pipe", + }); + + const timer = + timeout > 0 ? setTimeout(() => proc.kill(), timeout) : undefined; + + try { + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + if (timer) clearTimeout(timer); + + if (exitCode !== 0) { + return { ok: false, error: stderr.trim() || `Exit code ${exitCode}` }; + } + + const trimmed = stdout.trim(); + if (!trimmed) return { ok: true, data: undefined }; + + try { + return { ok: true, data: JSON.parse(trimmed) as T }; + } catch { + return { + ok: false, + error: `Failed to parse JSON: ${trimmed.slice(0, 200)}`, + }; + } + } catch (err) { + if (timer) clearTimeout(timer); + return { + ok: false, + error: err instanceof Error ? err.message : String(err), + }; + } +} + +/** + * Run a CLI command returning raw text output (no `--json` flag, no JSON parsing). + * Use for commands that emit YAML, plain text, or other non-JSON formats. + */ +export async function runCliRaw( + args: string[], + timeout = DEFAULT_TIMEOUT, +): Promise<CliResult<string>> { + const proc = Bun.spawn([CLI, ...args], { + stdout: "pipe", + stderr: "pipe", + }); + + const timer = + timeout > 0 ? setTimeout(() => proc.kill(), timeout) : undefined; + + try { + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + if (timer) clearTimeout(timer); + + if (exitCode !== 0) { + return { ok: false, error: stderr.trim() || `Exit code ${exitCode}` }; + } + + return { ok: true, data: stdout.trimEnd() }; + } catch (err) { + if (timer) clearTimeout(timer); + return { + ok: false, + error: err instanceof Error ? err.message : String(err), + }; + } +} + +/** + * Run a CLI command with data piped to stdin. Appends `--json` for structured output. + */ +export async function runCliStdin<T>( + args: string[], + stdinData: string, + timeout = DEFAULT_TIMEOUT, +): Promise<CliResult<T>> { + const proc = Bun.spawn([CLI, ...args, "--json"], { + stdout: "pipe", + stderr: "pipe", + stdin: "pipe", + }); + + const timer = + timeout > 0 ? setTimeout(() => proc.kill(), timeout) : undefined; + + try { + proc.stdin.write(stdinData); + proc.stdin.end(); + + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + if (timer) clearTimeout(timer); + + if (exitCode !== 0) { + return { ok: false, error: stderr.trim() || `Exit code ${exitCode}` }; + } + + const trimmed = stdout.trim(); + if (!trimmed) return { ok: true, data: undefined }; + + try { + return { ok: true, data: JSON.parse(trimmed) as T }; + } catch { + return { + ok: false, + error: `Failed to parse JSON: ${trimmed.slice(0, 200)}`, + }; + } + } catch (err) { + if (timer) clearTimeout(timer); + return { + ok: false, + error: err instanceof Error ? err.message : String(err), + }; + } +} + +/** Format a CliResult as an MCP CallToolResult. */ +export function toToolResult(result: CliResult<unknown>) { + if (!result.ok) { + return { + content: [{ type: "text" as const, text: result.error ?? "Unknown error" }], + isError: true, + }; + } + + const text = + result.data !== undefined + ? typeof result.data === "string" + ? result.data + : JSON.stringify(result.data, null, 2) + : "OK (no output)"; + + return { content: [{ type: "text" as const, text }] }; +} + +/** Check that the CLI binary is available. Throws if not. */ +export async function healthCheck(): Promise<string> { + const result = await runCliRaw(["--version"], 5_000); + if (!result.ok) { + throw new Error( + `clawdstrike CLI not found or not executable (${CLI}): ${result.error}`, + ); + } + return result.data ?? "unknown"; +} diff --git a/clawdstrike-plugin/scripts/mcp-server.ts b/clawdstrike-plugin/scripts/mcp-server.ts new file mode 100644 index 000000000..48750e80b --- /dev/null +++ b/clawdstrike-plugin/scripts/mcp-server.ts @@ -0,0 +1,492 @@ +/** + * ClawdStrike MCP Server + * + * Stdio-based MCP server exposing tools, resources, and prompts that shell + * out to the `clawdstrike` CLI binary. Each tool builds a CLI arg list, + * spawns the process, and returns the parsed output. + */ + +import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { z } from "zod"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { readFile } from "node:fs/promises"; + +import { + runCli, + runCliRaw, + runCliStdin, + toToolResult, + healthCheck, +} from "./cli-bridge.ts"; + +// --------------------------------------------------------------------------- +// Server +// --------------------------------------------------------------------------- + +const server = new McpServer({ + name: "clawdstrike", + version: "0.1.0", +}); + +// --------------------------------------------------------------------------- +// Tools +// --------------------------------------------------------------------------- + +// 1. clawdstrike_check --------------------------------------------------- +server.tool( + "clawdstrike_check", + "Check an action against ClawdStrike policy. Returns the enforcement decision (allow/deny) with evidence.", + { + action_type: z.string().describe("Action type (e.g. file, network, shell, mcp_tool)"), + target: z.string().describe("Target of the action (path, URL, command, etc.)"), + ruleset: z.string().optional().describe("Policy ruleset to evaluate against"), + content: z.string().optional().describe("Optional content for file-write checks"), + }, + async ({ action_type, target, ruleset, content }) => { + const args = ["check", "--action-type", action_type]; + if (ruleset) args.push("--ruleset", ruleset); + if (content) args.push("--content", content); + args.push(target); + return toToolResult(await runCli(args)); + }, +); + +// 2. clawdstrike_scan ----------------------------------------------------- +server.tool( + "clawdstrike_scan", + "Scan MCP server configurations for security issues, policy violations, and suspicious tool definitions.", + { + targets: z.array(z.string()).optional().describe("Paths to MCP config files or directories to scan"), + policy: z.string().optional().describe("Policy ruleset to apply during scan"), + }, + async ({ targets, policy }) => { + const args = ["hunt", "scan"]; + if (targets?.length) args.push(...targets); + if (policy) args.push("--policy", policy); + return toToolResult(await runCli(args)); + }, +); + +// 3. clawdstrike_query ---------------------------------------------------- +server.tool( + "clawdstrike_query", + "Search security events by filters. Returns matching events from the audit log.", + { + query: z.string().optional().describe("Free-text search query"), + source: z.string().optional().describe("Event source filter (tetragon, hubble, receipt, spine)"), + verdict: z.string().optional().describe("Verdict filter (allow, deny, audit)"), + kind: z.string().optional().describe("Event kind filter (process_exec, file_write, network_connect, etc.)"), + since: z.string().optional().describe("Start time (ISO 8601 or relative like '1h', '30m')"), + until: z.string().optional().describe("End time (ISO 8601 or relative)"), + limit: z.number().optional().describe("Maximum number of results"), + }, + async ({ query, source, verdict, kind, since, until, limit }) => { + const args = ["hunt", "query"]; + if (query) args.push(query); + if (source) args.push("--source", source); + if (verdict) args.push("--verdict", verdict); + if (kind) args.push("--kind", kind); + if (since) args.push("--since", since); + if (until) args.push("--until", until); + if (limit !== undefined) args.push("--limit", String(Math.floor(limit))); + return toToolResult(await runCli(args)); + }, +); + +// 4. clawdstrike_timeline ------------------------------------------------- +server.tool( + "clawdstrike_timeline", + "View a chronological timeline of security events. Useful for understanding event sequences and investigating incidents.", + { + source: z.string().optional().describe("Event source filter"), + verdict: z.string().optional().describe("Verdict filter (allow, deny, audit)"), + since: z.string().optional().describe("Start time (ISO 8601 or relative)"), + until: z.string().optional().describe("End time (ISO 8601 or relative)"), + limit: z.number().optional().describe("Maximum number of events"), + }, + async ({ source, verdict, since, until, limit }) => { + const args = ["hunt", "timeline"]; + if (source) args.push("--source", source); + if (verdict) args.push("--verdict", verdict); + if (since) args.push("--since", since); + if (until) args.push("--until", until); + if (limit !== undefined) args.push("--limit", String(Math.floor(limit))); + return toToolResult(await runCli(args)); + }, +); + +// 5. clawdstrike_correlate ------------------------------------------------ +server.tool( + "clawdstrike_correlate", + "Run correlation rules against security events to detect attack patterns and suspicious behavior sequences.", + { + rules: z.array(z.string()).describe("Correlation rule names or paths to apply"), + since: z.string().optional().describe("Start time (ISO 8601 or relative)"), + until: z.string().optional().describe("End time (ISO 8601 or relative)"), + }, + async ({ rules, since, until }) => { + const args = ["hunt", "correlate"]; + for (const r of rules) args.push("--rules", r); + if (since) args.push("--since", since); + if (until) args.push("--until", until); + return toToolResult(await runCli(args)); + }, +); + +// 6. clawdstrike_ioc ------------------------------------------------------ +server.tool( + "clawdstrike_ioc", + "Match events against Indicator of Compromise (IOC) feeds. Detects known-malicious IPs, domains, hashes, and commands.", + { + feeds: z.array(z.string()).describe("IOC feed names or paths"), + since: z.string().optional().describe("Start time (ISO 8601 or relative)"), + until: z.string().optional().describe("End time (ISO 8601 or relative)"), + }, + async ({ feeds, since, until }) => { + const args = ["hunt", "ioc"]; + for (const f of feeds) args.push("--feed", f); + if (since) args.push("--since", since); + if (until) args.push("--until", until); + return toToolResult(await runCli(args)); + }, +); + +// 7. clawdstrike_policy_show (Bug B: positional arg, raw YAML output) ----- +server.tool( + "clawdstrike_policy_show", + "Display the active security policy configuration, including all guards, rules, and their settings. Returns YAML.", + { + ruleset: z.string().optional().describe("Ruleset name to show (default, strict, permissive, ai-agent, etc.)"), + merged: z.boolean().optional().describe("Show the fully-merged policy with all inherited rules resolved"), + }, + async ({ ruleset, merged }) => { + const args = ["policy", "show"]; + if (ruleset) args.push(ruleset); + if (merged) args.push("--merged"); + return toToolResult(await runCliRaw(args)); + }, +); + +// 8. clawdstrike_policy_eval (Bug C: pipe event_json via stdin) ----------- +server.tool( + "clawdstrike_policy_eval", + "Evaluate a specific event against a policy reference. Returns the enforcement decision and matched rules.", + { + policy_ref: z.string().describe("Policy reference (ruleset name, file path, or URL)"), + event_json: z.string().describe("JSON string of the event to evaluate"), + }, + async ({ policy_ref, event_json }) => { + // Validate that event_json is parseable JSON before sending to CLI + try { + JSON.parse(event_json); + } catch { + return { + content: [{ type: "text" as const, text: "Invalid JSON in event_json parameter" }], + isError: true, + }; + } + const args = ["policy", "eval", policy_ref, "-"]; + return toToolResult(await runCliStdin(args, event_json)); + }, +); + +// 9. clawdstrike_hunt_diff ------------------------------------------------ +server.tool( + "clawdstrike_hunt_diff", + "Compare MCP scan results against a baseline to detect configuration drift: new servers, removed servers, or changed tool sets.", + { + baseline: z.string().describe("Path to baseline scan results"), + current: z.string().optional().describe("Path to current scan results (runs a fresh scan if omitted)"), + }, + async ({ baseline, current }) => { + const args = ["hunt", "scan", "diff", "--baseline", baseline]; + if (current) args.push("--current", current); + return toToolResult(await runCli(args)); + }, +); + +// 10. clawdstrike_report -------------------------------------------------- +server.tool( + "clawdstrike_report", + "Generate a composite security report by running timeline analysis and correlation rules, then combining the results.", + { + rules: z.array(z.string()).describe("Correlation rule names or paths"), + since: z.string().optional().describe("Start time (ISO 8601 or relative)"), + until: z.string().optional().describe("End time (ISO 8601 or relative)"), + }, + async ({ rules, since, until }) => { + // Build shared time-range args + const timeArgs: string[] = []; + if (since) timeArgs.push("--since", since); + if (until) timeArgs.push("--until", until); + + // Run timeline and correlate in parallel, with 60s timeout for reports + const [timelineResult, correlateResult] = await Promise.all([ + runCli<unknown>(["hunt", "timeline", ...timeArgs], 60_000), + (() => { + const args = ["hunt", "correlate"]; + for (const r of rules) args.push("--rules", r); + args.push(...timeArgs); + return runCli<unknown>(args, 60_000); + })(), + ]); + + const report = { + generated_at: new Date().toISOString(), + timeline: timelineResult.ok + ? timelineResult.data + : { error: timelineResult.error }, + correlation: correlateResult.ok + ? correlateResult.data + : { error: correlateResult.error }, + }; + + return { + content: [{ type: "text" as const, text: JSON.stringify(report, null, 2) }], + }; + }, +); + +// 11. clawdstrike_policy_lint --------------------------------------------- +server.tool( + "clawdstrike_policy_lint", + "Lint a policy file or ruleset reference for errors and warnings. Returns structured diagnostics.", + { + ref: z.string().describe("Policy reference to lint (ruleset name, file path, or URL)"), + strict: z.boolean().optional().describe("Enable strict mode for additional warnings"), + }, + async ({ ref, strict }) => { + const args = ["policy", "lint", ref]; + if (strict) args.push("--strict"); + return toToolResult(await runCli(args)); + }, +); + +// 12. clawdstrike_policy_simulate ----------------------------------------- +server.tool( + "clawdstrike_policy_simulate", + "Dry-run a batch of events against a policy reference. Returns per-event decisions without enforcing.", + { + ref: z.string().describe("Policy reference to simulate against"), + events: z.string().describe("JSON array of events to simulate"), + }, + async ({ ref, events }) => { + try { + JSON.parse(events); + } catch { + return { + content: [{ type: "text" as const, text: "Invalid JSON in events parameter" }], + isError: true, + }; + } + const args = ["policy", "simulate", ref, "-"]; + return toToolResult(await runCliStdin(args, events, 60_000)); + }, +); + +// 13. clawdstrike_verify_receipt ------------------------------------------ +server.tool( + "clawdstrike_verify_receipt", + "Verify an Ed25519-signed enforcement receipt. Returns verification status and decoded payload.", + { + pubkey: z.string().describe("Ed25519 public key (hex or base64)"), + receipt: z.string().describe("Receipt to verify (file path or JSON string)"), + }, + async ({ pubkey, receipt }) => { + const args = ["verify", "--pubkey", pubkey, receipt]; + return toToolResult(await runCli(args)); + }, +); + +// 14. clawdstrike_merkle_verify ------------------------------------------- +server.tool( + "clawdstrike_merkle_verify", + "Verify a Merkle inclusion proof. Returns text confirmation of proof validity.", + { + root: z.string().describe("Merkle root hash (hex)"), + leaf: z.string().describe("Leaf hash to verify (hex)"), + proof: z.string().describe("Comma-separated sibling hashes forming the proof path"), + }, + async ({ root, leaf, proof }) => { + const args = ["merkle", "verify", "--root", root, "--leaf", leaf, "--proof", proof]; + return toToolResult(await runCliRaw(args)); + }, +); + +// 15. clawdstrike_guard_inspect ------------------------------------------- +server.tool( + "clawdstrike_guard_inspect", + "Inspect a guard plugin's metadata, configuration schema, and capabilities.", + { + plugin_ref: z.string().describe("Guard plugin reference (built-in name or path to plugin)"), + }, + async ({ plugin_ref }) => { + const args = ["guard", "inspect", plugin_ref]; + return toToolResult(await runCliRaw(args)); + }, +); + +// --------------------------------------------------------------------------- +// Resources +// --------------------------------------------------------------------------- + +// Static resource: current session receipts +server.resource( + "session-receipts", + "clawdstrike://session/receipts", + { description: "Current session enforcement receipts (JSONL)" }, + async () => { + const sessionId = process.env.CLAWDSTRIKE_SESSION_ID ?? "current"; + const receiptsPath = join( + homedir(), + ".clawdstrike", + "receipts", + `session-${sessionId}.jsonl`, + ); + + try { + const data = await readFile(receiptsPath, "utf-8"); + return { + contents: [ + { + uri: "clawdstrike://session/receipts", + mimeType: "application/jsonl", + text: data, + }, + ], + }; + } catch { + return { + contents: [ + { + uri: "clawdstrike://session/receipts", + mimeType: "text/plain", + text: "No receipts found for current session.", + }, + ], + }; + } + }, +); + +// Template resource: policy by ruleset name +server.resource( + "policy", + new ResourceTemplate("clawdstrike://policy/{ruleset}", { list: undefined }), + { description: "Security policy configuration (YAML) for a given ruleset" }, + async (uri, { ruleset }) => { + const rulesetName = Array.isArray(ruleset) ? ruleset[0] : ruleset; + const result = await runCliRaw(["policy", "show", String(rulesetName)]); + + if (!result.ok) { + return { + contents: [ + { + uri: uri.href, + mimeType: "text/plain", + text: result.error ?? "Failed to load policy", + }, + ], + }; + } + + return { + contents: [ + { + uri: uri.href, + mimeType: "text/yaml", + text: result.data ?? "", + }, + ], + }; + }, +); + +// --------------------------------------------------------------------------- +// Prompts +// --------------------------------------------------------------------------- + +server.prompt( + "investigate-incident", + "Structured 6-step threat hunt workflow for investigating security incidents within a time range.", + { + since: z.string().describe("Start of investigation window (ISO 8601 or relative like '1h')"), + until: z.string().optional().describe("End of investigation window (ISO 8601 or relative)"), + rules: z.string().optional().describe("Comma-separated correlation rule names to apply"), + }, + ({ since, until, rules }) => { + const timeRange = until ? `from ${since} to ${until}` : `since ${since}`; + const rulesNote = rules + ? `Focus on these correlation rules: ${rules}` + : "Use all available correlation rules."; + + return { + messages: [ + { + role: "user" as const, + content: { + type: "text" as const, + text: [ + `Investigate security incidents ${timeRange}. ${rulesNote}`, + "", + "Follow this 6-step threat hunt workflow:", + "", + "1. **Scope** — Use clawdstrike_timeline to get an overview of events in the time range. Identify event volume, sources, and anomalies.", + "2. **Triage** — Use clawdstrike_query to filter for deny verdicts and high-severity events. Prioritize by impact.", + "3. **Correlate** — Use clawdstrike_correlate to run correlation rules and detect multi-step attack patterns.", + "4. **IOC Match** — Use clawdstrike_ioc to check events against known indicators of compromise.", + "5. **Evidence** — Collect and verify enforcement receipts. Check Merkle proofs for tamper evidence.", + "6. **Report** — Use clawdstrike_report to generate a composite summary. Include timeline, findings, and recommended actions.", + ].join("\n"), + }, + }, + ], + }; + }, +); + +server.prompt( + "assess-posture", + "Security posture assessment workflow: evaluate current policy, scan for misconfigurations, and identify gaps.", + { + ruleset: z.string().optional().describe("Ruleset to assess (defaults to active policy)"), + }, + ({ ruleset }) => { + const target = ruleset ? `the "${ruleset}" ruleset` : "the active policy"; + + return { + messages: [ + { + role: "user" as const, + content: { + type: "text" as const, + text: [ + `Assess the security posture of ${target}.`, + "", + "Follow this workflow:", + "", + "1. **Policy Review** — Use clawdstrike_policy_show to display the current policy configuration. Identify enabled guards and their settings.", + "2. **Lint** — Use clawdstrike_policy_lint with --strict to check for policy errors, warnings, and best-practice violations.", + "3. **MCP Scan** — Use clawdstrike_scan to detect misconfigured or suspicious MCP server definitions.", + "4. **Baseline Drift** — If a baseline exists, use clawdstrike_hunt_diff to detect configuration drift.", + "5. **Gap Analysis** — Compare the policy against the strict ruleset. Identify missing guards and permissive rules.", + "6. **Recommendations** — Summarize findings and provide prioritized remediation steps.", + ].join("\n"), + }, + }, + ], + }; + }, +); + +// --------------------------------------------------------------------------- +// Start +// --------------------------------------------------------------------------- + +// Health check: ensure CLI binary is available before accepting connections +await healthCheck(); + +const transport = new StdioServerTransport(); +await server.connect(transport); diff --git a/clawdstrike-plugin/scripts/post-tool-receipt.sh b/clawdstrike-plugin/scripts/post-tool-receipt.sh new file mode 100755 index 000000000..f168cfa5d --- /dev/null +++ b/clawdstrike-plugin/scripts/post-tool-receipt.sh @@ -0,0 +1,106 @@ +#!/bin/bash +# Clawdstrike post-tool receipt logger (plugin version) +# Writes one JSONL line per tool call for audit trail +# ALWAYS exits 0 -- never blocks on post-tool logging + +set -uo pipefail + +SESSION_ID="${CLAWDSTRIKE_SESSION_ID:-unknown}" +RECEIPT_DIR="$HOME/.clawdstrike/receipts" +RECEIPT_FILE="${RECEIPT_DIR}/session-${SESSION_ID}.jsonl" + +# Read hook input from stdin (best-effort) +INPUT=$(cat 2>/dev/null) || true + +# Extract tool name +TOOL_NAME=$(echo "$INPUT" | jq -er '.tool_name // empty' 2>/dev/null) || true +if [ -z "${TOOL_NAME:-}" ]; then + exit 0 +fi + +# Map tool names to action types (same mapping as pre-tool-check.sh) +TOOL_INPUT=$(echo "$INPUT" | jq -ec '.tool_input // {}' 2>/dev/null) || TOOL_INPUT="{}" + +case "$TOOL_NAME" in + Read) + ACTION_TYPE="file_access" + TARGET=$(echo "$TOOL_INPUT" | jq -er '.file_path // .path // empty' 2>/dev/null || true) + ;; + Glob) + ACTION_TYPE="file_access" + TARGET=$(echo "$TOOL_INPUT" | jq -er '.pattern // empty' 2>/dev/null || true) + ;; + Grep) + ACTION_TYPE="file_access" + TARGET=$(echo "$TOOL_INPUT" | jq -er '.file_path // .path // .pattern // empty' 2>/dev/null || true) + ;; + Write) + ACTION_TYPE="file_write" + TARGET=$(echo "$TOOL_INPUT" | jq -er '.file_path // .path // empty' 2>/dev/null || true) + ;; + Edit) + ACTION_TYPE="file_write" + TARGET=$(echo "$TOOL_INPUT" | jq -er '.file_path // .path // empty' 2>/dev/null || true) + ;; + Bash) + ACTION_TYPE="shell" + TARGET=$(echo "$TOOL_INPUT" | jq -er '.command // empty' 2>/dev/null || true) + ;; + WebFetch|WebSearch) + ACTION_TYPE="egress" + TARGET=$(echo "$TOOL_INPUT" | jq -er '.url // .query // empty' 2>/dev/null || true) + ;; + *) + ACTION_TYPE="mcp_tool" + TARGET="$TOOL_NAME" + ;; +esac + +# Default target if extraction failed +TARGET="${TARGET:-unknown}" + +# Determine actual outcome from tool result +OUTCOME="success" +if echo "$INPUT" | jq -e '.isError == true' >/dev/null 2>&1; then + OUTCOME="error" +elif echo "$INPUT" | jq -e '.error != null' >/dev/null 2>&1; then + OUTCOME="error" +fi + +# Extract duration if available +DURATION_MS="" +if DURATION_MS_RAW=$(echo "$INPUT" | jq -er '.response_duration_ms // empty' 2>/dev/null); then + DURATION_MS="$DURATION_MS_RAW" +fi + +# Ensure receipt directory exists +mkdir -p "$RECEIPT_DIR" 2>/dev/null || true + +# Generate ISO8601 timestamp +TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null) || TIMESTAMP="unknown" + +# Write JSONL receipt line (include duration_ms if available) +if [ -n "$DURATION_MS" ]; then + jq -cn \ + --arg timestamp "$TIMESTAMP" \ + --arg session_id "$SESSION_ID" \ + --arg tool_name "$TOOL_NAME" \ + --arg action_type "$ACTION_TYPE" \ + --arg target "$TARGET" \ + --arg outcome "$OUTCOME" \ + --argjson duration_ms "$DURATION_MS" \ + '{timestamp:$timestamp,session_id:$session_id,tool_name:$tool_name,action_type:$action_type,target:$target,outcome:$outcome,duration_ms:$duration_ms}' \ + >> "$RECEIPT_FILE" 2>/dev/null || true +else + jq -cn \ + --arg timestamp "$TIMESTAMP" \ + --arg session_id "$SESSION_ID" \ + --arg tool_name "$TOOL_NAME" \ + --arg action_type "$ACTION_TYPE" \ + --arg target "$TARGET" \ + --arg outcome "$OUTCOME" \ + '{timestamp:$timestamp,session_id:$session_id,tool_name:$tool_name,action_type:$action_type,target:$target,outcome:$outcome}' \ + >> "$RECEIPT_FILE" 2>/dev/null || true +fi + +exit 0 diff --git a/clawdstrike-plugin/scripts/pre-tool-check.sh b/clawdstrike-plugin/scripts/pre-tool-check.sh new file mode 100755 index 000000000..9557f528f --- /dev/null +++ b/clawdstrike-plugin/scripts/pre-tool-check.sh @@ -0,0 +1,212 @@ +#!/bin/bash +# Clawdstrike pre-tool hook for Claude Code (plugin version) +# Checks actions against security policy before execution +# Ported from ~/.claude/hooks/clawdstrike-check.sh with session_id support + +set -euo pipefail + +# Configuration +CLAWDSTRIKE_ENDPOINT="${CLAWDSTRIKE_ENDPOINT:-http://127.0.0.1:9878}" +CLAWDSTRIKE_TOKEN_FILE="${CLAWDSTRIKE_TOKEN_FILE:-$HOME/.config/clawdstrike/agent-local-token}" +CLAWDSTRIKE_HOOK_FAIL_OPEN="${CLAWDSTRIKE_HOOK_FAIL_OPEN:-0}" + +fail() { + local reason="$1" + echo "Clawdstrike hook error: ${reason}" >&2 + case "$CLAWDSTRIKE_HOOK_FAIL_OPEN" in + 1|true|True|TRUE|yes|Yes|YES) + echo "CLAWDSTRIKE_HOOK_FAIL_OPEN is set; allowing action despite hook failure." >&2 + exit 0 + ;; + esac + exit 1 +} + +# Read hook input from stdin +if ! INPUT=$(cat); then + fail "failed to read hook input" +fi + +# Extract tool name and input from hook data +if ! TOOL_NAME=$(echo "$INPUT" | jq -er '.tool_name // empty' 2>/dev/null); then + fail "invalid hook payload: missing/invalid .tool_name" +fi + +if ! TOOL_INPUT=$(echo "$INPUT" | jq -ec '.tool_input // {} | if type == "object" then . else {"tool_input": .} end' 2>/dev/null); then + fail "invalid hook payload: .tool_input is not JSON" +fi + +# Skip if no tool name +if [ -z "$TOOL_NAME" ]; then + exit 0 +fi + +# Map tool names to hushd /api/v1/check action types. +CONTENT="" +case "$TOOL_NAME" in + Read) + ACTION_TYPE="file_access" + TARGET=$(echo "$TOOL_INPUT" | jq -er '.file_path // .path // empty' 2>/dev/null || true) + ;; + Glob) + ACTION_TYPE="file_access" + TARGET=$(echo "$TOOL_INPUT" | jq -er '.pattern // empty' 2>/dev/null || true) + ;; + Grep) + ACTION_TYPE="file_access" + # Grep may be invoked without an explicit path (searching the full workspace). In that case + # fall back to `.pattern` so we don't bypass the policy check entirely. + TARGET=$(echo "$TOOL_INPUT" | jq -er '.file_path // .path // .pattern // empty' 2>/dev/null || true) + ;; + Write) + ACTION_TYPE="file_write" + TARGET=$(echo "$TOOL_INPUT" | jq -er '.file_path // .path // empty' 2>/dev/null || true) + CONTENT=$(echo "$TOOL_INPUT" | jq -er '.content // .text // empty' 2>/dev/null || true) + ;; + Edit) + ACTION_TYPE="file_write" + TARGET=$(echo "$TOOL_INPUT" | jq -er '.file_path // .path // empty' 2>/dev/null || true) + CONTENT=$(echo "$TOOL_INPUT" | jq -er '.new_string // .content // empty' 2>/dev/null || true) + ;; + Bash) + ACTION_TYPE="shell" + TARGET=$(echo "$TOOL_INPUT" | jq -er '.command // empty' 2>/dev/null || true) + ;; + WebFetch|WebSearch) + ACTION_TYPE="egress" + TARGET=$(echo "$TOOL_INPUT" | jq -er '.url // .query // empty' 2>/dev/null || true) + ;; + *) + # Unknown tool: treat as an MCP tool and let policy decide. + ACTION_TYPE="mcp_tool" + TARGET="$TOOL_NAME" + ;; +esac + +# Handle empty target based on tool type (fail-closed for tools that require a target) +if [ -z "${TARGET:-}" ]; then + case "$TOOL_NAME" in + Read|Write|Edit|Bash) + echo "BLOCKED by Clawdstrike: ${TOOL_NAME} requires a target but none was provided" >&2 + # Write denial receipt + if [ -n "${CLAWDSTRIKE_SESSION_ID:-}" ]; then + _RECEIPT_DIR="$HOME/.clawdstrike/receipts" + _RECEIPT_FILE="${_RECEIPT_DIR}/session-${CLAWDSTRIKE_SESSION_ID}.jsonl" + _TS=$(date -u +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null) || _TS="unknown" + mkdir -p "$_RECEIPT_DIR" 2>/dev/null || true + jq -cn \ + --arg timestamp "$_TS" \ + --arg session_id "$CLAWDSTRIKE_SESSION_ID" \ + --arg tool_name "$TOOL_NAME" \ + --arg action_type "$ACTION_TYPE" \ + --arg target "" \ + --arg outcome "deny" \ + --arg guard "empty_target" \ + --arg message "${TOOL_NAME} requires a target but none was provided" \ + '{timestamp:$timestamp,session_id:$session_id,tool_name:$tool_name,action_type:$action_type,target:$target,outcome:$outcome,guard:$guard,message:$message}' \ + >> "$_RECEIPT_FILE" 2>/dev/null || true + fi + exit 1 + ;; + Glob|Grep|WebSearch) + # These tools can legitimately operate without an explicit target + exit 0 + ;; + *) + echo "Clawdstrike: unknown tool '${TOOL_NAME}' with empty target, skipping check" >&2 + exit 0 + ;; + esac +fi + +# Build JSON safely, including session_id when available. +build_payload() { + local jq_args=() + jq_args+=(--arg action_type "$ACTION_TYPE") + jq_args+=(--arg target "$TARGET") + + local jq_template_fields='action_type:$action_type,target:$target' + + if [ -n "${CONTENT:-}" ]; then + jq_args+=(--arg content "$CONTENT") + jq_template_fields="${jq_template_fields},content:\$content" + fi + + if [ "$ACTION_TYPE" = "mcp_tool" ]; then + jq_args+=(--argjson args "$TOOL_INPUT") + jq_template_fields="${jq_template_fields},args:\$args" + fi + + if [ -n "${CLAWDSTRIKE_SESSION_ID:-}" ]; then + jq_args+=(--arg session_id "$CLAWDSTRIKE_SESSION_ID") + jq_template_fields="${jq_template_fields},session_id:\$session_id" + fi + + jq -cn "${jq_args[@]}" "{${jq_template_fields}}" +} + +if ! PAYLOAD=$(build_payload 2>/dev/null); then + fail "failed to encode policy request payload" +fi + +if [ ! -f "$CLAWDSTRIKE_TOKEN_FILE" ]; then + fail "agent auth token file not found at $CLAWDSTRIKE_TOKEN_FILE" +fi + +if ! CLAWDSTRIKE_TOKEN=$(cat "$CLAWDSTRIKE_TOKEN_FILE"); then + fail "failed to read agent auth token" +fi + +if [ -z "$CLAWDSTRIKE_TOKEN" ]; then + fail "agent auth token is empty" +fi + +CHECK_URL="${CLAWDSTRIKE_ENDPOINT}/api/v1/agent/policy-check" + +if ! RESPONSE=$(curl -sS --max-time 8 -X POST "$CHECK_URL" \ + -H "Authorization: Bearer ${CLAWDSTRIKE_TOKEN}" \ + -H "Content-Type: application/json" \ + --data "$PAYLOAD" 2>/dev/null); then + fail "policy-check request failed" +fi + +if ! ALLOWED=$(echo "$RESPONSE" | jq -er '.allowed' 2>/dev/null); then + fail "policy-check returned malformed response" +fi + +if [ "$ALLOWED" = "false" ]; then + GUARD=$(echo "$RESPONSE" | jq -er '.guard // "unknown"' 2>/dev/null || echo "unknown") + + # When the agent returns a well-formed deny due to daemon infrastructure errors, + # treat it as a hook failure so CLAWDSTRIKE_HOOK_FAIL_OPEN can apply. + if [[ "$GUARD" == hushd_* ]]; then + fail "policy daemon error (${GUARD})" + fi + + MESSAGE=$(echo "$RESPONSE" | jq -er '.message // "Action blocked by security policy"' 2>/dev/null || echo "Action blocked by security policy") + + # Write denial receipt for session audit trail (Item 13) + if [ -n "${CLAWDSTRIKE_SESSION_ID:-}" ]; then + _RECEIPT_DIR="$HOME/.clawdstrike/receipts" + _RECEIPT_FILE="${_RECEIPT_DIR}/session-${CLAWDSTRIKE_SESSION_ID}.jsonl" + _TS=$(date -u +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null) || _TS="unknown" + mkdir -p "$_RECEIPT_DIR" 2>/dev/null || true + jq -cn \ + --arg timestamp "$_TS" \ + --arg session_id "$CLAWDSTRIKE_SESSION_ID" \ + --arg tool_name "$TOOL_NAME" \ + --arg action_type "$ACTION_TYPE" \ + --arg target "$TARGET" \ + --arg outcome "deny" \ + --arg guard "$GUARD" \ + --arg message "$MESSAGE" \ + '{timestamp:$timestamp,session_id:$session_id,tool_name:$tool_name,action_type:$action_type,target:$target,outcome:$outcome,guard:$guard,message:$message}' \ + >> "$_RECEIPT_FILE" 2>/dev/null || true + fi + + echo "BLOCKED by Clawdstrike (${GUARD}): ${MESSAGE}" >&2 + echo " Target: ${TARGET}" >&2 + exit 1 +fi + +exit 0 diff --git a/clawdstrike-plugin/scripts/session-end.sh b/clawdstrike-plugin/scripts/session-end.sh new file mode 100755 index 000000000..2741190d2 --- /dev/null +++ b/clawdstrike-plugin/scripts/session-end.sh @@ -0,0 +1,42 @@ +#!/bin/bash +# Clawdstrike session end hook (plugin version) +# Finalizes audit trail with session summary +# ALWAYS exits 0 + +set -uo pipefail + +SESSION_ID="${CLAWDSTRIKE_SESSION_ID:-unknown}" +RECEIPT_DIR="$HOME/.clawdstrike/receipts" +RECEIPT_FILE="${RECEIPT_DIR}/session-${SESSION_ID}.jsonl" + +# If no receipt file exists for this session, nothing to summarize +if [ ! -f "$RECEIPT_FILE" ]; then + exit 0 +fi + +# Count total tool call lines (exclude session_start and session_end events) +TOTAL_CALLS=$(grep -c '"tool_name"' "$RECEIPT_FILE" 2>/dev/null) || TOTAL_CALLS=0 + +# Count denied actions (lines with "deny" outcome) +DENIED_CALLS=$(grep -c '"outcome":"deny"' "$RECEIPT_FILE" 2>/dev/null) || DENIED_CALLS=0 + +# Generate ISO8601 timestamp +TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null) || TIMESTAMP="unknown" + +# Write summary line to JSONL +jq -cn \ + --arg timestamp "$TIMESTAMP" \ + --arg session_id "$SESSION_ID" \ + --arg event "session_end" \ + --argjson total_tool_calls "$TOTAL_CALLS" \ + --argjson denied_tool_calls "$DENIED_CALLS" \ + '{timestamp:$timestamp,session_id:$session_id,event:$event,total_tool_calls:$total_tool_calls,denied_tool_calls:$denied_tool_calls}' \ + >> "$RECEIPT_FILE" 2>/dev/null || true + +# Sign the receipt file if a signing key is configured (best-effort, never blocks) +if [ -n "${CLAWDSTRIKE_SIGNING_KEY:-}" ] && [ -f "$CLAWDSTRIKE_SIGNING_KEY" ]; then + CLI="${CLAWDSTRIKE_CLI:-clawdstrike}" + "$CLI" sign --key "$CLAWDSTRIKE_SIGNING_KEY" "$RECEIPT_FILE" -o "${RECEIPT_FILE}.sig" 2>/dev/null || true +fi + +exit 0 diff --git a/clawdstrike-plugin/scripts/session-start.sh b/clawdstrike-plugin/scripts/session-start.sh new file mode 100755 index 000000000..705fe9228 --- /dev/null +++ b/clawdstrike-plugin/scripts/session-start.sh @@ -0,0 +1,92 @@ +#!/bin/bash +# Clawdstrike session start hook (plugin version) +# Initializes audit trail and probes hushd health + +set -uo pipefail + +CLAWDSTRIKE_ENDPOINT="${CLAWDSTRIKE_ENDPOINT:-http://127.0.0.1:9878}" +CLI="${CLAWDSTRIKE_CLI:-clawdstrike}" +RECEIPT_DIR="$HOME/.clawdstrike/receipts" + +# Generate a unique session ID (UUID preferred, fallback to nanosecond timestamp + random bytes) +if command -v uuidgen >/dev/null 2>&1; then + SESSION_ID="$(uuidgen | tr '[:upper:]' '[:lower:]')" +else + SESSION_ID="$(date +%s%N 2>/dev/null || date +%s)-$(head -c 16 /dev/urandom | xxd -p)" +fi + +# Create receipt directory +mkdir -p "$RECEIPT_DIR" 2>/dev/null || true + +# Probe hushd health +HUSHD_STATUS="disconnected" +if HEALTH_RESPONSE=$(curl -s --max-time 2 "${CLAWDSTRIKE_ENDPOINT}/health" 2>/dev/null); then + if echo "$HEALTH_RESPONSE" | jq -e '.status == "ok" or .status == "healthy"' >/dev/null 2>&1; then + HUSHD_STATUS="connected" + fi +fi + +# Verify policy bundle if configured +if [ -n "${CLAWDSTRIKE_POLICY_BUNDLE:-}" ] && [ -f "$CLAWDSTRIKE_POLICY_BUNDLE" ]; then + if BUNDLE_RESULT=$("$CLI" policy bundle verify "$CLAWDSTRIKE_POLICY_BUNDLE" --json 2>/dev/null); then + if ! echo "$BUNDLE_RESULT" | jq -e '.valid == true' >/dev/null 2>&1; then + echo "Clawdstrike: policy bundle verification failed" >&2 + exit 1 + fi + else + echo "Clawdstrike: policy bundle verification command failed" >&2 + exit 1 + fi +fi + +# Get active policy info +# `policy show` returns YAML by default; use `--json` for parseable output. +POLICY_NAME="ai-agent" +GUARD_COUNT="unknown" +if POLICY_INFO=$("$CLI" policy show ai-agent --json 2>/dev/null); then + POLICY_NAME=$(echo "$POLICY_INFO" | jq -er '.name // "ai-agent"' 2>/dev/null) || POLICY_NAME="ai-agent" + GUARD_COUNT=$(echo "$POLICY_INFO" | jq -er '.guards | length' 2>/dev/null) || GUARD_COUNT="unknown" +fi + +# Write initial session receipt line +TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null) || TIMESTAMP="unknown" +RECEIPT_FILE="${RECEIPT_DIR}/session-${SESSION_ID}.jsonl" + +jq -cn \ + --arg timestamp "$TIMESTAMP" \ + --arg session_id "$SESSION_ID" \ + --arg event "session_start" \ + --arg hushd_status "$HUSHD_STATUS" \ + --arg policy "$POLICY_NAME" \ + --arg guard_count "$GUARD_COUNT" \ + '{timestamp:$timestamp,session_id:$session_id,event:$event,hushd_status:$hushd_status,policy:$policy,guard_count:$guard_count}' \ + >> "$RECEIPT_FILE" 2>/dev/null || true + +# Build enforcement status line +if [ "$HUSHD_STATUS" = "connected" ]; then + ENFORCEMENT_LINE="Enforcement: ACTIVE (hushd ${CLAWDSTRIKE_ENDPOINT})" +else + ENFORCEMENT_LINE="Enforcement: DISCONNECTED (hushd unreachable at ${CLAWDSTRIKE_ENDPOINT})" +fi + +# Build the additional context message +CONTEXT="ClawdStrike Security Active +Session: ${SESSION_ID} +Policy: ${POLICY_NAME} (${GUARD_COUNT} guards) +${ENFORCEMENT_LINE} + +Available commands: + /clawdstrike:scan - Scan MCP configs for security issues + /clawdstrike:audit - View session audit trail + /clawdstrike:posture - Assess security posture (A-F grade) + /clawdstrike:policy - Display active policy details + /clawdstrike:tui - Launch interactive TUI dashboard" + +# Output hookSpecificOutput JSON to stdout +# Set CLAWDSTRIKE_SESSION_ID env var so subsequent hooks share the same session +jq -cn \ + --arg context "$CONTEXT" \ + --arg session_id "$SESSION_ID" \ + '{hookSpecificOutput:{additionalContext:$context,env:{CLAWDSTRIKE_SESSION_ID:$session_id}}}' + +exit 0 diff --git a/clawdstrike-plugin/scripts/stop-handler.sh b/clawdstrike-plugin/scripts/stop-handler.sh new file mode 100755 index 000000000..bec44c2ff --- /dev/null +++ b/clawdstrike-plugin/scripts/stop-handler.sh @@ -0,0 +1,50 @@ +#!/bin/bash +# Clawdstrike stop hook handler (plugin version) +# Writes session_stop receipt and summary if session_end not yet written +# ALWAYS exits 0 + +set -uo pipefail + +SESSION_ID="${CLAWDSTRIKE_SESSION_ID:-unknown}" +RECEIPT_DIR="$HOME/.clawdstrike/receipts" +RECEIPT_FILE="${RECEIPT_DIR}/session-${SESSION_ID}.jsonl" + +# Read hook input from stdin (best-effort) +INPUT=$(cat 2>/dev/null) || true + +# Extract reason if provided +REASON=$(echo "$INPUT" | jq -er '.reason // "unknown"' 2>/dev/null) || REASON="unknown" + +# Generate ISO8601 timestamp +TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null) || TIMESTAMP="unknown" + +# Ensure receipt directory exists +mkdir -p "$RECEIPT_DIR" 2>/dev/null || true + +# Write session_stop receipt line +jq -cn \ + --arg timestamp "$TIMESTAMP" \ + --arg session_id "$SESSION_ID" \ + --arg event "session_stop" \ + --arg reason "$REASON" \ + '{timestamp:$timestamp,session_id:$session_id,event:$event,reason:$reason}' \ + >> "$RECEIPT_FILE" 2>/dev/null || true + +# If session_end has not yet been written, write a summary now +if [ -f "$RECEIPT_FILE" ]; then + if ! grep -q '"event":"session_end"' "$RECEIPT_FILE" 2>/dev/null; then + TOTAL_CALLS=$(grep -c '"tool_name"' "$RECEIPT_FILE" 2>/dev/null) || TOTAL_CALLS=0 + DENIED_CALLS=$(grep -c '"outcome":"deny"' "$RECEIPT_FILE" 2>/dev/null) || DENIED_CALLS=0 + + jq -cn \ + --arg timestamp "$TIMESTAMP" \ + --arg session_id "$SESSION_ID" \ + --arg event "session_end" \ + --argjson total_tool_calls "$TOTAL_CALLS" \ + --argjson denied_tool_calls "$DENIED_CALLS" \ + '{timestamp:$timestamp,session_id:$session_id,event:$event,total_tool_calls:$total_tool_calls,denied_tool_calls:$denied_tool_calls}' \ + >> "$RECEIPT_FILE" 2>/dev/null || true + fi +fi + +exit 0 diff --git a/clawdstrike-plugin/scripts/user-prompt-check.sh b/clawdstrike-plugin/scripts/user-prompt-check.sh new file mode 100755 index 000000000..3e6accc6e --- /dev/null +++ b/clawdstrike-plugin/scripts/user-prompt-check.sh @@ -0,0 +1,124 @@ +#!/bin/bash +# Clawdstrike user prompt submission hook (plugin version) +# Checks user prompts for prompt injection before processing +# Exit 0 = allow, Exit 2 = deny (block prompt) + +set -euo pipefail + +# Configuration +CLAWDSTRIKE_ENDPOINT="${CLAWDSTRIKE_ENDPOINT:-http://127.0.0.1:9878}" +CLAWDSTRIKE_TOKEN_FILE="${CLAWDSTRIKE_TOKEN_FILE:-$HOME/.config/clawdstrike/agent-local-token}" +CLAWDSTRIKE_HOOK_FAIL_OPEN="${CLAWDSTRIKE_HOOK_FAIL_OPEN:-0}" +SESSION_ID="${CLAWDSTRIKE_SESSION_ID:-unknown}" +RECEIPT_DIR="$HOME/.clawdstrike/receipts" +RECEIPT_FILE="${RECEIPT_DIR}/session-${SESSION_ID}.jsonl" + +fail() { + local reason="$1" + echo "Clawdstrike prompt hook error: ${reason}" >&2 + case "$CLAWDSTRIKE_HOOK_FAIL_OPEN" in + 1|true|True|TRUE|yes|Yes|YES) + echo "CLAWDSTRIKE_HOOK_FAIL_OPEN is set; allowing prompt despite hook failure." >&2 + exit 0 + ;; + esac + exit 2 +} + +# Read hook input from stdin +if ! INPUT=$(cat); then + fail "failed to read hook input" +fi + +# Extract the prompt text +if ! PROMPT=$(echo "$INPUT" | jq -er '.prompt // empty' 2>/dev/null); then + fail "invalid hook payload: missing/invalid .prompt" +fi + +if [ -z "$PROMPT" ]; then + exit 0 +fi + +# Truncate target to first 200 characters for the policy check target field +TARGET=$(printf '%.200s' "$PROMPT") + +# Read auth token +if [ ! -f "$CLAWDSTRIKE_TOKEN_FILE" ]; then + fail "agent auth token file not found at $CLAWDSTRIKE_TOKEN_FILE" +fi + +if ! CLAWDSTRIKE_TOKEN=$(cat "$CLAWDSTRIKE_TOKEN_FILE"); then + fail "failed to read agent auth token" +fi + +if [ -z "$CLAWDSTRIKE_TOKEN" ]; then + fail "agent auth token is empty" +fi + +# Build policy-check payload +PAYLOAD_ARGS=( + --arg action_type "prompt_injection" + --arg target "$TARGET" + --arg content "$PROMPT" +) +PAYLOAD_TEMPLATE='{action_type:$action_type,target:$target,content:$content' + +if [ -n "${SESSION_ID:-}" ] && [ "$SESSION_ID" != "unknown" ]; then + PAYLOAD_ARGS+=(--arg session_id "$SESSION_ID") + PAYLOAD_TEMPLATE="${PAYLOAD_TEMPLATE},session_id:\$session_id" +fi + +PAYLOAD_TEMPLATE="${PAYLOAD_TEMPLATE}}" + +if ! PAYLOAD=$(jq -cn "${PAYLOAD_ARGS[@]}" "$PAYLOAD_TEMPLATE" 2>/dev/null); then + fail "failed to encode policy request payload" +fi + +CHECK_URL="${CLAWDSTRIKE_ENDPOINT}/api/v1/agent/policy-check" + +if ! RESPONSE=$(curl -sS --max-time 8 -X POST "$CHECK_URL" \ + -H "Authorization: Bearer ${CLAWDSTRIKE_TOKEN}" \ + -H "Content-Type: application/json" \ + --data "$PAYLOAD" 2>/dev/null); then + fail "policy-check request failed" +fi + +if ! ALLOWED=$(echo "$RESPONSE" | jq -er '.allowed' 2>/dev/null); then + fail "policy-check returned malformed response" +fi + +# Write receipt line +TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null) || TIMESTAMP="unknown" +mkdir -p "$RECEIPT_DIR" 2>/dev/null || true + +if [ "$ALLOWED" = "false" ]; then + GUARD=$(echo "$RESPONSE" | jq -er '.guard // "unknown"' 2>/dev/null || echo "unknown") + MESSAGE=$(echo "$RESPONSE" | jq -er '.message // "Prompt blocked by security policy"' 2>/dev/null || echo "Prompt blocked by security policy") + + jq -cn \ + --arg timestamp "$TIMESTAMP" \ + --arg session_id "$SESSION_ID" \ + --arg tool_name "UserPromptSubmit" \ + --arg action_type "prompt_injection" \ + --arg target "$TARGET" \ + --arg outcome "deny" \ + --arg guard "$GUARD" \ + --arg message "$MESSAGE" \ + '{timestamp:$timestamp,session_id:$session_id,tool_name:$tool_name,action_type:$action_type,target:$target,outcome:$outcome,guard:$guard,message:$message}' \ + >> "$RECEIPT_FILE" 2>/dev/null || true + + echo "BLOCKED by Clawdstrike (${GUARD}): ${MESSAGE}" >&2 + exit 2 +fi + +jq -cn \ + --arg timestamp "$TIMESTAMP" \ + --arg session_id "$SESSION_ID" \ + --arg tool_name "UserPromptSubmit" \ + --arg action_type "prompt_injection" \ + --arg target "$TARGET" \ + --arg outcome "allow" \ + '{timestamp:$timestamp,session_id:$session_id,tool_name:$tool_name,action_type:$action_type,target:$target,outcome:$outcome}' \ + >> "$RECEIPT_FILE" 2>/dev/null || true + +exit 0 diff --git a/clawdstrike-plugin/skills/policy-guide/SKILL.md b/clawdstrike-plugin/skills/policy-guide/SKILL.md new file mode 100644 index 000000000..4799110ed --- /dev/null +++ b/clawdstrike-plugin/skills/policy-guide/SKILL.md @@ -0,0 +1,103 @@ +--- +description: "Guide to ClawdStrike security policies and guard configuration" +--- + +# Policy Guide + +<trigger> +This skill activates when the user or conversation involves: +- Questions about what actions are allowed or blocked +- Policy configuration, guard behavior, or security rules +- Choosing or comparing security rulesets +- Understanding why an action was denied +- Customizing guard settings or thresholds +- Denial errors such as "action denied", "blocked by guard", or "policy violation" +- Questions like "why was X blocked", "why can't I access Y", or "how do I allow Z" +</trigger> + +## The 12 Built-in Guards + +| Guard | Action Type | Purpose | Default Status | +|-------|-------------|---------|----------------| +| **ForbiddenPathGuard** | file | Blocks access to sensitive filesystem paths (e.g., /etc/shadow, ~/.ssh/id_rsa) | permissive: ON, default: ON, strict: ON | +| **PathAllowlistGuard** | file | Only allows file access to explicitly permitted paths | permissive: OFF, default: OFF, strict: ON | +| **EgressAllowlistGuard** | egress | Controls outbound network access by domain allowlist | permissive: OFF, default: ON, strict: ON | +| **SecretLeakGuard** | file | Detects secrets, API keys, and credentials in file writes | permissive: ON, default: ON, strict: ON | +| **PatchIntegrityGuard** | file | Validates that patches/diffs don't introduce unsafe changes | permissive: OFF, default: ON, strict: ON | +| **ShellCommandGuard** | shell | Blocks dangerous shell commands (rm -rf, sudo, etc.) | permissive: OFF, default: ON, strict: ON | +| **McpToolGuard** | mcp_tool | Restricts which MCP tools can be invoked | permissive: OFF, default: OFF, strict: ON | +| **PromptInjectionGuard** | prompt | Detects prompt injection attempts in inputs | permissive: OFF, default: ON, strict: ON | +| **JailbreakGuard** | prompt | 4-layer jailbreak detection (heuristic + statistical + ML + LLM-judge) | permissive: OFF, default: OFF, strict: ON | +| **ComputerUseGuard** | computer_use | Controls Computer Use Agent actions for remote desktop | permissive: OFF, default: OFF, strict: ON | +| **RemoteDesktopSideChannelGuard** | remote_desktop | Side-channel controls (clipboard, audio, drive mapping, file transfer) | permissive: OFF, default: OFF, strict: ON | +| **InputInjectionCapabilityGuard** | computer_use | Restricts input injection capabilities in CUA environments | permissive: OFF, default: OFF, strict: ON | + +## Available Rulesets + +Use `clawdstrike_policy_show` to inspect any ruleset. + +| Ruleset | Use Case | +|---------|----------| +| `permissive` | Development/testing -- minimal restrictions | +| `default` | General purpose -- balanced security | +| `strict` | High-security environments -- maximum restrictions | +| `ai-agent` | AI coding agents -- tuned for agent workflows | +| `cicd` | CI/CD pipelines -- restricted to build/deploy operations | +| `ai-agent-posture` | Agent posture assessment -- monitoring without blocking | +| `remote-desktop` | Remote desktop sessions -- balanced CUA controls | +| `remote-desktop-permissive` | Permissive CUA -- fewer restrictions for trusted environments | +| `remote-desktop-strict` | Strict CUA -- maximum restrictions for untrusted environments | + +## How to Check Policies + +### Show active policy +Call `clawdstrike_policy_show` with no arguments to see the currently loaded policy, or pass a ruleset name to inspect a specific one. + +### Evaluate a hypothetical action +Call `clawdstrike_policy_eval` with an action_type and target to see which guards would fire and what the verdict would be, without actually executing the action. + +## Policy Inheritance + +Policies support inheritance via the `extends` field: +- Built-in rulesets can be referenced by name (e.g., `extends: strict`) +- Local files can be referenced by path +- Remote URLs and git refs are supported +- Child policies override parent settings; guards merge by name + +## Design Philosophy: Fail-Closed + +ClawdStrike follows a fail-closed design: +- **Invalid policies** are rejected at load time (not silently ignored) +- **Errors during guard evaluation** result in deny (not allow) +- **Unknown action types** are denied by default +- **Missing configuration** causes startup failure, not permissive fallback + +This means if something goes wrong, the system errs on the side of security rather than availability. + +## What To Do When Too Strict + +If the active policy is blocking legitimate actions, follow these steps to relax it safely: + +1. **Identify the blocking guard**: Call `clawdstrike_policy_eval` with the denied action to see exactly which guard is blocking it. +2. **Check if the action is expected**: Verify the action is genuinely needed and not a misconfigured command or wrong path. +3. **Try a less restrictive ruleset**: If on `strict`, try `default` or `ai-agent`. Use `clawdstrike_policy_show` to compare what changes. +4. **Create a custom override**: Extend the current ruleset and override only the specific guard: + ```yaml + schema_version: "1.2.0" + extends: strict + guards: + ForbiddenPathGuard: + additional_allowed_paths: + - "/path/that/was/blocked" + ``` +5. **Add path-specific exceptions**: For file guards, add paths to allowlists rather than disabling the guard entirely. +6. **Disable a single guard as last resort**: Set `enabled: false` for a specific guard only if the above options do not work. Never disable SecretLeakGuard in production. +7. **Re-verify**: After changes, run `clawdstrike_policy_eval` again to confirm the action is now allowed without opening unintended gaps. + +## Response Guidelines + +When this skill is active: +- Use `clawdstrike_policy_show` and `clawdstrike_policy_eval` to give concrete answers +- Explain guard behavior in terms of what the user is trying to do +- Recommend the most appropriate ruleset for the user's use case +- When an action is denied, explain which guard blocked it and why diff --git a/clawdstrike-plugin/skills/security-review/SKILL.md b/clawdstrike-plugin/skills/security-review/SKILL.md new file mode 100644 index 000000000..9a471044b --- /dev/null +++ b/clawdstrike-plugin/skills/security-review/SKILL.md @@ -0,0 +1,68 @@ +--- +description: "Security review for risky code changes" +--- + +# Security Review + +<trigger> +This skill activates when the user or conversation involves: +- Multi-file edits touching security-sensitive paths (auth, config, credentials, .env, keys) +- Shell commands that modify system state, install packages, or change permissions +- New dependency additions or version changes +- Changes to CI/CD, Docker, or infrastructure configuration +- File writes to sensitive directories (/etc, ~/.ssh, ~/.aws, ~/.config) +- File reads of sensitive paths (private keys, credential stores, token caches, certificate files) +- Any content containing these keywords: "secret", "credential", "API key", "token", "password", "private key", "certificate", ".env", "auth", "permission" +</trigger> + +## Security Checklist + +Before proceeding with risky actions, use the `clawdstrike_check` MCP tool to verify policy compliance: + +1. **Pre-flight check**: Call `clawdstrike_check` with the action_type and target before executing +2. **Secret scanning**: Verify no secrets, API keys, or credentials are being written to files +3. **Dependency audit**: For new dependencies, check for known vulnerabilities +4. **Permission scope**: Ensure file operations stay within allowed paths +5. **Shell safety**: Validate shell commands against the ShellCommandGuard policy + +## Action Type Mapping + +Use these action_type values when calling `clawdstrike_check`: + +| Scenario | action_type | target | +|----------|-------------|--------| +| Writing/reading files | `file` | Absolute file path | +| Running shell commands | `shell` | The command string | +| HTTP/network requests | `egress` | Domain or URL | +| Installing packages | `shell` | Install command | +| MCP tool invocation | `mcp_tool` | Tool name | + +## Response Guidelines + +When this skill is active: +- Proactively call `clawdstrike_check` before file writes to sensitive paths +- Flag potential security issues with severity levels (Critical/High/Medium/Low) +- Suggest safer alternatives when an action would be blocked by policy +- Reference specific guards that would evaluate the action + +## Recommended Tools + +Use these MCP tools in order of priority when this skill activates: + +| Tool | When to Use | +|------|-------------| +| `clawdstrike_check` | Before any file write, shell command, or egress -- the primary enforcement tool | +| `clawdstrike_policy_eval` | To test hypothetical actions without executing them -- use for planning | +| `clawdstrike_policy_show` | To understand which guards are active and what the current restrictions are | +| `clawdstrike_scan` | To audit all MCP server configs for misconfigurations before a review | +| `clawdstrike_policy_lint` | To validate policy YAML files for syntax/schema errors | + +## Guard Reference + +These guards are evaluated during checks: +- **ForbiddenPathGuard** - Blocks access to sensitive filesystem paths +- **PathAllowlistGuard** - Enforces allowlist-based path access +- **SecretLeakGuard** - Detects secrets/credentials in file content +- **ShellCommandGuard** - Blocks dangerous shell commands +- **EgressAllowlistGuard** - Controls outbound network access +- **PatchIntegrityGuard** - Validates patch/diff safety diff --git a/clawdstrike-plugin/skills/threat-hunt/SKILL.md b/clawdstrike-plugin/skills/threat-hunt/SKILL.md new file mode 100644 index 000000000..d9c886f9d --- /dev/null +++ b/clawdstrike-plugin/skills/threat-hunt/SKILL.md @@ -0,0 +1,105 @@ +--- +description: "Threat hunting and security event investigation" +--- + +# Threat Hunt + +<trigger> +This skill activates when the user or conversation involves: +- Investigating security events or suspicious activity +- Breach investigation or incident response +- Threat hunting across audit logs or event streams +- Indicators of Compromise (IOCs) such as suspicious IPs, domains, hashes, or file paths +- Correlating security events across multiple sources +- MITRE ATT&CK technique references +- "BLOCKED by Clawdstrike" or similar denial messages appearing in conversation output +- Repeated policy denials or unexpected security enforcement behavior +</trigger> + +## Investigation Workflow + +Follow this structured approach when investigating security events: + +### 1. Establish Timeline + +Call `clawdstrike_timeline` to get a chronological view of recent events: +- Start with a broad time range, then narrow down +- Look for clusters of activity that indicate automated or coordinated actions +- Note any gaps that might indicate log tampering + +### 2. Query and Filter + +Use `clawdstrike_query` to drill into specific criteria: +- Filter by verdict (allow/deny/audit) to find blocked actions +- Filter by action_type (file/shell/egress/mcp_tool) to focus investigation +- Filter by guard name to see which security controls were triggered +- Search for specific paths, commands, or domains + +### 3. Correlate Events + +Run `clawdstrike_correlate` to detect patterns across events: +- Use built-in correlation rules to identify attack sequences +- Look for lateral movement patterns (multiple targets from one source) +- Detect privilege escalation attempts (sequence of increasingly sensitive operations) +- Identify data exfiltration patterns (sensitive file reads followed by egress) + +### 4. Check IOCs + +Use `clawdstrike_ioc` to check indicators against threat intelligence: +- Submit suspicious domains, IPs, file hashes, or paths +- Cross-reference with known threat actor TTPs +- Check if IOCs appear in multiple events (indicating persistence) + +### 5. Generate Report + +Call `clawdstrike_report` to produce a structured investigation summary: +- Include timeline of events, findings, and recommended actions +- Reference specific events by ID for traceability +- Map findings to MITRE ATT&CK techniques where applicable + +## MITRE ATT&CK Quick Reference + +Common techniques to look for in agent security events: + +| Technique | ID | Indicators | +|-----------|------|------------| +| Command and Scripting Interpreter | T1059 | Shell commands with encoded payloads, eval/exec usage | +| File and Directory Discovery | T1083 | Enumeration of sensitive directories | +| Exfiltration Over Web Service | T1567 | Egress to uncommon domains after file reads | +| Credential Access | T1552 | Access to .env, .ssh, credential files | +| Defense Evasion | T1562 | Attempts to modify security config or disable guards | +| Persistence | T1546 | Modifications to shell profiles, cron, startup files | +| Privilege Escalation | T1548 | sudo/chmod/chown commands, setuid changes | + +## MITRE Technique to MCP Tool Mapping + +Use this table to select the right investigation tool for each technique: + +| MITRE Technique | ID | Primary MCP Tool | Investigation Approach | +|-----------------|----|------------------|----------------------| +| Command and Scripting Interpreter | T1059 | `clawdstrike_query` | Filter by `action_type=shell`, look for encoded payloads or eval/exec | +| File and Directory Discovery | T1083 | `clawdstrike_timeline` | Broad timeline scan for sequential file reads across sensitive dirs | +| Exfiltration Over Web Service | T1567 | `clawdstrike_correlate` | Correlate file reads followed by egress to uncommon domains | +| Credential Access | T1552 | `clawdstrike_query` | Filter by `action_type=file` targeting .env, .ssh, credential paths | +| Defense Evasion | T1562 | `clawdstrike_query` | Filter for policy modification attempts or guard config changes | +| Persistence | T1546 | `clawdstrike_ioc` | Check shell profile, cron, and startup file modifications | +| Privilege Escalation | T1548 | `clawdstrike_query` | Filter by `action_type=shell` for sudo, chmod, chown, setuid | + +## Incident Classification + +Classify incidents using these severity levels: + +| Classification | Criteria | Response | +|---------------|----------|----------| +| **P1 - Critical** | Active exploitation, data exfiltration confirmed, credential compromise | Immediate remediation, revoke credentials, isolate affected sessions | +| **P2 - High** | Blocked exploit attempt, repeated policy violations, suspicious lateral movement | Investigate within current session, tighten policy, monitor for recurrence | +| **P3 - Medium** | Single denied action matching known TTP, anomalous but unconfirmed activity | Log for review, verify policy coverage, check for related events | +| **P4 - Low** | Informational anomaly, policy audit events, benign tool misuse | Document in report, no immediate action required | + +## Response Guidelines + +When this skill is active: +- Present findings in order of severity and confidence +- Always provide specific event IDs and timestamps +- Recommend concrete remediation steps for each finding +- Distinguish between confirmed threats and suspicious activity requiring further investigation diff --git a/clawdstrike-plugin/test/hooks.test.ts b/clawdstrike-plugin/test/hooks.test.ts new file mode 100644 index 000000000..c9375b21e --- /dev/null +++ b/clawdstrike-plugin/test/hooks.test.ts @@ -0,0 +1,354 @@ +/** + * Hook script integration tests. + * + * Feeds mock JSON payloads via stdin to the shell scripts and verifies + * exit codes and (where applicable) JSONL receipt output. + */ + +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { mkdtemp, rm, readFile, mkdir, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const SCRIPTS_DIR = join(import.meta.dir, "..", "scripts"); +const PRE_TOOL = join(SCRIPTS_DIR, "pre-tool-check.sh"); +const POST_TOOL = join(SCRIPTS_DIR, "post-tool-receipt.sh"); +const SESSION_START = join(SCRIPTS_DIR, "session-start.sh"); +const SESSION_END = join(SCRIPTS_DIR, "session-end.sh"); + +/** Run a hook script with the given stdin payload and environment overrides. */ +async function runHook( + script: string, + stdinPayload: string, + env: Record<string, string> = {}, +): Promise<{ exitCode: number; stdout: string; stderr: string }> { + const proc = Bun.spawn(["bash", script], { + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + env: { + ...process.env, + ...env, + // Disable fail-open by default so denials are real + CLAWDSTRIKE_HOOK_FAIL_OPEN: env.CLAWDSTRIKE_HOOK_FAIL_OPEN ?? "0", + }, + }); + + proc.stdin.write(stdinPayload); + proc.stdin.end(); + + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + + return { exitCode, stdout, stderr }; +} + +describe("pre-tool-check.sh", () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "clawdstrike-test-")); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + it("exits 1 when token file is missing and fail-open is off", async () => { + const payload = JSON.stringify({ + tool_name: "Bash", + tool_input: { command: "echo hello" }, + }); + + const result = await runHook(PRE_TOOL, payload, { + CLAWDSTRIKE_TOKEN_FILE: join(tempDir, "nonexistent-token"), + CLAWDSTRIKE_ENDPOINT: "http://127.0.0.1:1", + CLAWDSTRIKE_HOOK_FAIL_OPEN: "0", + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("token"); + }); + + it("exits 0 when token file is missing but fail-open is on", async () => { + const payload = JSON.stringify({ + tool_name: "Bash", + tool_input: { command: "echo hello" }, + }); + + const result = await runHook(PRE_TOOL, payload, { + CLAWDSTRIKE_TOKEN_FILE: join(tempDir, "nonexistent-token"), + CLAWDSTRIKE_ENDPOINT: "http://127.0.0.1:1", + CLAWDSTRIKE_HOOK_FAIL_OPEN: "1", + }); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain("CLAWDSTRIKE_HOOK_FAIL_OPEN is set"); + }); + + it("exits 1 when payload is missing tool_name and fail-open is off", async () => { + const payload = JSON.stringify({ tool_input: {} }); + + const result = await runHook(PRE_TOOL, payload, { + CLAWDSTRIKE_TOKEN_FILE: join(tempDir, "nonexistent-token"), + CLAWDSTRIKE_ENDPOINT: "http://127.0.0.1:1", + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("tool_name"); + }); + + it("exits 1 for Read tool with no target (fail-closed)", async () => { + const payload = JSON.stringify({ + tool_name: "Read", + tool_input: {}, + }); + + const result = await runHook(PRE_TOOL, payload, { + CLAWDSTRIKE_TOKEN_FILE: join(tempDir, "nonexistent-token"), + CLAWDSTRIKE_ENDPOINT: "http://127.0.0.1:1", + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("BLOCKED"); + expect(result.stderr).toContain("target"); + }); + + it("writes denial receipt when session_id is set and target is empty", async () => { + const receiptDir = join(tempDir, ".clawdstrike", "receipts"); + const sessionId = "test-session-001"; + + const payload = JSON.stringify({ + tool_name: "Write", + tool_input: {}, + }); + + const result = await runHook(PRE_TOOL, payload, { + HOME: tempDir, + CLAWDSTRIKE_SESSION_ID: sessionId, + CLAWDSTRIKE_TOKEN_FILE: join(tempDir, "nonexistent-token"), + CLAWDSTRIKE_ENDPOINT: "http://127.0.0.1:1", + }); + + expect(result.exitCode).toBe(1); + + const receiptFile = join(receiptDir, `session-${sessionId}.jsonl`); + const content = await readFile(receiptFile, "utf-8"); + const receipt = JSON.parse(content.trim()); + expect(receipt.outcome).toBe("deny"); + expect(receipt.guard).toBe("empty_target"); + expect(receipt.tool_name).toBe("Write"); + }); + + it("exits 0 for Glob with no explicit target (allowed)", async () => { + const payload = JSON.stringify({ + tool_name: "Glob", + tool_input: {}, + }); + + const result = await runHook(PRE_TOOL, payload, { + CLAWDSTRIKE_TOKEN_FILE: join(tempDir, "nonexistent-token"), + CLAWDSTRIKE_ENDPOINT: "http://127.0.0.1:1", + }); + + expect(result.exitCode).toBe(0); + }); +}); + +describe("post-tool-receipt.sh", () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "clawdstrike-test-")); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + it("always exits 0 even with missing input", async () => { + const result = await runHook(POST_TOOL, "", { + HOME: tempDir, + CLAWDSTRIKE_SESSION_ID: "test-post", + }); + + expect(result.exitCode).toBe(0); + }); + + it("writes a JSONL receipt for a successful tool call", async () => { + const sessionId = "test-receipt-001"; + const payload = JSON.stringify({ + tool_name: "Read", + tool_input: { file_path: "/etc/hosts" }, + }); + + const result = await runHook(POST_TOOL, payload, { + HOME: tempDir, + CLAWDSTRIKE_SESSION_ID: sessionId, + }); + + expect(result.exitCode).toBe(0); + + const receiptFile = join(tempDir, ".clawdstrike", "receipts", `session-${sessionId}.jsonl`); + const content = await readFile(receiptFile, "utf-8"); + const receipt = JSON.parse(content.trim()); + expect(receipt.tool_name).toBe("Read"); + expect(receipt.action_type).toBe("file_access"); + expect(receipt.target).toBe("/etc/hosts"); + expect(receipt.outcome).toBe("success"); + expect(receipt.session_id).toBe(sessionId); + }); + + it("records error outcome when tool reports isError", async () => { + const sessionId = "test-error-001"; + const payload = JSON.stringify({ + tool_name: "Bash", + tool_input: { command: "exit 1" }, + isError: true, + }); + + const result = await runHook(POST_TOOL, payload, { + HOME: tempDir, + CLAWDSTRIKE_SESSION_ID: sessionId, + }); + + expect(result.exitCode).toBe(0); + + const receiptFile = join(tempDir, ".clawdstrike", "receipts", `session-${sessionId}.jsonl`); + const content = await readFile(receiptFile, "utf-8"); + const receipt = JSON.parse(content.trim()); + expect(receipt.outcome).toBe("error"); + expect(receipt.action_type).toBe("shell"); + }); + + it("includes duration_ms when present", async () => { + const sessionId = "test-duration-001"; + const payload = JSON.stringify({ + tool_name: "WebFetch", + tool_input: { url: "https://example.com" }, + response_duration_ms: 42, + }); + + const result = await runHook(POST_TOOL, payload, { + HOME: tempDir, + CLAWDSTRIKE_SESSION_ID: sessionId, + }); + + expect(result.exitCode).toBe(0); + + const receiptFile = join(tempDir, ".clawdstrike", "receipts", `session-${sessionId}.jsonl`); + const content = await readFile(receiptFile, "utf-8"); + const receipt = JSON.parse(content.trim()); + expect(receipt.duration_ms).toBe(42); + }); +}); + +describe("session-start.sh", () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "clawdstrike-test-")); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + it("outputs hookSpecificOutput JSON on success", async () => { + const result = await runHook(SESSION_START, "", { + HOME: tempDir, + CLAWDSTRIKE_ENDPOINT: "http://127.0.0.1:1", + CLAWDSTRIKE_CLI: "false", + }); + + // session-start should exit 0 even if hushd is unreachable + expect(result.exitCode).toBe(0); + + // stdout should contain hookSpecificOutput JSON + const output = JSON.parse(result.stdout.trim()); + expect(output.hookSpecificOutput).toBeDefined(); + expect(output.hookSpecificOutput.additionalContext).toContain("ClawdStrike"); + }); + + it("creates a receipt file with session_start event", async () => { + const result = await runHook(SESSION_START, "", { + HOME: tempDir, + CLAWDSTRIKE_ENDPOINT: "http://127.0.0.1:1", + CLAWDSTRIKE_CLI: "false", + }); + + expect(result.exitCode).toBe(0); + + // Find the receipt file (session ID is generated dynamically) + const { readdir } = await import("node:fs/promises"); + const receiptsDir = join(tempDir, ".clawdstrike", "receipts"); + const files = await readdir(receiptsDir); + const sessionFiles = files.filter((f) => f.startsWith("session-") && f.endsWith(".jsonl")); + expect(sessionFiles.length).toBeGreaterThanOrEqual(1); + + const content = await readFile(join(receiptsDir, sessionFiles[0]), "utf-8"); + const receipt = JSON.parse(content.trim()); + expect(receipt.event).toBe("session_start"); + expect(receipt.hushd_status).toBe("disconnected"); + }); +}); + +describe("session-end.sh", () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "clawdstrike-test-")); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + it("exits 0 when no receipt file exists", async () => { + const result = await runHook(SESSION_END, "", { + HOME: tempDir, + CLAWDSTRIKE_SESSION_ID: "nonexistent-session", + }); + + expect(result.exitCode).toBe(0); + }); + + it("writes session_end summary to existing receipt file", async () => { + const sessionId = "test-end-001"; + const receiptDir = join(tempDir, ".clawdstrike", "receipts"); + await mkdir(receiptDir, { recursive: true }); + const receiptFile = join(receiptDir, `session-${sessionId}.jsonl`); + + // Seed with a tool call line + await writeFile( + receiptFile, + JSON.stringify({ + timestamp: "2026-01-01T00:00:00Z", + session_id: sessionId, + tool_name: "Read", + action_type: "file_access", + target: "/etc/hosts", + outcome: "success", + }) + "\n", + ); + + const result = await runHook(SESSION_END, "", { + HOME: tempDir, + CLAWDSTRIKE_SESSION_ID: sessionId, + }); + + expect(result.exitCode).toBe(0); + + const content = await readFile(receiptFile, "utf-8"); + const lines = content.trim().split("\n"); + expect(lines.length).toBe(2); + + const summary = JSON.parse(lines[1]); + expect(summary.event).toBe("session_end"); + expect(summary.total_tool_calls).toBe(1); + expect(summary.denied_tool_calls).toBe(0); + }); +}); diff --git a/clawdstrike-plugin/test/mcp-tools.test.ts b/clawdstrike-plugin/test/mcp-tools.test.ts new file mode 100644 index 000000000..3e1f46e77 --- /dev/null +++ b/clawdstrike-plugin/test/mcp-tools.test.ts @@ -0,0 +1,187 @@ +/** + * MCP tools unit tests. + * + * Tests the CLI bridge functions (runCli, runCliRaw, runCliStdin) directly, + * plus input validation logic used by MCP tool handlers. + */ + +import { describe, it, expect } from "bun:test"; +import { + runCli, + runCliRaw, + runCliStdin, + toToolResult, + healthCheck, + CLI, +} from "../scripts/cli-bridge.ts"; +import type { CliResult } from "../scripts/cli-bridge.ts"; + +describe("runCli", () => { + it("returns parsed JSON from a successful command", async () => { + // Use `echo` which always exists — it writes a JSON string to stdout + const result = await runCli<{ hello: string }>( + [], + 5_000, + ); + // Since CLI defaults to "clawdstrike" which likely doesn't exist in test env, + // we expect an error about the binary not being found + if (!result.ok) { + expect(result.error).toBeDefined(); + } + // Either way, the result should have the right shape + expect(result).toHaveProperty("ok"); + }); + + it("returns error for non-zero exit code", async () => { + // Override CLI isn't practical here, so we test the shape + const result = await runCli(["nonexistent-subcommand"], 5_000); + // Will fail because clawdstrike binary likely doesn't exist + expect(result.ok).toBe(false); + expect(result.error).toBeDefined(); + }); + + it("returns error on timeout", async () => { + // Use a very short timeout with a command that would take longer + const result = await runCli(["--help"], 1); + // Should either succeed fast or timeout — both are valid + expect(result).toHaveProperty("ok"); + }); +}); + +describe("runCliRaw", () => { + it("returns raw text output without JSON parsing", async () => { + const result = await runCliRaw(["--version"], 5_000); + // If clawdstrike is installed, result.ok is true and data is a string + // If not installed, result.ok is false with an error + expect(result).toHaveProperty("ok"); + if (result.ok) { + expect(typeof result.data).toBe("string"); + } + }); + + it("does not append --json flag", async () => { + // runCliRaw should pass args as-is + const result = await runCliRaw(["--help"], 5_000); + expect(result).toHaveProperty("ok"); + }); +}); + +describe("runCliStdin", () => { + it("pipes data to stdin", async () => { + const result = await runCliStdin( + ["policy", "eval", "strict", "-"], + JSON.stringify({ action_type: "shell", target: "rm -rf /" }), + 5_000, + ); + // Binary may not exist — just verify the result shape + expect(result).toHaveProperty("ok"); + if (!result.ok) { + expect(result.error).toBeDefined(); + } + }); +}); + +describe("toToolResult", () => { + it("formats successful JSON result", () => { + const input: CliResult<unknown> = { + ok: true, + data: { verdict: "allow", guard: "test" }, + }; + const result = toToolResult(input); + expect(result.content).toHaveLength(1); + expect(result.content[0].type).toBe("text"); + expect(JSON.parse(result.content[0].text)).toEqual({ + verdict: "allow", + guard: "test", + }); + expect(result).not.toHaveProperty("isError"); + }); + + it("formats successful string result (raw mode)", () => { + const input: CliResult<unknown> = { + ok: true, + data: "schema_version: 1.2.0\nname: strict", + }; + const result = toToolResult(input); + expect(result.content[0].text).toBe("schema_version: 1.2.0\nname: strict"); + }); + + it("formats empty successful result", () => { + const input: CliResult<unknown> = { ok: true }; + const result = toToolResult(input); + expect(result.content[0].text).toBe("OK (no output)"); + }); + + it("formats error result", () => { + const input: CliResult<unknown> = { + ok: false, + error: "Permission denied", + }; + const result = toToolResult(input); + expect(result.content[0].text).toBe("Permission denied"); + expect(result.isError).toBe(true); + }); + + it("formats error with no message", () => { + const input: CliResult<unknown> = { ok: false }; + const result = toToolResult(input); + expect(result.content[0].text).toBe("Unknown error"); + expect(result.isError).toBe(true); + }); +}); + +describe("healthCheck", () => { + it("throws when CLI binary is not found", async () => { + // healthCheck uses the module-level CLI constant + // If clawdstrike is not installed, it should throw + try { + await healthCheck(); + // If it succeeds, the binary is installed — that's fine + } catch (err) { + expect(err).toBeInstanceOf(Error); + expect((err as Error).message).toContain("clawdstrike CLI not found"); + } + }); +}); + +describe("input validation", () => { + it("detects invalid JSON in event_json", () => { + const invalidJson = "not json at all {"; + let isValid = true; + try { + JSON.parse(invalidJson); + } catch { + isValid = false; + } + expect(isValid).toBe(false); + }); + + it("accepts valid JSON in event_json", () => { + const validJson = '{"action_type":"shell","target":"ls"}'; + let isValid = true; + try { + JSON.parse(validJson); + } catch { + isValid = false; + } + expect(isValid).toBe(true); + }); + + it("Math.floor normalizes limit values", () => { + expect(Math.floor(10.7)).toBe(10); + expect(Math.floor(0.9)).toBe(0); + expect(Math.floor(-1.5)).toBe(-2); + expect(Math.floor(100)).toBe(100); + }); +}); + +describe("CLI configuration", () => { + it("uses CLAWDSTRIKE_CLI env var when set", () => { + // The CLI constant is read at module load time from process.env + // We verify it defaults to "clawdstrike" in test env + expect(typeof CLI).toBe("string"); + if (!process.env.CLAWDSTRIKE_CLI) { + expect(CLI).toBe("clawdstrike"); + } + }); +}); diff --git a/clawdstrike-plugin/tsconfig.json b/clawdstrike-plugin/tsconfig.json new file mode 100644 index 000000000..972e6dc83 --- /dev/null +++ b/clawdstrike-plugin/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "lib": ["ESNext"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "allowJs": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "types": ["bun-types"] + }, + "include": ["scripts/**/*.ts", "test/**/*.ts"], + "exclude": ["node_modules"] +}