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') {