From e253b8b2919b2cd0a11e2019962e675e8d61539d Mon Sep 17 00:00:00 2001 From: CyberSecurityUP Date: Sun, 23 Aug 2026 15:43:13 -0300 Subject: [PATCH] feat(web): bulk select/clear-all leads; custom lead generates a real agent - Select all / Clear all buttons in the Leads step toolbar - respects the current search filter, so filtering to "sql" then Select all only pins those, not all 412 leads. The per-category master switch (already select/deselect-all for that category, indeterminate when partial) was the only bulk control before; this adds the "everything" case. - '+ Custom lead' now generates an ACTUAL specialist-agent markdown file (agents_md/vulns/custom_.md, same format every other agent uses) via the claude CLI on the operator's Anthropic subscription (claude-opus-4-8 by default - matches the harness's own default model), instead of folding free text into --focus. The new lead is immediately selectable and pinnable via --only like any other agent; verified the Rust harness's own agent loader picks it up (agent count went 435 -> 436, neurosploit agents confirmed it). Two things found and fixed while wiring this up: - the skip-permissions flag gave the model file/bash tool access, which made it try to write the file itself and narrate doing so instead of just returning text. Dropped the flag (pure text completion needs no tools) and told it explicitly not to use any. - Even so, defensively strip anything before the first '# ' heading before saving, in case a model still prepends commentary. Falls back to the old free-text-focus behavior if generation fails (claude not installed/logged in, malformed output, timeout) so the operator's intent isn't lost. - New "Custom Leads" category, shown first, so generated leads have a visible home instead of landing in the catch-all "Other" bucket. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0129WdYHccPsH27k5GGuwijd --- web/public/app.js | 40 +++++++++++++-- web/public/index.html | 3 ++ web/server.js | 111 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 151 insertions(+), 3 deletions(-) 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') {