feat(local-models): self-hosted backbone runs the Arsenal (Ollama + OpenAI-compatible)
This commit is contained in:
1 parent
1e20ce1c03
commit
eac30e4ee3
5 files changed
+136
-35
No files matched your search
@@ -22,9 +22,11 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
Point T3MP3ST at an authorized target and the kill chain is yours — **recon → exploit → report**, from a web War Room or the CLI, driven by the AI coding agent you're already signed into (Claude Code, Codex, Hermes). No new API keys, no cloud, no second bill. Your agent is the brain; T3MP3ST is the war machine you bolt around it. Self-hosted storm, keyless warfare. ⚡
|
**Your AI coding agent is already a hacker — T3MP3ST hands it an arsenal.**
|
||||||
|
|
||||||
The recon engine is live and tool-backed; the exploit loop is benchmark-proven — **90.1% pass@1 on XBEN**, XBOW's own 104-challenge suite, plus hint-free CTF solves and a cold hunt on real post-cutoff CVEs. Every number recomputes from committed data (`npm run verify-claims`), and the [status table](#what-ships-today) is exact about what's live, what's scaffolding, and what's still roadmap. The full 8-operator swarm is the architecture it grows into — loud about the mission, honest about where the build is. Full receipts, caveats, and the held-out CVE breakdown are in [Benchmarks](#benchmarks) below.
|
Point it at an authorized target and the kill chain runs itself: **recon → exploit → report**, from a browser War Room or the CLI, driven by the agent you're *already* signed into — Claude Code, Codex, Hermes — or a model you run **fully offline** (Ollama, LM Studio, vLLM). No new API keys, no cloud tenant, no second bill. Your agent is the brain; T3MP3ST is the war machine bolted around it. **Self-hosted storm. Keyless warfare.** ⚡
|
||||||
|
|
||||||
|
And it won't ask you to take its word for it. On **XBOW's own 104-challenge suite it scores 90.1% pass@1** — above XBOW's self-reported 85% — alongside hint-free CTF solves and a **cold hunt on real, post-cutoff CVEs the model had never seen**. Every number in this README recomputes from committed data with one command (`npm run verify-claims`). Loud about the mission, honest about the build — the [status table](#what-ships-today) says exactly what's live, what's scaffolding, and what's still roadmap; full receipts in [Benchmarks](#benchmarks).
|
||||||
|
|
||||||
Three things set it apart:
|
Three things set it apart:
|
||||||
|
|
||||||
@@ -69,6 +71,17 @@ Prefer to bring a key? Set one and skip the connect step:
|
|||||||
export OPENROUTER_API_KEY=... # or VENICE_API_KEY / ANTHROPIC_API_KEY
|
export OPENROUTER_API_KEY=... # or VENICE_API_KEY / ANTHROPIC_API_KEY
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Or run it **fully offline** on your own model — no key, no cloud. Defaults to Ollama; point it at any OpenAI-compatible server (LM Studio, vLLM, llama.cpp):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ollama serve && ollama pull llama3 # or an OpenAI-compatible server
|
||||||
|
export TEMPEST_LOCAL_BASE_URL=http://localhost:11434/api # LM Studio: http://localhost:1234/v1
|
||||||
|
export TEMPEST_LOCAL_MODEL=llama3
|
||||||
|
npx tempest config # → "Change default provider" → local
|
||||||
|
```
|
||||||
|
|
||||||
|
Tool-calling works on any local model (it's driven over text), so the Arsenal runs even on models without native function-calling.
|
||||||
|
|
||||||
Check the numbers for yourself:
|
Check the numbers for yourself:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* turn 0 and every operator abstains without ever running the Arsenal. These tests pin the fix AND
|
* 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).
|
* 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 { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
import { EventEmitter } from 'events';
|
import { EventEmitter } from 'events';
|
||||||
|
|
||||||
// Mock the local-agent CLI bridge (LocalAgentAdapter) and the codex spawn/file read (CodexAdapter).
|
// Mock the local-agent CLI bridge (LocalAgentAdapter) and the codex spawn/file read (CodexAdapter).
|
||||||
@@ -97,3 +97,40 @@ describe('codex backbone surfaces toolCalls (guards the CodexAdapter half of the
|
|||||||
expect(res.toolCalls).toBeUndefined();
|
expect(res.toolCalls).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('local-model (HTTP) backbone surfaces toolCalls — the keyless-path fix for self-hosted models', () => {
|
||||||
|
const origFetch = global.fetch;
|
||||||
|
afterEach(() => { global.fetch = origFetch; });
|
||||||
|
const mockJson = (body: unknown) => {
|
||||||
|
const spy = vi.fn(async (_url: string, _init: { body: string }) => ({ ok: true, json: async () => body } as unknown as Response));
|
||||||
|
global.fetch = spy as unknown as typeof fetch;
|
||||||
|
return spy;
|
||||||
|
};
|
||||||
|
const localModel = (baseUrl?: string) => new LLMBackbone({ provider: 'local', model: 'llama3', baseUrl } as never);
|
||||||
|
|
||||||
|
it('Ollama wire (/api/chat): parses a tool request out of message.content', async () => {
|
||||||
|
mockJson({ model: 'llama3', message: { role: 'assistant', content: '```json\n{"tool_calls":[{"name":"nmap_scan","arguments":{"target":"x"}}]}\n```' } });
|
||||||
|
const res = await localModel('http://localhost:11434/api').chatWithTools([{ role: 'user', content: 'scan' }], TOOLS as never);
|
||||||
|
expect(res.toolCalls?.[0]?.name).toBe('nmap_scan');
|
||||||
|
expect(res.finishReason).toBe('tool_calls');
|
||||||
|
});
|
||||||
|
it('OpenAI-compatible wire (/v1): parses out of choices[0].message.content + hits /v1/chat/completions', async () => {
|
||||||
|
const spy = mockJson({ model: 'local', choices: [{ message: { content: '{"tool_calls":[{"name":"nmap_scan","arguments":{"target":"y"}}]}' } }] });
|
||||||
|
const res = await localModel('http://localhost:1234/v1').chatWithTools([{ role: 'user', content: 'scan' }], TOOLS as never);
|
||||||
|
expect(res.toolCalls?.[0]?.name).toBe('nmap_scan');
|
||||||
|
expect(spy.mock.calls[0][0] as string).toContain('/v1/chat/completions');
|
||||||
|
});
|
||||||
|
it('prose debrief → final answer, not a forever-abstain', async () => {
|
||||||
|
mockJson({ model: 'llama3', message: { content: 'No exploitable surface found. Final debrief.' } });
|
||||||
|
const res = await localModel().chatWithTools([{ role: 'user', content: 'scan' }], TOOLS as never);
|
||||||
|
expect(res.toolCalls).toBeUndefined();
|
||||||
|
expect(res.finishReason).toBe('stop');
|
||||||
|
});
|
||||||
|
it('describes the Arsenal to the model (so a local model knows what it can request)', async () => {
|
||||||
|
const spy = mockJson({ message: { content: 'ok' } });
|
||||||
|
await localModel().chatWithTools([{ role: 'user', content: 'scan' }], TOOLS as never);
|
||||||
|
const body = JSON.parse((spy.mock.calls[0][1] as { body: string }).body);
|
||||||
|
const sys = body.messages.find((m: { role: string }) => m.role === 'system');
|
||||||
|
expect(sys.content).toContain('nmap_scan');
|
||||||
|
});
|
||||||
|
});
|
||||||
+2
-2
@@ -164,7 +164,7 @@ async function interactiveMode(): Promise<void> {
|
|||||||
showBanner();
|
showBanner();
|
||||||
|
|
||||||
// Check for API keys
|
// Check for API keys
|
||||||
const providers = getConfiguredProviders().filter(p => p !== 'mock' && p !== 'local');
|
const providers = getConfiguredProviders().filter(p => p !== 'mock');
|
||||||
|
|
||||||
if (providers.length === 0) {
|
if (providers.length === 0) {
|
||||||
showBox(
|
showBox(
|
||||||
@@ -527,7 +527,7 @@ async function openSettings(): Promise<void> {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'provider':
|
case 'provider':
|
||||||
const providers = getConfiguredProviders().filter(p => p !== 'mock' && p !== 'local');
|
const providers = getConfiguredProviders().filter(p => p !== 'mock');
|
||||||
if (providers.length === 0) {
|
if (providers.length === 0) {
|
||||||
showWarning('No API keys configured');
|
showWarning('No API keys configured');
|
||||||
break;
|
break;
|
||||||
|
|||||||
+9
-4
@@ -392,11 +392,11 @@ export const AVAILABLE_MODELS: Record<LLMProvider, ModelInfo[]> = {
|
|||||||
local: [
|
local: [
|
||||||
{
|
{
|
||||||
id: 'local-model',
|
id: 'local-model',
|
||||||
name: 'Local Model',
|
name: 'Local model (Ollama / LM Studio / vLLM — set TEMPEST_LOCAL_MODEL)',
|
||||||
provider: 'Local',
|
provider: 'Local',
|
||||||
contextWindow: 32000,
|
contextWindow: 32000,
|
||||||
maxOutput: 4096,
|
maxOutput: 4096,
|
||||||
capabilities: ['reasoning', 'code'],
|
capabilities: ['reasoning', 'code', 'tools'],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
'local-agent': [
|
'local-agent': [
|
||||||
@@ -619,8 +619,13 @@ class ConfigManager {
|
|||||||
actualModel = 'mock-model';
|
actualModel = 'mock-model';
|
||||||
break;
|
break;
|
||||||
case 'local':
|
case 'local':
|
||||||
baseUrl = 'http://localhost:11434/api';
|
// Self-hosted, keyless. Defaults to Ollama; point TEMPEST_LOCAL_BASE_URL at
|
||||||
actualModel = model || 'llama3';
|
// any OpenAI-compatible server (LM Studio :1234/v1, vLLM :8000/v1, llama.cpp)
|
||||||
|
// and TEMPEST_LOCAL_MODEL at the model tag you're serving.
|
||||||
|
baseUrl = process.env.TEMPEST_LOCAL_BASE_URL?.trim() || 'http://localhost:11434/api';
|
||||||
|
// 'local-model' is the placeholder id from AVAILABLE_MODELS — treat it as unset so
|
||||||
|
// TEMPEST_LOCAL_MODEL (or the llama3 default) wins; a real tag passed in still takes priority.
|
||||||
|
actualModel = (model && model !== 'local-model' ? model : undefined) || process.env.TEMPEST_LOCAL_MODEL?.trim() || 'llama3';
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unknown provider: ${actualProvider}`);
|
throw new Error(`Unknown provider: ${actualProvider}`);
|
||||||
|
|||||||
+72
-26
@@ -705,6 +705,14 @@ class MockAdapter implements LLMProviderAdapter {
|
|||||||
// LOCAL ADAPTER (Ollama, etc.)
|
// LOCAL ADAPTER (Ollama, etc.)
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
|
// Local model over HTTP — a fully self-hosted, keyless backbone. Talks to Ollama's
|
||||||
|
// native /api/chat by default, or any OpenAI-compatible local server (LM Studio,
|
||||||
|
// vLLM, llama.cpp, Ollama's own /v1) when TEMPEST_LOCAL_BASE_URL is a /v1 endpoint.
|
||||||
|
// Tool-calling is done over TEXT (renderToolContract + parseTextToolCalls, defined
|
||||||
|
// below and shared with the CLI-agent adapters): it works on ANY local model,
|
||||||
|
// whether or not it supports native function-calling — without it a local model
|
||||||
|
// hits the keyless-path abstain bug (no toolCalls → ReAct bails turn 0 → Arsenal
|
||||||
|
// never runs).
|
||||||
class LocalAdapter implements LLMProviderAdapter {
|
class LocalAdapter implements LLMProviderAdapter {
|
||||||
name = 'local';
|
name = 'local';
|
||||||
private config: LLMConfig;
|
private config: LLMConfig;
|
||||||
@@ -717,28 +725,55 @@ class LocalAdapter implements LLMProviderAdapter {
|
|||||||
return { valid: true };
|
return { valid: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
async chat(messages: LLMMessage[], options?: ChatOptions): Promise<LLMResponse> {
|
// A /v1 base URL means an OpenAI-compatible server (/chat/completions, choices[]).
|
||||||
const baseUrl = this.config.baseUrl || 'http://localhost:11434/api';
|
private isOpenAIWire(baseUrl: string): boolean {
|
||||||
const url = `${baseUrl}/chat`;
|
return /\/v1(\/|$)/.test(baseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
const requestBody = {
|
// Inject the Arsenal contract as a system turn when tools are offered, and
|
||||||
model: this.config.model,
|
// sanitize prior tool-request / tool-result turns — a plain local model doesn't
|
||||||
messages: messages.map(m => ({
|
// understand role:'tool', and re-emitting a prior ```json``` block would get
|
||||||
role: m.role,
|
// re-parsed as a fresh live call (defeats termination). Its TEXT reply is parsed
|
||||||
content: m.content,
|
// back into tool calls after the round-trip.
|
||||||
})),
|
private buildMessages(messages: LLMMessage[], options?: ChatOptions): { role: string; content: string }[] {
|
||||||
stream: false,
|
const contract = renderToolContract(options?.tools);
|
||||||
options: {
|
const preamble = 'You are the planning brain for T3MP3ST, an authorized offensive-security harness.'
|
||||||
num_predict: options?.maxTokens || this.config.maxTokens || 4096,
|
+ (contract
|
||||||
temperature: options?.temperature ?? this.config.temperature ?? 0.7,
|
? ' You drive a ReAct loop: REQUEST tools, the harness runs them and returns results, you reason until the surface is exhausted.' + contract
|
||||||
},
|
: ' Operate in planning and analysis mode; when JSON is requested, return ONLY the requested block — no preamble.');
|
||||||
};
|
const out: { role: string; content: string }[] = [{ role: 'system', content: preamble }];
|
||||||
|
for (const m of messages) {
|
||||||
|
if (m.role === 'assistant' && m.toolCalls?.length) {
|
||||||
|
out.push({ role: 'assistant', content: `[requested tools: ${m.toolCalls.map(t => t.name).join(', ')}]` });
|
||||||
|
} else if (m.role === 'tool') {
|
||||||
|
out.push({ role: 'user', content: `TOOL RESULT (${m.name || 'tool'}):\n${m.content}` });
|
||||||
|
} else {
|
||||||
|
out.push({ role: m.role, content: m.content });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async chat(messages: LLMMessage[], options?: ChatOptions): Promise<LLMResponse> {
|
||||||
|
const baseUrl = (this.config.baseUrl || 'http://localhost:11434/api').replace(/\/$/, '');
|
||||||
|
const openaiWire = this.isOpenAIWire(baseUrl);
|
||||||
|
const url = `${baseUrl}/${openaiWire ? 'chat/completions' : 'chat'}`;
|
||||||
|
const wireMessages = this.buildMessages(messages, options);
|
||||||
|
const maxTokens = options?.maxTokens || this.config.maxTokens || 4096;
|
||||||
|
const temperature = options?.temperature ?? this.config.temperature ?? 0.7;
|
||||||
|
|
||||||
|
const requestBody = openaiWire
|
||||||
|
? { model: this.config.model, messages: wireMessages, max_tokens: maxTokens, temperature, stream: false }
|
||||||
|
: { model: this.config.model, messages: wireMessages, stream: false, options: { num_predict: maxTokens, temperature } };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
// Local OpenAI-compatible servers usually ignore auth, but LM Studio & co.
|
||||||
|
// expect *some* bearer — send a dummy unless the operator set a real key.
|
||||||
|
...(openaiWire ? { Authorization: `Bearer ${this.config.apiKey || 'local'}` } : {}),
|
||||||
},
|
},
|
||||||
body: JSON.stringify(requestBody),
|
body: JSON.stringify(requestBody),
|
||||||
signal: AbortSignal.timeout(this.config.timeout || 120000),
|
signal: AbortSignal.timeout(this.config.timeout || 120000),
|
||||||
@@ -748,24 +783,35 @@ class LocalAdapter implements LLMProviderAdapter {
|
|||||||
throw new Error(`Local LLM error: ${response.status}`);
|
throw new Error(`Local LLM error: ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json() as OllamaResponse;
|
const data = await response.json() as OllamaResponse & {
|
||||||
|
choices?: Array<{ message?: { content?: string } }>;
|
||||||
|
usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number };
|
||||||
|
};
|
||||||
|
const content = openaiWire ? (data.choices?.[0]?.message?.content || '') : (data.message?.content || '');
|
||||||
|
|
||||||
|
// Tool-calling over text: if the Arsenal was offered, parse the model's tool
|
||||||
|
// requests so the ReAct loop EXECUTES them instead of abstaining on turn 0.
|
||||||
|
const toolCalls = options?.tools?.length ? parseTextToolCalls(content) : undefined;
|
||||||
|
|
||||||
|
const usage = openaiWire
|
||||||
|
? (data.usage
|
||||||
|
? { promptTokens: data.usage.prompt_tokens, completionTokens: data.usage.completion_tokens, totalTokens: data.usage.total_tokens }
|
||||||
|
: undefined)
|
||||||
|
: (data.eval_count
|
||||||
|
? { promptTokens: data.prompt_eval_count || 0, completionTokens: data.eval_count || 0, totalTokens: (data.prompt_eval_count || 0) + (data.eval_count || 0) }
|
||||||
|
: undefined);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: data.message?.content || '',
|
content,
|
||||||
model: data.model || this.config.model,
|
model: data.model || this.config.model,
|
||||||
usage: data.eval_count
|
usage,
|
||||||
? {
|
finishReason: toolCalls?.length ? 'tool_calls' : 'stop',
|
||||||
promptTokens: data.prompt_eval_count || 0,
|
toolCalls,
|
||||||
completionTokens: data.eval_count || 0,
|
|
||||||
totalTokens: (data.prompt_eval_count || 0) + (data.eval_count || 0),
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
finishReason: 'stop',
|
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof TypeError && error.message.includes('fetch')) {
|
if (error instanceof TypeError && error.message.includes('fetch')) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Could not connect to local LLM. Make sure Ollama is running (ollama serve)'
|
'Could not connect to local LLM. Start Ollama (`ollama serve`), or point TEMPEST_LOCAL_BASE_URL at your OpenAI-compatible server (LM Studio / vLLM / llama.cpp).'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
Reference in new issue
Block a user