Add files via upload
This commit is contained in:
52 files changed
+33300
No files matched your search
@@ -0,0 +1,505 @@
|
||||
/**
|
||||
* T3MP3ST Agent Loop
|
||||
*
|
||||
* ReAct (Reason + Act) agent that connects LLM tool calling to Arsenal execution.
|
||||
* This is the core autonomous loop: the LLM decides which tool to use,
|
||||
* Arsenal executes it, and results are fed back for the next decision.
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'eventemitter3';
|
||||
import type { LLMBackbone } from '../llm/index.js';
|
||||
import type { Arsenal } from '../arsenal/index.js';
|
||||
import type {
|
||||
LLMMessage,
|
||||
LLMToolDefinition,
|
||||
LLMToolCall,
|
||||
ToolResult,
|
||||
ToolFinding,
|
||||
Severity,
|
||||
Target,
|
||||
Task,
|
||||
} from '../types/index.js';
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
export interface AgentLoopOptions {
|
||||
/** Max ReAct iterations before forcing a conclusion (default: 15) */
|
||||
maxIterations?: number;
|
||||
/** Max total tokens to spend (default: 50000) */
|
||||
maxTokens?: number;
|
||||
/** Tool categories to expose (default: all) */
|
||||
toolCategories?: string[];
|
||||
/** Whether to include detailed tool output in context (default: true) */
|
||||
verboseToolOutput?: boolean;
|
||||
/** Max characters per tool result before truncation (default: 4000) */
|
||||
maxToolOutputLength?: number;
|
||||
}
|
||||
|
||||
export interface AgentStep {
|
||||
iteration: number;
|
||||
type: 'tool_call' | 'observation' | 'reasoning';
|
||||
toolName?: string;
|
||||
toolArgs?: Record<string, unknown>;
|
||||
toolResult?: ToolResult;
|
||||
content?: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface AgentResult {
|
||||
success: boolean;
|
||||
/** Final summary from the LLM */
|
||||
summary: string;
|
||||
/** All steps taken during execution */
|
||||
steps: AgentStep[];
|
||||
/** Findings extracted from tool results */
|
||||
findings: ToolFinding[];
|
||||
/** Total iterations used */
|
||||
iterations: number;
|
||||
/** Total tokens consumed */
|
||||
tokensUsed: number;
|
||||
/** Duration in ms */
|
||||
durationMs: number;
|
||||
/** Whether the agent hit the iteration limit */
|
||||
hitLimit: boolean;
|
||||
}
|
||||
|
||||
export interface AgentEvents {
|
||||
'agent:step': AgentStep;
|
||||
'agent:tool_call': { name: string; args: Record<string, unknown> };
|
||||
'agent:tool_result': { name: string; result: ToolResult };
|
||||
'agent:thinking': { content: string };
|
||||
'agent:complete': AgentResult;
|
||||
'agent:error': { error: Error; step: number };
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// AGENT LOOP
|
||||
// =============================================================================
|
||||
|
||||
export class AgentLoop extends EventEmitter<AgentEvents> {
|
||||
private llm: LLMBackbone;
|
||||
private arsenal: Arsenal;
|
||||
private options: Required<AgentLoopOptions>;
|
||||
|
||||
constructor(llm: LLMBackbone, arsenal: Arsenal, options?: AgentLoopOptions) {
|
||||
super();
|
||||
this.llm = llm;
|
||||
this.arsenal = arsenal;
|
||||
this.options = {
|
||||
maxIterations: options?.maxIterations ?? 15,
|
||||
maxTokens: options?.maxTokens ?? 50000,
|
||||
toolCategories: options?.toolCategories ?? [],
|
||||
verboseToolOutput: options?.verboseToolOutput ?? true,
|
||||
maxToolOutputLength: options?.maxToolOutputLength ?? 4000,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the ReAct loop for a task
|
||||
*/
|
||||
async run(
|
||||
task: Task,
|
||||
systemPrompt: string,
|
||||
target?: Target,
|
||||
sourceContext?: string
|
||||
): Promise<AgentResult> {
|
||||
const startTime = Date.now();
|
||||
const steps: AgentStep[] = [];
|
||||
const allFindings: ToolFinding[] = [];
|
||||
let tokensUsed = 0;
|
||||
let hitLimit = false;
|
||||
// anti-stall: dedup identical tool calls + detect runs of no-new-findings (ported from the hunter)
|
||||
const seenCalls = new Map<string, string>();
|
||||
let noProgress = 0;
|
||||
|
||||
// Get tool definitions from Arsenal
|
||||
const toolDefs = this.arsenal.getToolDefinitions(
|
||||
this.options.toolCategories.length > 0 ? this.options.toolCategories : undefined
|
||||
);
|
||||
|
||||
// Build initial messages
|
||||
const messages: LLMMessage[] = [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: this.buildTaskPrompt(task, target, toolDefs, sourceContext) },
|
||||
];
|
||||
|
||||
for (let i = 0; i < this.options.maxIterations; i++) {
|
||||
try {
|
||||
// Ask the LLM what to do next
|
||||
const response = await this.llm.chatWithTools(messages, toolDefs, {
|
||||
maxTokens: 4096,
|
||||
temperature: 0.3, // Lower temperature for tool-using tasks
|
||||
});
|
||||
|
||||
tokensUsed += response.usage?.totalTokens || 0;
|
||||
|
||||
// If the LLM wants to call tools
|
||||
if (response.toolCalls?.length) {
|
||||
// Record the assistant's reasoning
|
||||
if (response.content) {
|
||||
const thinkStep: AgentStep = {
|
||||
iteration: i,
|
||||
type: 'reasoning',
|
||||
content: response.content,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
steps.push(thinkStep);
|
||||
this.emit('agent:thinking', { content: response.content });
|
||||
}
|
||||
|
||||
// Add assistant message to context (with tool calls)
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
content: response.content || '',
|
||||
toolCalls: response.toolCalls,
|
||||
});
|
||||
|
||||
// Execute each tool call
|
||||
const findingsBefore = allFindings.length;
|
||||
for (const toolCall of response.toolCalls) {
|
||||
// ANTI-STALL: a byte-identical repeat is almost always wasted — steer instead of re-running.
|
||||
const callHash = `${toolCall.name}:${JSON.stringify(toolCall.arguments || {})}`;
|
||||
let toolStep: AgentStep;
|
||||
if (seenCalls.has(callHash)) {
|
||||
const dup: ToolResult = {
|
||||
success: false,
|
||||
error: `Duplicate call — you already ran ${toolCall.name} with these exact arguments. ` +
|
||||
`Prior result: ${seenCalls.get(callHash)}. Do NOT repeat it — change the arguments, pick a different tool, or move to your final debrief.`,
|
||||
};
|
||||
toolStep = { iteration: i, type: 'tool_call', toolName: toolCall.name, toolArgs: toolCall.arguments, toolResult: dup, timestamp: Date.now() };
|
||||
} else {
|
||||
toolStep = await this.executeTool(toolCall, target, i);
|
||||
seenCalls.set(callHash, String(toolStep.toolResult?.output || toolStep.toolResult?.error || 'no output').replace(/\s+/g, ' ').slice(0, 160));
|
||||
}
|
||||
steps.push(toolStep);
|
||||
|
||||
// Collect findings — tag with REAL tool provenance (the output that backs them)
|
||||
if (toolStep.toolResult?.findings) {
|
||||
const out = String(toolStep.toolResult.output ?? '').slice(0, 4000);
|
||||
for (const tf of toolStep.toolResult.findings) {
|
||||
allFindings.push({ ...tf, provenance: 'tool', toolName: toolCall.name, toolOutput: out || tf.details });
|
||||
}
|
||||
}
|
||||
|
||||
// Build tool result content
|
||||
const resultContent = this.formatToolResult(toolStep.toolResult);
|
||||
|
||||
// Add tool result to context
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
content: resultContent,
|
||||
toolCallId: toolCall.id,
|
||||
name: toolCall.name,
|
||||
});
|
||||
}
|
||||
|
||||
// ANTI-STALL: after a run of iterations with no new findings, steer (once) toward a new
|
||||
// vector or a final debrief — don't let the agent grind the budget on a dead approach.
|
||||
noProgress = allFindings.length > findingsBefore ? 0 : noProgress + 1;
|
||||
if (noProgress >= 4 && i < this.options.maxIterations - 2) {
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: '[System: 4 iterations with no new findings. Either pursue a GENUINELY different vector/tool/argument now, or produce your final debrief if the surface is exhausted. Do not keep repeating the current approach.]',
|
||||
});
|
||||
noProgress = 0;
|
||||
}
|
||||
} else {
|
||||
// LLM finished reasoning — this is the final answer.
|
||||
const finalStep: AgentStep = {
|
||||
iteration: i,
|
||||
type: 'reasoning',
|
||||
content: response.content,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
steps.push(finalStep);
|
||||
|
||||
// Parse the structured debrief block so the model's OWN analysis is captured —
|
||||
// previously only tool-emitted findings were kept and the final report was dropped.
|
||||
for (const f of this.parseFinalFindings(response.content || '')) {
|
||||
if (!allFindings.some((x) => x.title === f.title)) allFindings.push(f);
|
||||
}
|
||||
|
||||
const result: AgentResult = {
|
||||
success: true,
|
||||
summary: response.content,
|
||||
steps,
|
||||
findings: allFindings,
|
||||
iterations: i + 1,
|
||||
tokensUsed,
|
||||
durationMs: Date.now() - startTime,
|
||||
hitLimit: false,
|
||||
};
|
||||
|
||||
this.emit('agent:complete', result);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Check token budget
|
||||
if (tokensUsed >= this.options.maxTokens) {
|
||||
hitLimit = true;
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
this.emit('agent:error', { error: error as Error, step: i });
|
||||
|
||||
// On error, try to continue with a reduced context
|
||||
const errMsg = error instanceof Error ? error.message : String(error);
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: `[System: Tool execution error: ${errMsg}. Please continue with available information or try a different approach.]`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Hit iteration or token limit — ask LLM for a final summary
|
||||
hitLimit = true;
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: 'You have reached the maximum number of steps. Please provide a final summary of everything you discovered, including all findings and recommendations.',
|
||||
});
|
||||
|
||||
let summary = 'Agent reached iteration limit without producing a final summary.';
|
||||
try {
|
||||
const finalResponse = await this.llm.chat(messages, { maxTokens: 2048 });
|
||||
summary = finalResponse.content;
|
||||
tokensUsed += finalResponse.usage?.totalTokens || 0;
|
||||
// capture the structured debrief from the limit-summary too (same contract as the clean finish)
|
||||
for (const f of this.parseFinalFindings(summary)) {
|
||||
if (!allFindings.some((x) => x.title === f.title)) allFindings.push(f);
|
||||
}
|
||||
} catch {
|
||||
// Use what we have
|
||||
}
|
||||
|
||||
const result: AgentResult = {
|
||||
success: allFindings.length > 0 || steps.some(s => s.toolResult?.success),
|
||||
summary,
|
||||
steps,
|
||||
findings: allFindings,
|
||||
iterations: this.options.maxIterations,
|
||||
tokensUsed,
|
||||
durationMs: Date.now() - startTime,
|
||||
hitLimit,
|
||||
};
|
||||
|
||||
this.emit('agent:complete', result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a single tool call via the Arsenal
|
||||
*/
|
||||
private async executeTool(
|
||||
toolCall: LLMToolCall,
|
||||
target: Target | undefined,
|
||||
iteration: number
|
||||
): Promise<AgentStep> {
|
||||
this.emit('agent:tool_call', { name: toolCall.name, args: toolCall.arguments });
|
||||
|
||||
let toolResult: ToolResult;
|
||||
try {
|
||||
toolResult = await this.arsenal.execute(toolCall.name, {
|
||||
target,
|
||||
parameters: toolCall.arguments,
|
||||
});
|
||||
} catch (err) {
|
||||
// A bad/hallucinated tool name must NOT crash the loop. Return the callable set so the
|
||||
// model self-corrects in-place instead of dying or looping on a tool that doesn't exist.
|
||||
const available = this.arsenal
|
||||
.getToolDefinitions(this.options.toolCategories.length ? this.options.toolCategories : undefined)
|
||||
.map((t) => t.name);
|
||||
toolResult = {
|
||||
success: false,
|
||||
error: `Tool "${toolCall.name}" is not available. Callable tools this run: ${available.join(', ') || '(none)'}. ` +
|
||||
`Use one of these EXACT names — do not invent tools; if none fit, describe the capability you need in prose.`,
|
||||
};
|
||||
}
|
||||
|
||||
this.emit('agent:tool_result', { name: toolCall.name, result: toolResult });
|
||||
|
||||
return {
|
||||
iteration,
|
||||
type: 'tool_call',
|
||||
toolName: toolCall.name,
|
||||
toolArgs: toolCall.arguments,
|
||||
toolResult,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the initial task prompt with target context and prior intel
|
||||
*/
|
||||
private buildTaskPrompt(task: Task, target?: Target, tools?: LLMToolDefinition[], sourceContext?: string): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
parts.push(`## MISSION TASK: ${task.name}`);
|
||||
parts.push(`**Phase**: ${task.phase} | **Priority**: ${task.priority}/10`);
|
||||
parts.push(`\n### Objective`);
|
||||
parts.push(task.description);
|
||||
|
||||
// White-box source excerpt (security-prioritized) — provided by the large-repo
|
||||
// analysis pipeline. Optional + backward-compatible: absent/empty keeps the
|
||||
// original black-box prompt. When present, give the model the real source so it
|
||||
// can ground findings in code rather than probing blind.
|
||||
if (sourceContext && sourceContext.trim().length > 0) {
|
||||
parts.push(`\n### White-box source (security-prioritized excerpt)`);
|
||||
parts.push(`The following is a security-prioritized excerpt of the target's own source code. Use it to locate and confirm vulnerabilities against the actual implementation — but only report a finding as verified when a tool result backs it.`);
|
||||
parts.push(sourceContext);
|
||||
}
|
||||
|
||||
if (target) {
|
||||
parts.push(`\n### Target Intelligence`);
|
||||
parts.push(`- **Address**: ${target.address}`);
|
||||
parts.push(`- **Type**: ${target.type} | **Zone**: ${target.zone} | **Status**: ${target.status}`);
|
||||
|
||||
// Prior recon intel: discovered services
|
||||
if (target.services?.length) {
|
||||
parts.push(`\n**Discovered Services** (from prior recon):`);
|
||||
for (const svc of target.services) {
|
||||
const version = svc.version ? ` v${svc.version}` : '';
|
||||
const banner = svc.banner ? ` [${svc.banner.slice(0, 60)}]` : '';
|
||||
parts.push(` - ${svc.port}/${svc.protocol}: ${svc.name}${version}${banner}`);
|
||||
}
|
||||
parts.push(`Use this intel to focus your efforts. These are confirmed services.`);
|
||||
}
|
||||
|
||||
// Prior vuln intel: discovered vulnerabilities
|
||||
if (target.vulnerabilities?.length) {
|
||||
parts.push(`\n**Known Vulnerabilities** (from prior scanning):`);
|
||||
for (const vuln of target.vulnerabilities.slice(0, 10)) {
|
||||
const cves = vuln.cve?.length ? ` (${vuln.cve.join(', ')})` : '';
|
||||
parts.push(` - [${vuln.severity.toUpperCase()}] ${vuln.name}${cves}`);
|
||||
if (vuln.description) parts.push(` ${vuln.description.slice(0, 200)}`);
|
||||
}
|
||||
if (target.vulnerabilities.length > 10) {
|
||||
parts.push(` ... and ${target.vulnerabilities.length - 10} more`);
|
||||
}
|
||||
parts.push(`Use this intel to prioritize your testing.`);
|
||||
}
|
||||
|
||||
// Prior credential intel
|
||||
if (target.credentials?.length) {
|
||||
parts.push(`\n**Harvested Credentials** (from prior phases):`);
|
||||
for (const cred of target.credentials.slice(0, 5)) {
|
||||
parts.push(` - ${cred.username || 'unknown'}:*** (${cred.type}) via ${cred.source}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (tools?.length) {
|
||||
parts.push(`\n### Arsenal (${tools.length} tools available)`);
|
||||
parts.push(`You have the following tools available via function calling. Select the most effective tool for each step of your assessment:`);
|
||||
// Group tools by rough function
|
||||
const toolList = tools.map(t => ` - **${t.name}**: ${t.description}`);
|
||||
parts.push(toolList.join('\n'));
|
||||
parts.push(`\n### Standing Orders`);
|
||||
parts.push(`1. Call tools via function calling — do NOT fabricate results`);
|
||||
parts.push(`2. Analyze each result before deciding the next action`);
|
||||
parts.push(`3. Report findings immediately as you discover them`);
|
||||
parts.push(`4. When finished, END your final message with a single fenced \`\`\`json block:\n` +
|
||||
` {"findings":[{"title":"…","severity":"critical|high|medium|low|info","details":"… cite the tool output that evidences it …","cvss":0.0,"cve":["…"],"remediation":"…"}],"abstained":false}\n` +
|
||||
` This block is the ONLY finding channel the harness records — anything described only in prose is dropped. Emit [] findings + "abstained":true if you found nothing real.`);
|
||||
}
|
||||
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a tool result for the LLM context
|
||||
*/
|
||||
private formatToolResult(result?: ToolResult): string {
|
||||
if (!result) return 'No result returned.';
|
||||
|
||||
const parts: string[] = [];
|
||||
parts.push(`Success: ${result.success}`);
|
||||
|
||||
if (result.output) {
|
||||
let output = result.output;
|
||||
// ALWAYS cap tool output (verbose just gets a larger budget). Previously this was gated on
|
||||
// !verboseToolOutput, which defaults TRUE — so by default nothing was capped and large tool
|
||||
// results flooded the context window. Keep HEAD+TAIL: flags/results often land at the end.
|
||||
const cap = this.options.verboseToolOutput ? this.options.maxToolOutputLength * 4 : this.options.maxToolOutputLength;
|
||||
if (output.length > cap) {
|
||||
const head = Math.floor(cap * 0.7);
|
||||
const tail = cap - head;
|
||||
output = output.slice(0, head) + `\n… (truncated ${output.length - cap} chars) …\n` + output.slice(-tail);
|
||||
}
|
||||
parts.push(output);
|
||||
}
|
||||
|
||||
if (result.error) {
|
||||
parts.push(`Error: ${result.error}`);
|
||||
}
|
||||
|
||||
if (result.findings?.length) {
|
||||
parts.push('\nFindings:');
|
||||
for (const f of result.findings) {
|
||||
parts.push(` - [${f.severity.toUpperCase()}] ${f.title}: ${f.details}`);
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the model's final debrief block into structured findings. The operator is told to
|
||||
* end its last message with a fenced ```json {findings:[…]} block; this is the ONLY prose→data
|
||||
* channel the harness honors (no substring guessing). Returns [] if there's no valid block.
|
||||
*/
|
||||
private parseFinalFindings(content: string): ToolFinding[] {
|
||||
if (!content) return [];
|
||||
const SEV = new Set(['critical', 'high', 'medium', 'low', 'info']);
|
||||
const blocks = [...content.matchAll(/```(?:json)?\s*([\s\S]*?)```/g)].map((m) => m[1]);
|
||||
const candidates = blocks.length ? blocks.reverse() : [content];
|
||||
for (const c of candidates) {
|
||||
const start = c.indexOf('{');
|
||||
const end = c.lastIndexOf('}');
|
||||
if (start === -1 || end <= start) continue;
|
||||
try {
|
||||
const obj = JSON.parse(c.slice(start, end + 1));
|
||||
if (!obj || !Array.isArray(obj.findings)) continue;
|
||||
return obj.findings.filter((f: any) => f && f.title).map((f: any) => ({
|
||||
title: String(f.title).slice(0, 200),
|
||||
severity: (SEV.has(String(f.severity).toLowerCase()) ? String(f.severity).toLowerCase() : 'info') as Severity,
|
||||
details: String(f.details ?? f.evidence ?? f.evidence_ref ?? '').slice(0, 4000),
|
||||
cvss: typeof f.cvss === 'number' ? f.cvss : undefined,
|
||||
cve: Array.isArray(f.cve) ? f.cve.map(String) : undefined,
|
||||
remediation: f.remediation ? String(f.remediation) : undefined,
|
||||
// Model-asserted in the debrief — NO tool provenance. The gate downgrades these.
|
||||
provenance: 'model' as const,
|
||||
}));
|
||||
} catch { /* try the next candidate block */ }
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// FACTORY
|
||||
// =============================================================================
|
||||
|
||||
export function createAgentLoop(
|
||||
llm: LLMBackbone,
|
||||
arsenal: Arsenal,
|
||||
options?: AgentLoopOptions
|
||||
): AgentLoop {
|
||||
return new AgentLoop(llm, arsenal, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a one-shot agent task — convenience function
|
||||
*/
|
||||
export async function runAgentTask(
|
||||
llm: LLMBackbone,
|
||||
arsenal: Arsenal,
|
||||
task: Task,
|
||||
systemPrompt: string,
|
||||
target?: Target,
|
||||
options?: AgentLoopOptions
|
||||
): Promise<AgentResult> {
|
||||
const agent = createAgentLoop(llm, arsenal, options);
|
||||
return agent.run(task, systemPrompt, target);
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
// =============================================================================
|
||||
// LOCAL AGENT CONNECTORS — "bring your own already-authed agent"
|
||||
// =============================================================================
|
||||
// Detect + connect agent CLIs that already live, authenticated, on the operator's machine
|
||||
// (Claude Code, Codex, Hermes) and drive them headlessly as t3mp3st operators.
|
||||
//
|
||||
// SECURITY POSTURE (important):
|
||||
// - We NEVER read, print, log, or transmit credential contents. Auth is detected purely by the
|
||||
// PRESENCE of the CLI's own auth artifact (a file path or a macOS keychain item) — never its bytes.
|
||||
// - We do NOT enter or store any key. The CLIs are already logged in by the user; we only invoke them.
|
||||
// - A "ping"/"dispatch" spawns the user's own CLI as a child process with a prompt and captures its
|
||||
// stdout (the model's reply to OUR prompt — not secrets). Everything is local + user-initiated.
|
||||
// =============================================================================
|
||||
|
||||
import { execFile, execFileSync, spawn } from 'child_process';
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'fs';
|
||||
import { homedir, tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
// t3mp3st injects its OWN provider keys (from .env) into the server process. If we let those leak into
|
||||
// a spawned CLI, the CLI uses t3mp3st's key instead of the user's native login → 401. The entire point
|
||||
// of this feature is "use the agent you already authed", so we strip these before spawning so each CLI
|
||||
// falls back to its own auth (keychain / ~/.codex/auth.json / ~/.hermes/.env).
|
||||
const PROVIDER_ENV_TO_STRIP = [
|
||||
'ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL', 'ANTHROPIC_MODEL',
|
||||
'OPENAI_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_API_BASE', 'OPENAI_ORGANIZATION',
|
||||
'OPENROUTER_API_KEY',
|
||||
];
|
||||
|
||||
const HOME = homedir();
|
||||
const expand = (p: string): string => (p.startsWith('~') ? HOME + p.slice(1) : p);
|
||||
|
||||
export type LocalAgentId = 'claude' | 'codex' | 'hermes';
|
||||
|
||||
interface AgentSpec {
|
||||
id: LocalAgentId;
|
||||
label: string;
|
||||
vendor: string;
|
||||
bin: string;
|
||||
blurb: string;
|
||||
/** how we drive it as a one-shot, non-interactive operator */
|
||||
invokeHint: string;
|
||||
versionArgs: string[];
|
||||
parseVersion: (out: string) => string;
|
||||
/** any-of: presence ⇒ authed. PRESENCE ONLY — contents are never read. */
|
||||
authArtifacts: string[];
|
||||
/** macOS keychain fallback service name */
|
||||
keychainService?: string;
|
||||
/** build the argv for a headless one-shot prompt */
|
||||
oneShot: (prompt: string, model?: string) => string[];
|
||||
}
|
||||
|
||||
const SPECS: AgentSpec[] = [
|
||||
{
|
||||
id: 'claude',
|
||||
label: 'Claude Code',
|
||||
vendor: 'Anthropic',
|
||||
bin: 'claude',
|
||||
blurb: 'Anthropic agentic CLI',
|
||||
invokeHint: 'claude -p "<prompt>"',
|
||||
versionArgs: ['--version'],
|
||||
parseVersion: (o) => (o.match(/[\d]+\.[\d]+(\.[\d]+)?/) || ['?'])[0],
|
||||
authArtifacts: ['~/.claude/.credentials.json', '~/.claude.json'],
|
||||
keychainService: 'Claude Code-credentials',
|
||||
oneShot: (p, m) => ['-p', p, '--output-format', 'text', ...(m ? ['--model', m] : [])],
|
||||
},
|
||||
{
|
||||
id: 'codex',
|
||||
label: 'Codex',
|
||||
vendor: 'OpenAI',
|
||||
bin: 'codex',
|
||||
blurb: 'OpenAI Codex CLI',
|
||||
invokeHint: 'codex exec "<prompt>"',
|
||||
versionArgs: ['--version'],
|
||||
parseVersion: (o) => (o.match(/[\d]+\.[\d]+(\.[\d]+)?/) || ['?'])[0],
|
||||
authArtifacts: ['~/.codex/auth.json', '~/.config/codex/auth.json'],
|
||||
oneShot: (p, m) => ['exec', ...(m ? ['-m', m] : []), p],
|
||||
},
|
||||
{
|
||||
id: 'hermes',
|
||||
label: 'Hermes',
|
||||
vendor: 'Hermes Agent',
|
||||
bin: 'hermes',
|
||||
blurb: 'Hermes Agent — tool-calling AI',
|
||||
invokeHint: 'hermes -z "<prompt>" (--yolo only if T3MP3ST_HERMES_YOLO=1)',
|
||||
versionArgs: ['--version'],
|
||||
parseVersion: (o) => (o.match(/v([\d]+\.[\d]+(\.[\d]+)?)/)?.[1]) || (o.match(/[\d]+\.[\d]+(\.[\d]+)?/) || ['?'])[0],
|
||||
authArtifacts: ['~/.hermes/.env'],
|
||||
oneShot: (p, m) => ['-z', p, ...(hermesYoloEnabled() ? ['--yolo'] : []), ...(m ? ['-m', m] : [])],
|
||||
},
|
||||
];
|
||||
|
||||
export function getSpec(id: string): AgentSpec | undefined {
|
||||
return SPECS.find((s) => s.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* B-06 — Hermes '--yolo' auto-approves EVERY tool call with no confirmation:
|
||||
* powerful but dangerous (unattended command exec with no gate). It is OFF by
|
||||
* default; the operator must explicitly opt in with T3MP3ST_HERMES_YOLO=1. Safe
|
||||
* mode (the default) runs Hermes without the flag so it honors its own approval
|
||||
* prompts. Applies to both the one-shot backbone call and the chat path.
|
||||
*/
|
||||
export function hermesYoloEnabled(): boolean {
|
||||
return /^(1|true|yes|on)$/i.test((process.env.T3MP3ST_HERMES_YOLO || '').trim());
|
||||
}
|
||||
|
||||
export interface AgentDetection {
|
||||
id: LocalAgentId;
|
||||
label: string;
|
||||
vendor: string;
|
||||
bin: string;
|
||||
blurb: string;
|
||||
invokeHint: string;
|
||||
installed: boolean;
|
||||
path?: string;
|
||||
version?: string;
|
||||
authed: boolean;
|
||||
authMethod?: string; // e.g. "file" or "keychain" — never the secret itself
|
||||
ready: boolean; // installed && authed
|
||||
}
|
||||
|
||||
function resolvePath(bin: string): string | undefined {
|
||||
try {
|
||||
return execFileSync('command', ['-v', bin], { shell: '/bin/bash', encoding: 'utf8' }).trim() || undefined;
|
||||
} catch {
|
||||
try { return execFileSync('which', [bin], { encoding: 'utf8' }).trim() || undefined; } catch { return undefined; }
|
||||
}
|
||||
}
|
||||
|
||||
function authState(spec: AgentSpec): { authed: boolean; method?: string } {
|
||||
for (const a of spec.authArtifacts) {
|
||||
if (existsSync(expand(a))) return { authed: true, method: 'file' };
|
||||
}
|
||||
if (spec.keychainService) {
|
||||
try {
|
||||
execFileSync('security', ['find-generic-password', '-s', spec.keychainService], { stdio: 'ignore' });
|
||||
return { authed: true, method: 'keychain' };
|
||||
} catch { /* not in keychain */ }
|
||||
}
|
||||
return { authed: false };
|
||||
}
|
||||
|
||||
function detectOne(spec: AgentSpec): Promise<AgentDetection> {
|
||||
const base = {
|
||||
id: spec.id, label: spec.label, vendor: spec.vendor, bin: spec.bin,
|
||||
blurb: spec.blurb, invokeHint: spec.invokeHint,
|
||||
};
|
||||
return new Promise((resolve) => {
|
||||
execFile(spec.bin, spec.versionArgs, { timeout: 8000 }, (err, stdout) => {
|
||||
if (err && (err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
resolve({ ...base, installed: false, authed: false, ready: false });
|
||||
return;
|
||||
}
|
||||
// even a non-zero exit but no ENOENT means the binary exists
|
||||
const version = spec.parseVersion(String(stdout || ''));
|
||||
const auth = authState(spec);
|
||||
resolve({
|
||||
...base,
|
||||
installed: true,
|
||||
path: resolvePath(spec.bin),
|
||||
version,
|
||||
authed: auth.authed,
|
||||
authMethod: auth.method,
|
||||
ready: auth.authed,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Detect every known local agent CLI (installed? authed? ready?). No tokens spent. */
|
||||
export async function detectLocalAgents(): Promise<AgentDetection[]> {
|
||||
// Test/CI hook: T3MP3ST_DISABLE_LOCAL_AGENTS=1 forces a backbone-less server (skip
|
||||
// local-agent auto-detection) so key-required / fail-closed paths can be exercised
|
||||
// deterministically — used by scripts/arsenal-smoke.mjs for a reproducible run.
|
||||
if (/^(1|true|yes|on)$/i.test((process.env.T3MP3ST_DISABLE_LOCAL_AGENTS || '').trim())) return [];
|
||||
return Promise.all(SPECS.map(detectOne));
|
||||
}
|
||||
|
||||
export interface AgentRunResult {
|
||||
ok: boolean;
|
||||
latencyMs: number;
|
||||
output: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive a connected agent with a one-shot headless prompt (real round-trip — SPENDS the agent's quota).
|
||||
* Used by /ping (liveness proof) and /dispatch (actually using the operator).
|
||||
*/
|
||||
export function runLocalAgent(
|
||||
id: string,
|
||||
prompt: string,
|
||||
opts: { model?: string; timeoutMs?: number; maxChars?: number } = {},
|
||||
): Promise<AgentRunResult> {
|
||||
const spec = getSpec(id);
|
||||
const t0 = Date.now();
|
||||
if (!spec) return Promise.resolve({ ok: false, latencyMs: 0, output: '', error: `unknown agent: ${id}` });
|
||||
const args = spec.oneShot(prompt, opts.model);
|
||||
const timeoutMs = opts.timeoutMs ?? 120000;
|
||||
const maxChars = opts.maxChars ?? 4000;
|
||||
// child env = ours minus the injected provider keys, so the CLI uses its OWN native login.
|
||||
const env: NodeJS.ProcessEnv = { ...process.env };
|
||||
for (const k of PROVIDER_ENV_TO_STRIP) delete env[k];
|
||||
return new Promise((resolve) => {
|
||||
// stdin:'ignore' so the agent doesn't stall waiting on piped input (e.g. `claude -p`'s 3s stdin wait).
|
||||
const child = spawn(spec.bin, args, { env, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let out = '';
|
||||
let errOut = '';
|
||||
let done = false;
|
||||
const finish = (r: AgentRunResult): void => { if (!done) { done = true; clearTimeout(timer); resolve(r); } };
|
||||
const timer = setTimeout(() => {
|
||||
try { child.kill('SIGKILL'); } catch { /* noop */ }
|
||||
finish({ ok: false, latencyMs: Date.now() - t0, output: out.trim().slice(0, maxChars), error: `timed out after ${timeoutMs}ms` });
|
||||
}, timeoutMs);
|
||||
child.stdout?.on('data', (d) => { if (out.length < 1_000_000) out += String(d); });
|
||||
child.stderr?.on('data', (d) => { if (errOut.length < 100_000) errOut += String(d); });
|
||||
child.on('error', (e) => finish({ ok: false, latencyMs: Date.now() - t0, output: '', error: (e as Error).message }));
|
||||
child.on('close', (code) => {
|
||||
const latencyMs = Date.now() - t0;
|
||||
const output = out.trim().slice(0, maxChars);
|
||||
if (code === 0) finish({ ok: true, latencyMs, output });
|
||||
else finish({ ok: false, latencyMs, output, error: (errOut.trim() || `exited with code ${code}`).slice(0, 300) });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** A cheap liveness probe — asks the agent to echo a token. SPENDS a tiny bit of the agent's quota. */
|
||||
export function pingLocalAgent(id: string, prompt?: string, timeoutMs?: number): Promise<AgentRunResult> {
|
||||
return runLocalAgent(id, prompt || 'Reply with exactly the single word: PONG', {
|
||||
timeoutMs: timeoutMs ?? 90000,
|
||||
maxChars: 400,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive a connected agent as the LLM BACKEND for the mission/operator flow — a long-prompt one-shot
|
||||
* that returns ONLY the model's reply text (no CLI banner). Per-agent invocation: claude/codex feed
|
||||
* the prompt via STDIN (robust for long planning prompts), codex uses --output-last-message for a
|
||||
* clean reply, hermes takes the prompt as an arg. Provider keys are stripped so each CLI uses its own
|
||||
* login (no API key needed). Throws on non-zero exit / timeout so the LLMBackbone retry/fallback fires.
|
||||
*/
|
||||
export function localAgentChat(id: string, prompt: string, opts: { model?: string; timeoutMs?: number } = {}): Promise<string> {
|
||||
const spec = getSpec(id);
|
||||
if (!spec) return Promise.reject(new Error(`unknown local agent: ${id}`));
|
||||
const env: NodeJS.ProcessEnv = { ...process.env };
|
||||
for (const k of PROVIDER_ENV_TO_STRIP) delete env[k];
|
||||
const model = opts.model && opts.model !== 'codex-default' && opts.model !== id ? opts.model : undefined;
|
||||
const timeoutMs = opts.timeoutMs ?? 240000;
|
||||
|
||||
let args: string[];
|
||||
let viaStdin = true;
|
||||
let outFile: string | null = null;
|
||||
let workDir: string | null = null;
|
||||
if (id === 'claude') {
|
||||
args = ['-p', '--output-format', 'text', ...(model ? ['--model', model] : [])];
|
||||
} else if (id === 'codex') {
|
||||
workDir = mkdtempSync(join(tmpdir(), 't3mp3st-codexllm-'));
|
||||
outFile = join(workDir, 'reply.txt');
|
||||
args = ['exec', '--skip-git-repo-check', '--color', 'never', '--sandbox', 'read-only', '--output-last-message', outFile, ...(model ? ['-m', model] : [])];
|
||||
} else { // hermes — takes the prompt as an arg
|
||||
args = ['-z', prompt, ...(hermesYoloEnabled() ? ['--yolo'] : []), ...(model ? ['-m', model] : [])];
|
||||
viaStdin = false;
|
||||
}
|
||||
|
||||
const cleanup = () => { if (workDir) { try { rmSync(workDir, { recursive: true, force: true }); } catch { /* noop */ } } };
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(spec.bin, args, { env, stdio: [viaStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'] });
|
||||
let out = '';
|
||||
let errOut = '';
|
||||
let done = false;
|
||||
const finish = (fn: () => void) => { if (!done) { done = true; clearTimeout(timer); cleanup(); fn(); } };
|
||||
const timer = setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* noop */ } finish(() => reject(new Error(`${id} timed out after ${timeoutMs}ms`))); }, timeoutMs);
|
||||
child.stdout?.on('data', (d) => { if (out.length < 8_000_000) out += String(d); });
|
||||
child.stderr?.on('data', (d) => { if (errOut.length < 200_000) errOut += String(d); });
|
||||
child.on('error', (e) => finish(() => reject(e)));
|
||||
child.on('close', (code) => {
|
||||
let content = out.trim();
|
||||
if (outFile) { try { content = (readFileSync(outFile, 'utf8').trim() || content); } catch { /* fall back to stdout */ } }
|
||||
finish(() => {
|
||||
if (code === 0 && content) resolve(content);
|
||||
else reject(new Error((errOut.trim() || content || `exited with code ${code}`).slice(0, 800)));
|
||||
});
|
||||
});
|
||||
if (viaStdin && child.stdin) { child.stdin.write(prompt); child.stdin.end(); }
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user