diff --git a/docs/index.html b/docs/index.html
index 0a686f2..f3ca6b4 100755
--- a/docs/index.html
+++ b/docs/index.html
@@ -5551,7 +5551,7 @@
A domain lights up only when there's a benchmark behind it. Greyed tiles are in development.
πΈοΈ
Web β
Apps, APIs, auth flows, OWASP Top 10. XBEN 90.1% pass@1.
-
π
Code β
White-box source audits. CVE-Zero 6/10 + 9/10 held-out (7 languages).
+
π
Code β
White-box source audits. Held-out CVE-Zero: single-agent 8/10 exact file/line/CWE, 10/10 found (7 languages).
π©
CTF β
Wargames, ranges, challenges. Cybench 23/40 hint-free.
π
Network / Infra β
Live recon engine (nmap/DNS/HTTP). Lateral + privesc experimental.
π€
Embedded / IoT / OT β
Firmware, robotics, ICS/SCADA. Coordinated-disclosure CVE pipeline live.
diff --git a/scripts/cve-zero-split.mjs b/scripts/cve-zero-split.mjs
index d49f8bd..97a96af 100644
--- a/scripts/cve-zero-split.mjs
+++ b/scripts/cve-zero-split.mjs
@@ -3,7 +3,7 @@
// The recommender (composeSquad tuning + rankConfigs learning) may read ONLY the TRAIN split, and
// any "config X beats config Y" comparison is reported on TEST. A generalization claim ("our tuned
// squads win in general") may use ONLY the HOLDOUT split β fresh, never-seen CVEs (CVE-Zero-v2),
-// which is intentionally EMPTY until curated. This is what stops us from overfitting the eval and
+// a curated 10-sample set the hardened prompts were never tuned on. This is what stops us from overfitting the eval and
// then quoting the overfit number. The three families shared across the corpus (path / ssrf / authz)
// each land >=1 in BOTH train and test so neither half is blind to them.
export const SPLIT = {
@@ -39,7 +39,8 @@ if (isMain && process.argv.includes('--self-test')) {
ok('train + test are 5 + 5, disjoint', SPLIT.train.length === 5 && SPLIT.test.length === 5 && !SPLIT.train.some((x) => SPLIT.test.includes(x)));
ok('every split id is a real sample', [...SPLIT.train, ...SPLIT.test].every((id) => gt[id]));
for (const f of ['path', 'ssrf', 'authz']) ok(`shared family "${f}" appears in BOTH train and test`, trainF.has(f) && testF.has(f));
- ok('holdout is empty until CVE-Zero-v2 is curated', SPLIT.holdout.length === 0);
+ ok('holdout is the curated 10-sample CVE-Zero-v2 split (real samples, disjoint from train/test)',
+ SPLIT.holdout.length === 10 && SPLIT.holdout.every((id) => gt[id]) && !SPLIT.holdout.some((x) => SPLIT.train.includes(x) || SPLIT.test.includes(x)));
console.log(`\n${fail === 0 ? 'β
ALL PASS' : 'β ' + fail + ' FAILED'} β ${pass}/${pass + fail}\n`);
process.exitCode = fail === 0 ? 0 : 1;
};
diff --git a/src/__tests__/local-agent-tool-calling.test.ts b/src/__tests__/local-agent-tool-calling.test.ts
new file mode 100644
index 0000000..93b2ad1
--- /dev/null
+++ b/src/__tests__/local-agent-tool-calling.test.ts
@@ -0,0 +1,99 @@
+/**
+ * REGRESSION (keyless-path bug): the local-agent / codex text-CLI backbones must surface `toolCalls`
+ * parsed from the agent's reply. If they don't, the ReAct loop takes its "final answer" branch on
+ * turn 0 and every operator abstains without ever running the Arsenal. These tests pin the fix AND
+ * the parser-hardening from the PR #16 audit (over-match, ReDoS, drift-abstains, string args, Codex coverage).
+ */
+import { describe, it, expect, vi } from 'vitest';
+import { EventEmitter } from 'events';
+
+// Mock the local-agent CLI bridge (LocalAgentAdapter) and the codex spawn/file read (CodexAdapter).
+vi.mock('../agent/local-agents.js', () => ({ localAgentChat: vi.fn() }));
+vi.mock('child_process', () => ({
+ spawn: vi.fn(() => {
+ const child = new EventEmitter() as EventEmitter & { stdin: { end: () => void }; stdout: EventEmitter; stderr: EventEmitter };
+ child.stdin = { end: () => setTimeout(() => child.emit('close', 0), 0) };
+ child.stdout = new EventEmitter();
+ child.stderr = new EventEmitter();
+ return child;
+ }),
+}));
+vi.mock('fs/promises', async (orig) => {
+ const actual = await orig
();
+ return { ...actual, readFile: vi.fn() };
+});
+
+import { parseTextToolCalls, LLMBackbone } from '../llm/index.js';
+import { localAgentChat } from '../agent/local-agents.js';
+import { readFile } from 'fs/promises';
+const cli = vi.mocked(localAgentChat);
+const fileRead = vi.mocked(readFile as unknown as (...a: unknown[]) => Promise);
+
+const TOOLS = [{
+ name: 'nmap_scan', description: 'port scan a host',
+ parameters: { type: 'object' as const, properties: { target: { type: 'string' } }, required: ['target'] },
+}];
+const localBackbone = () => new LLMBackbone({ provider: 'local-agent', model: 'codex' } as never);
+const codexBackbone = () => new LLMBackbone({ provider: 'codex', model: 'codex-default' } as never);
+
+describe('parseTextToolCalls β happy path + drift tolerance', () => {
+ it('parses a contracted fenced block', () => {
+ expect(parseTextToolCalls('reasoning\n```json\n{"tool_calls":[{"name":"nmap_scan","arguments":{"target":"x"}}]}\n```'))
+ .toMatchObject([{ name: 'nmap_scan', arguments: { target: 'x' } }]);
+ });
+ it('prose-only debrief is the final answer (no calls)', () => {
+ expect(parseTextToolCalls('Surface exhausted; no findings. Abstaining.')).toBeUndefined();
+ });
+ // --- audit must-fixes below ---
+ it('unfenced object + a stray brace elsewhere still parses (was: greedy over-match β silent abstain)', () => {
+ const r = parseTextToolCalls('example shape {"x":1}\n{"tool_calls":[{"name":"port_scan","arguments":{"host":"h"}}]}\ntrailing {note}');
+ expect(r).toHaveLength(1);
+ expect(r![0].name).toBe('port_scan');
+ });
+ it('tolerates a trailing comma', () => {
+ expect(parseTextToolCalls('```json\n{"tool_calls":[{"name":"nmap_scan","arguments":{"target":"x"}},]}\n```')).toHaveLength(1);
+ });
+ it('accepts a single un-wrapped {name,arguments} object', () => {
+ expect(parseTextToolCalls('```json\n{"name":"nmap_scan","arguments":{"target":"x"}}\n```')).toMatchObject([{ name: 'nmap_scan' }]);
+ });
+ it('accepts an "actions" wrapper key', () => {
+ expect(parseTextToolCalls('{"actions":[{"name":"curl_request","arguments":{}}]}')).toHaveLength(1);
+ });
+ it('coerces a string "arguments" into an object (scope-gate blind-spot fix)', () => {
+ const r = parseTextToolCalls('{"tool_calls":[{"name":"nmap_scan","arguments":"{\\"target\\":\\"x\\"}"}]}');
+ expect(r![0].arguments).toEqual({ target: 'x' });
+ });
+ it('is DoS-safe on pathological brace input (was: quadratic ReDoS)', () => {
+ const t0 = Date.now();
+ expect(parseTextToolCalls('{'.repeat(80000))).toBeUndefined();
+ expect(Date.now() - t0).toBeLessThan(300); // bounded β the old greedy regex took ~2100ms
+ });
+});
+
+describe('local-agent backbone surfaces toolCalls (keyless-path fix)', () => {
+ it('returns toolCalls + finishReason=tool_calls when the agent requests a tool', async () => {
+ cli.mockResolvedValueOnce('```json\n{"tool_calls":[{"name":"nmap_scan","arguments":{"target":"x"}}]}\n```');
+ const res = await localBackbone().chatWithTools([{ role: 'user', content: 'scan' }], TOOLS as never);
+ expect(res.toolCalls?.[0]?.name).toBe('nmap_scan');
+ expect(res.finishReason).toBe('tool_calls');
+ });
+ it('returns no toolCalls (final answer) on a prose debrief', async () => {
+ cli.mockResolvedValueOnce('Final debrief: nothing exploitable.');
+ const res = await localBackbone().chatWithTools([{ role: 'user', content: 'scan' }], TOOLS as never);
+ expect(res.toolCalls).toBeUndefined();
+ expect(res.finishReason).toBe('stop');
+ });
+});
+
+describe('codex backbone surfaces toolCalls (guards the CodexAdapter half of the fix)', () => {
+ it('returns toolCalls when codex emits the contract', async () => {
+ fileRead.mockResolvedValueOnce('```json\n{"tool_calls":[{"name":"nmap_scan","arguments":{"target":"x"}}]}\n```');
+ const res = await codexBackbone().chatWithTools([{ role: 'user', content: 'scan' }], TOOLS as never);
+ expect(res.toolCalls?.[0]?.name).toBe('nmap_scan');
+ });
+ it('returns no toolCalls on a codex prose debrief', async () => {
+ fileRead.mockResolvedValueOnce('No exploitable surface. Final debrief.');
+ const res = await codexBackbone().chatWithTools([{ role: 'user', content: 'scan' }], TOOLS as never);
+ expect(res.toolCalls).toBeUndefined();
+ });
+});
diff --git a/src/llm/index.ts b/src/llm/index.ts
index 8f57cd3..a9bace6 100755
--- a/src/llm/index.ts
+++ b/src/llm/index.ts
@@ -777,6 +777,113 @@ class LocalAdapter implements LLMProviderAdapter {
// CODEX ADAPTER (local Codex CLI/account subscription)
// =============================================================================
+// ββ tool-calling over a plain-text CLI agent (local-agent / codex backbones) ββ
+// These CLIs return TEXT, not structured tool_calls like an API β so historically
+// the ReAct loop got no `toolCalls`, took its "final answer" branch on turn 0, and
+// abstained without ever running the Arsenal (the keyless-path bug). Fix: describe
+// the Arsenal + a strict JSON action-contract in the prompt, then parse the agent's
+// text reply back into LLMToolCall[] so `arsenal.execute` actually runs the tools.
+function renderToolContract(tools?: LLMToolDefinition[]): string {
+ if (!tools?.length) return '';
+ const lines = ['\n## ARSENAL β tools the HARNESS runs for you (you REQUEST them, it EXECUTES + returns the output):'];
+ for (const t of tools) {
+ const props = (t.parameters?.properties || {}) as Record;
+ const req = new Set(t.parameters?.required || []);
+ const sig = Object.entries(props).map(([k, v]) => `${k}${req.has(k) ? '*' : ''}: ${v.type || 'any'}`).join(', ');
+ lines.push(`- ${t.name}(${sig}) β ${t.description}`);
+ }
+ lines.push(
+ '',
+ '## ACTION CONTRACT β follow EXACTLY:',
+ 'β’ To run one or more tools, reply with ONLY this fenced block, nothing else:',
+ '```json',
+ '{"tool_calls":[{"name":"","arguments":{ ... }}]}',
+ '```',
+ ' The harness runs them (scope-gated) and returns the results as new messages; then you reason again.',
+ 'β’ When the attack surface is exhausted and you are DONE, reply with your final debrief in prose (NO json block).',
+ 'β’ Never run these tools yourself β REQUEST them. Requesting is how you act.',
+ );
+ return lines.join('\n');
+}
+
+// Yield each brace-BALANCED {...} substring (string-aware). Linear + hard-budgeted, so it replaces a
+// greedy /\{[\s\S]*\}/ that both over-matched (first-'{' to last-'}') and backtracked quadratically
+// (ReDoS). Bounded work regardless of input shape (unbalanced braces, 80k '{', etc.).
+function* balancedObjectSpans(text: string): Generator {
+ const n = text.length;
+ let budget = 2_000_000; // total-scan cap β DoS-safe upper bound on worst-case work
+ let spans = 0;
+ for (let i = 0; i < n && spans < 200 && budget > 0; i++) {
+ if (text[i] !== '{') continue;
+ let depth = 0, inStr = false, esc = false;
+ for (let j = i; j < n && budget > 0; j++, budget--) {
+ const c = text[j];
+ if (inStr) { if (esc) esc = false; else if (c === '\\') esc = true; else if (c === '"') inStr = false; continue; }
+ if (c === '"') inStr = true;
+ else if (c === '{') depth++;
+ else if (c === '}') { depth--; if (depth === 0) { spans++; yield text.slice(i, j + 1); i = j; break; } }
+ }
+ }
+}
+
+// Parse an LLMToolCall[] out of a text-CLI reply. Tolerant of realistic model drift so a valid tool
+// intent doesn't silently degrade to a zero-tool abstain: fenced blocks, balanced bare objects, a
+// single un-wrapped {name,...}, {tool_calls|actions|calls|tools:[...]} wrappers, and trailing commas.
+export function parseTextToolCalls(text: string): LLMToolCall[] | undefined {
+ const coerceArgs = (a: unknown): Record => {
+ if (typeof a === 'string') {
+ try { const p: unknown = JSON.parse(a); return p && typeof p === 'object' && !Array.isArray(p) ? (p as Record) : {}; } catch { return {}; }
+ }
+ return a && typeof a === 'object' && !Array.isArray(a) ? (a as Record) : {};
+ };
+ const build = (v: unknown): LLMToolCall[] | undefined => {
+ let arr: unknown;
+ if (Array.isArray(v)) arr = v;
+ else if (v && typeof v === 'object') {
+ const o = v as Record;
+ arr = o.tool_calls ?? o.toolCalls ?? o.actions ?? o.calls ?? o.tools;
+ if (arr === undefined && typeof o.name === 'string') arr = [o]; // a single un-wrapped call
+ }
+ if (!Array.isArray(arr) || arr.length === 0) return undefined;
+ const calls = arr
+ .filter((tc): tc is { name: string } => !!tc && typeof (tc as { name?: unknown }).name === 'string')
+ .map((tc, i) => {
+ const o = tc as { name: string; arguments?: unknown; args?: unknown; parameters?: unknown; input?: unknown };
+ return { id: `lc_${Date.now()}_${i}`, name: o.name, arguments: coerceArgs(o.arguments ?? o.args ?? o.parameters ?? o.input) };
+ });
+ return calls.length ? calls : undefined;
+ };
+ const tryParse = (s: string): LLMToolCall[] | undefined => {
+ const cleaned = s.trim().replace(/,(\s*[}\]])/g, '$1'); // tolerate trailing commas
+ let obj: unknown;
+ try { obj = JSON.parse(cleaned); } catch { return undefined; }
+ return build(obj);
+ };
+ // 1) fenced ```json / ```tool blocks (the contracted format)
+ for (const m of text.matchAll(/```(?:json|tool)?\s*([\s\S]*?)```/g)) {
+ const r = tryParse(m[1]); if (r) return r;
+ }
+ // 2) each brace-balanced object span (bounded scan β no greedy regex, no ReDoS)
+ for (const span of balancedObjectSpans(text)) {
+ const r = tryParse(span); if (r) return r;
+ }
+ // 3) the whole reply (e.g. a top-level array)
+ return tryParse(text);
+}
+
+// Render one conversation message for a text-CLI prompt. A prior assistant TOOL REQUEST is rendered as
+// a compact summary, NOT its raw ```json``` block β re-emitting the block let the CLI's next reply (or a
+// final debrief that quotes what it ran) get re-parsed as a fresh live call, defeating termination.
+function renderCliMessage(m: LLMMessage): string {
+ if (m.role === 'assistant' && m.toolCalls?.length) {
+ return `\n### ASSISTANT\n[requested tools: ${m.toolCalls.map((t) => t.name).join(', ')}]`;
+ }
+ if (m.role === 'tool') {
+ return `\n### TOOL RESULT (${m.name || 'tool'})\n${m.content}`;
+ }
+ return `\n### ${m.role.toUpperCase()}\n${m.content}`;
+}
+
class CodexAdapter implements LLMProviderAdapter {
name = 'codex';
private config: LLMConfig;
@@ -791,14 +898,19 @@ class CodexAdapter implements LLMProviderAdapter {
private formatPrompt(messages: LLMMessage[], options?: ChatOptions): string {
const parts = [
- 'You are acting as the Codex-backed planning brain for T3MP3ST.',
- 'Operate in planning, critique, and evidence-contract mode only. Do not modify files. Do not run active probes. If tools are available, use read-only inspection only.',
- 'When the caller requests JSON, return only valid JSON or the exact requested fenced JSON block.',
+ 'You are acting as the Codex-backed planning brain for T3MP3ST, an authorized offensive-security harness.',
+ 'Do not modify files and do not run active probes yourself.',
];
- if (options?.maxTokens) parts.push(`Target max output tokens: ${options.maxTokens}.`);
- for (const message of messages) {
- parts.push(`\n### ${message.role.toUpperCase()}\n${message.content}`);
+ const contract = renderToolContract(options?.tools);
+ if (contract) {
+ parts.push('You drive a ReAct loop: REQUEST tools via the contract below β the HARNESS runs them scope-gated and returns results β then reason until the surface is exhausted.');
+ parts.push(contract);
+ } else {
+ parts.push('Operate in planning, critique, and evidence-contract mode only. If tools are available, use read-only inspection only.');
+ parts.push('When the caller requests JSON, return only valid JSON or the exact requested fenced JSON block.');
}
+ if (options?.maxTokens) parts.push(`Target max output tokens: ${options.maxTokens}.`);
+ for (const message of messages) parts.push(renderCliMessage(message));
return parts.join('\n');
}
@@ -867,10 +979,14 @@ class CodexAdapter implements LLMProviderAdapter {
content = result.stdout;
}
+ const trimmed = content.trim();
+ // Tool-calling over text: parse the agent's tool requests so the ReAct loop EXECUTES them.
+ const toolCalls = options?.tools?.length ? parseTextToolCalls(trimmed) : undefined;
return {
- content: content.trim(),
+ content: trimmed,
model: this.config.model || 'codex-default',
- finishReason: 'stop',
+ finishReason: toolCalls?.length ? 'tool_calls' : 'stop',
+ toolCalls,
};
} finally {
await rm(workDir, { recursive: true, force: true }).catch(() => undefined);
@@ -890,17 +1006,31 @@ class LocalAgentAdapter implements LLMProviderAdapter {
private formatPrompt(messages: LLMMessage[], options?: ChatOptions): string {
const parts = [
'You are the local-agent planning brain for T3MP3ST, an authorized offensive-security harness.',
- 'Operate in planning, analysis, and evidence-contract mode. When the caller requests JSON, return ONLY valid JSON or the exact requested fenced block β no preamble.',
];
+ const contract = renderToolContract(options?.tools);
+ if (contract) {
+ parts.push('You drive a ReAct loop: REQUEST tools, the harness runs them and returns results, you reason until the surface is exhausted.');
+ parts.push(contract);
+ } else {
+ parts.push('Operate in planning, analysis, and evidence-contract mode. When the caller requests JSON, return ONLY valid JSON or the exact requested fenced block β no preamble.');
+ }
if (options?.maxTokens) parts.push(`Target max output tokens: ${options.maxTokens}.`);
- for (const m of messages) parts.push(`\n### ${m.role.toUpperCase()}\n${m.content}`);
+ for (const m of messages) parts.push(renderCliMessage(m));
return parts.join('\n');
}
async chat(messages: LLMMessage[], options?: ChatOptions): Promise {
const agentId = this.config.model || 'codex';
const prompt = this.formatPrompt(messages, options);
- const content = await localAgentChat(agentId, prompt, { timeoutMs: this.config.timeout || 240000 });
- return { content: content.trim(), model: `local-agent:${agentId}`, finishReason: 'stop' };
+ const content = (await localAgentChat(agentId, prompt, { timeoutMs: this.config.timeout || 240000 })).trim();
+ // Tool-calling over text: if the Arsenal was offered, parse the agent's tool requests so the
+ // ReAct loop EXECUTES them instead of treating this planning turn as the (abstaining) final answer.
+ const toolCalls = options?.tools?.length ? parseTextToolCalls(content) : undefined;
+ return {
+ content,
+ model: `local-agent:${agentId}`,
+ finishReason: toolCalls?.length ? 'tool_calls' : 'stop',
+ toolCalls,
+ };
}
}