diff --git a/web/public/app.js b/web/public/app.js index 1bc5311..0771dba 100644 --- a/web/public/app.js +++ b/web/public/app.js @@ -250,9 +250,43 @@ function renderCustomLeads() { renderCustomLeads(); })); } -$('#btnCustomLead').addEventListener('click', () => { - const text = prompt('Describe the custom lead (free text — becomes agent focus context):'); - if (text && text.trim()) { state.customLeads.push(text.trim()); renderCustomLeads(); } +$('#btnCustomLead').addEventListener('click', async () => { + const text = prompt('Describe the custom lead — Claude (Opus, subscription) generates a real specialist agent for it, ready to pin:'); + if (!text || !text.trim()) return; + const btn = $('#btnCustomLead'); + const original = btn.textContent; + btn.disabled = true; + btn.textContent = 'Generating…'; + try { + const { agent } = await api('/api/leads/generate', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ description: text.trim() }), + }); + await loadAgents(); // re-read agents_md/ so the new file appears in its category + state.selected.add(agent.id); + renderBoard(); + alert(`Generated and pinned: ${agent.title}`); + } catch (e) { + // Fall back to the old behavior — fold the raw text into --focus context + // — so a missing/logged-out Claude CLI doesn't lose the operator's intent. + state.customLeads.push(text.trim()); + renderCustomLeads(); + alert(`Couldn't generate a skill (${e.message}) — added as a focus hint instead.`); + } finally { + btn.disabled = false; + btn.textContent = original; + } +}); +$('#btnSelectAll').addEventListener('click', () => { + // Respects the current search/filter — selects only what's visible, so a + // filtered view ("sql") + Select all pins just those leads, not all 412. + const visible = state.search.trim() ? allAgents().filter((a) => (a.title + ' ' + a.name).toLowerCase().includes(state.search.trim().toLowerCase())) : allAgents(); + visible.forEach((a) => state.selected.add(a.id)); + renderBoard(); +}); +$('#btnClearAll').addEventListener('click', () => { + const visible = state.search.trim() ? allAgents().filter((a) => (a.title + ' ' + a.name).toLowerCase().includes(state.search.trim().toLowerCase())) : allAgents(); + visible.forEach((a) => state.selected.delete(a.id)); + renderBoard(); }); // --------------------------------------------------------------------------- diff --git a/web/public/index.html b/web/public/index.html index af176d4..f729c63 100644 --- a/web/public/index.html +++ b/web/public/index.html @@ -127,8 +127,11 @@
+ + +
Tip: click a category's switch to select/deselect every lead in it at once.
diff --git a/web/server.js b/web/server.js index 875cd1e..f8d084c 100644 --- a/web/server.js +++ b/web/server.js @@ -188,6 +188,7 @@ const CATEGORY_RULES = [ ]; function classify(name, kind) { + if (name.startsWith('custom_')) return 'Custom Leads'; // generated via /api/leads/generate if (kind === 'chain') return 'Attack Chains'; if (kind === 'recon') return 'Recon'; if (kind === 'code') return 'Code Review'; @@ -248,6 +249,7 @@ async function loadAgents() { // Selectable leads only (exclude meta/orchestration from the pentest board — // they're internal doctrine agents, not testable "leads"). const LEAD_ORDER = [ + 'Custom Leads', 'Business Logic', 'Broken Access Control', 'Injection', 'Cross-Site Scripting', 'LLM Application', 'Auth & Session', 'SSRF & Network', 'API & GraphQL', 'Cloud & Infra', 'Client-Side', 'Cryptography', 'Rate Limiting & DoS', @@ -263,6 +265,86 @@ async function loadAgents() { return agentCache; } +// --------------------------------------------------------------------------- +// Custom leads — "+ Custom lead" generates a REAL specialist-agent markdown +// file (same format agents_md/vulns/*.md uses) via the `claude` CLI on the +// operator's Anthropic subscription, so a custom lead is an actual pinnable +// agent, not just free text folded into --focus. Mirrors the exact one-shot +// invocation harness::models::cli_login_status() uses for the same CLI. +// --------------------------------------------------------------------------- + +const GEN_MODEL = 'claude-opus-4-8'; // matches Session::default() in app/src/repl.rs +const GEN_TIMEOUT_MS = 90_000; + +function slugify(s) { + return (s || '').toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 40) || 'lead'; +} + +function buildSkillGenPrompt(description) { + return `Write ONE new security-testing specialist-agent file for this custom lead, in EXACTLY this markdown shape and nothing else — no code fences around the whole thing, no preamble, no explanation, just the file content starting at the first line: + +# Agent +## User Prompt +You are testing **{target}** for . +**Recon Context:** +{recon_json} +**METHODOLOGY:** +### 1. +- +### 2. +- +(as many numbered steps as the vuln class actually needs — terse, technical, no filler) +### Report +\`\`\` +FINDING: +- Title: ... +- Severity: ... +- CWE: CWE- +- Endpoint: [URL] +- Evidence: ... +- Impact: ... +- Remediation: ... +\`\`\` +## System Prompt + + +The operator's custom lead request, verbatim: "${description}" + +Match the doctrine style of NeuroSploit's other agents_md/vulns/*.md files: terse, technical, no marketing language, one CWE, a real report template. + +Do not use any tools (no file writes, no bash, no search) — this is a pure text-completion task. Respond with ONLY the markdown file content above, nothing before it and nothing after it.`; +} + +function generateCustomLead(description) { + return new Promise((resolve, reject) => { + if (!binaryOnPath('claude')) { + return reject(new Error("claude CLI not found on PATH — install Claude Code and run `claude` to log in first")); + } + // No --dangerously-skip-permissions here: this is a pure text-completion + // call (no bash/file tools needed), and granting tool access made claude + // try to write the file itself and narrate doing so instead of just + // returning text — see buildSkillGenPrompt()'s explicit "no tools" line. + const child = spawn('claude', ['-p', '--model', GEN_MODEL, '--output-format', 'text'], { env: process.env }); + let out = '', err = ''; + const timer = setTimeout(() => { child.kill('SIGKILL'); reject(new Error('generation timed out')); }, GEN_TIMEOUT_MS); + child.stdout.on('data', (c) => { out += c; }); + child.stderr.on('data', (c) => { err += c; }); + child.on('error', (e) => { clearTimeout(timer); reject(new Error(`claude CLI failed to start: ${e.message}`)); }); + child.on('close', (code) => { + clearTimeout(timer); + if (!out.trim()) return reject(new Error(err.trim() || `claude exited ${code} with no output — is it logged in? run \`claude\` once to check.`)); + resolve(out); + }); + child.stdin.write(buildSkillGenPrompt(description)); + child.stdin.end(); + }); +} + +function binaryOnPath(bin) { + const dirs = (process.env.PATH || '').split(path.delimiter); + return dirs.some((d) => { try { return fs.existsSync(path.join(d, bin)); } catch { return false; } }); +} + // --------------------------------------------------------------------------- // Runs — read runs//{meta,status,findings}.json // --------------------------------------------------------------------------- @@ -625,6 +707,35 @@ const server = http.createServer(async (req, res) => { if (req.method === 'GET' && p === '/api/agents') { return sendJson(res, 200, await loadAgents()); } + if (req.method === 'POST' && p === '/api/leads/generate') { + const body = await readBody(req); + const description = (body.description || '').trim(); + if (!description) return sendJson(res, 400, { error: 'description is required' }); + let raw; + try { + raw = await generateCustomLead(description); + } catch (e) { + return sendJson(res, 502, { error: e.message }); + } + // Defensive: discard any wrapper text before the first '# ' heading — + // a model with tool access sometimes narrates ("I'll write the file + // now...") before the actual content despite being told not to. + const titleIdx = raw.search(/^#\s+/m); + if (titleIdx === -1) { + return sendJson(res, 502, { error: 'generation did not return a well-formed agent file', raw: raw.slice(0, 800) }); + } + const clean = raw.slice(titleIdx).trim(); + const titleMatch = clean.match(/^#\s+(.+?)\s*$/m); + if (!titleMatch || !/##\s*User Prompt/i.test(clean) || !/##\s*System Prompt/i.test(clean)) { + return sendJson(res, 502, { error: 'generation did not return a well-formed agent file', raw: raw.slice(0, 800) }); + } + const slug = `custom_${slugify(titleMatch[1])}`; + await fsp.writeFile(path.join(AGENTS_DIR, 'vulns', `${slug}.md`), clean + '\n'); + agentCache = null; // force a fresh read so the new lead shows up immediately + const { agents } = await loadAgents(); + const created = agents.find((a) => a.id === slug); + return sendJson(res, 200, { agent: created, raw: clean }); + } // ---- runs ---- if (req.method === 'GET' && p === '/api/runs') {