feat(scan): add live progress and recon bootstrap (#81)
* Add live scan progress view * Make local-agent recon report real tool progress * Feed recon bootstrap into local agent ReAct loop * Remove stale local-agent JSON tool parser * Label backend-seeded scan progress --------- Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
1 parent
8959a288e5
commit
089591b18f
6 files changed
+476
-11
No files matched your search
+70
-4
@@ -71,8 +71,8 @@ export interface AgentResult {
|
||||
|
||||
export interface AgentEvents {
|
||||
'agent:step': AgentStep;
|
||||
'agent:tool_call': { name: string; args: Record<string, unknown> };
|
||||
'agent:tool_result': { name: string; result: ToolResult };
|
||||
'agent:tool_call': { name: string; args: Record<string, unknown>; source?: 'agent' | 'backend_seeded' };
|
||||
'agent:tool_result': { name: string; result: ToolResult; source?: 'agent' | 'backend_seeded' };
|
||||
'agent:thinking': { content: string };
|
||||
'agent:complete': AgentResult;
|
||||
'agent:error': { error: Error; step: number };
|
||||
@@ -133,6 +133,39 @@ export class AgentLoop extends EventEmitter<AgentEvents> {
|
||||
{ role: 'user', content: this.buildTaskPrompt(task, target, toolDefs, sourceContext, sharedContext) },
|
||||
];
|
||||
|
||||
const bootstrapCall = this.getLocalReconBootstrap(task, target, toolDefs);
|
||||
if (bootstrapCall) {
|
||||
const toolStep = await this.executeTool(bootstrapCall, target, -1);
|
||||
steps.push(toolStep);
|
||||
seenCalls.set(
|
||||
`${bootstrapCall.name}:${JSON.stringify(bootstrapCall.arguments || {})}`,
|
||||
String(toolStep.toolResult?.output || toolStep.toolResult?.error || 'no output').replace(/\s+/g, ' ').slice(0, 160)
|
||||
);
|
||||
|
||||
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: bootstrapCall.name, toolOutput: out || tf.details });
|
||||
}
|
||||
}
|
||||
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
content: 'Running an initial low-noise recon check before planning deeper actions.',
|
||||
toolCalls: [bootstrapCall],
|
||||
});
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
content: this.formatToolResult(toolStep.toolResult),
|
||||
toolCallId: bootstrapCall.id,
|
||||
name: bootstrapCall.name,
|
||||
});
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: 'Use the bootstrap observation above as your first tool observation. Continue through the normal ACTION CONTRACT: request another listed tool only if it will materially improve the evidence; otherwise provide the final recon debrief.',
|
||||
});
|
||||
}
|
||||
|
||||
for (let i = 0; i < this.options.maxIterations; i++) {
|
||||
try {
|
||||
// Ask the LLM what to do next
|
||||
@@ -298,6 +331,38 @@ export class AgentLoop extends EventEmitter<AgentEvents> {
|
||||
return result;
|
||||
}
|
||||
|
||||
private getLocalReconBootstrap(
|
||||
task: Task,
|
||||
target: Target | undefined,
|
||||
tools: LLMToolDefinition[]
|
||||
): LLMToolCall | undefined {
|
||||
if (this.llm.getProvider() !== 'local-agent') return undefined;
|
||||
if (!target?.address) return undefined;
|
||||
if (task.phase !== 'reconnaissance' && task.operatorType !== 'recon') return undefined;
|
||||
|
||||
const hasTool = (name: string) => tools.some((tool) => tool.name === name);
|
||||
const taskName = task.name.toLowerCase();
|
||||
const id = `local-agent-bootstrap-${Date.now()}`;
|
||||
|
||||
if (taskName.includes('dns') && hasTool('dns_lookup')) {
|
||||
return { id, name: 'dns_lookup', arguments: { domain: target.address, type: 'A' } };
|
||||
}
|
||||
|
||||
if (taskName.includes('port') && hasTool('port_scan')) {
|
||||
return { id, name: 'port_scan', arguments: { target: target.address, ports: '80,443', timeout: 2000 } };
|
||||
}
|
||||
|
||||
if ((taskName.includes('web') || taskName.includes('fingerprint')) && hasTool('header_analysis')) {
|
||||
return { id, name: 'header_analysis', arguments: { url: `https://${target.address}` } };
|
||||
}
|
||||
|
||||
if ((taskName.includes('content') || taskName.includes('discover')) && hasTool('robots_txt_fetch')) {
|
||||
return { id, name: 'robots_txt_fetch', arguments: { url: `https://${target.address}` } };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a single tool call via the Arsenal
|
||||
*/
|
||||
@@ -306,7 +371,8 @@ export class AgentLoop extends EventEmitter<AgentEvents> {
|
||||
target: Target | undefined,
|
||||
iteration: number
|
||||
): Promise<AgentStep> {
|
||||
this.emit('agent:tool_call', { name: toolCall.name, args: toolCall.arguments });
|
||||
const source = iteration < 0 ? 'backend_seeded' : 'agent';
|
||||
this.emit('agent:tool_call', { name: toolCall.name, args: toolCall.arguments, source });
|
||||
|
||||
let toolResult: ToolResult;
|
||||
try {
|
||||
@@ -330,7 +396,7 @@ export class AgentLoop extends EventEmitter<AgentEvents> {
|
||||
};
|
||||
}
|
||||
|
||||
this.emit('agent:tool_result', { name: toolCall.name, result: toolResult });
|
||||
this.emit('agent:tool_result', { name: toolCall.name, result: toolResult, source });
|
||||
|
||||
return {
|
||||
iteration,
|
||||
|
||||
+100
@@ -212,6 +212,8 @@ import type {
|
||||
OperatorArchetype,
|
||||
CommandEvents,
|
||||
Finding,
|
||||
ScanProgressEvent,
|
||||
Task,
|
||||
} from './types/index.js';
|
||||
|
||||
// Re-export commonly used types
|
||||
@@ -271,6 +273,7 @@ import {
|
||||
|
||||
const DEFAULT_AGENT_MAX_ITERATIONS = 15;
|
||||
const LOCAL_AGENT_MAX_ITERATIONS = Number(process.env.T3MP3ST_LOCAL_AGENT_MAX_ITERATIONS || 30);
|
||||
const MAX_PROGRESS_EVENTS = 300;
|
||||
|
||||
/**
|
||||
* TEMPEST Command - Main orchestration controller
|
||||
@@ -544,6 +547,40 @@ export class TempestCommand extends EventEmitter<CommandEvents> {
|
||||
* Setup event forwarding for an operator
|
||||
*/
|
||||
private setupOperatorEvents(operator: OperatorAgent): void {
|
||||
operator.on('task:started', ({ task }) => {
|
||||
this.recordProgress('task_started', operator, task, `Started ${task.name}`);
|
||||
});
|
||||
|
||||
operator.on('task:completed', ({ task, result }) => {
|
||||
const findings = result.findings?.length ? ` Findings: ${result.findings.join(', ')}` : '';
|
||||
this.recordProgress(
|
||||
'task_completed',
|
||||
operator,
|
||||
task,
|
||||
`${result.success === false ? 'Finished unsuccessfully' : 'Completed'} ${task.name}.${findings}`,
|
||||
{ success: result.success !== false }
|
||||
);
|
||||
});
|
||||
|
||||
operator.on('task:failed', ({ task, error }) => {
|
||||
this.recordProgress('task_failed', operator, task, error, { success: false });
|
||||
});
|
||||
|
||||
operator.on('agent:thinking', ({ task, content }) => {
|
||||
this.recordProgress('thinking', operator, task, content);
|
||||
});
|
||||
|
||||
operator.on('agent:tool_call', ({ task, name, args, source }) => {
|
||||
this.recordProgress('tool_call', operator, task, `${name} ${JSON.stringify(args || {})}`, { toolName: name, source });
|
||||
});
|
||||
|
||||
operator.on('agent:tool_result', ({ task, name, result, source }) => {
|
||||
const detail = result.success
|
||||
? (result.output || 'Tool completed')
|
||||
: (result.error || result.output || 'Tool failed');
|
||||
this.recordProgress('tool_result', operator, task, detail, { toolName: name, success: result.success, source });
|
||||
});
|
||||
|
||||
operator.on('finding:discovered', ({ finding }) => {
|
||||
this.vault.addFinding(finding);
|
||||
this.emit('finding:discovered', { finding, operatorId: operator.id });
|
||||
@@ -628,6 +665,34 @@ export class TempestCommand extends EventEmitter<CommandEvents> {
|
||||
});
|
||||
}
|
||||
|
||||
private recordProgress(
|
||||
kind: ScanProgressEvent['kind'],
|
||||
operator: OperatorAgent,
|
||||
task: Task | undefined,
|
||||
detail: string,
|
||||
extra: Partial<Pick<ScanProgressEvent, 'toolName' | 'success' | 'source'>> = {}
|
||||
): void {
|
||||
const compact = String(detail || '').replace(/\s+/g, ' ').trim().slice(0, 2000);
|
||||
const event: ScanProgressEvent = {
|
||||
id: `progress-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
timestamp: Date.now(),
|
||||
kind,
|
||||
operatorId: operator.id,
|
||||
callsign: operator.callsign,
|
||||
archetype: operator.archetype,
|
||||
taskId: task?.id,
|
||||
taskName: task?.name,
|
||||
detail: compact,
|
||||
...extra,
|
||||
};
|
||||
|
||||
this.progressEvents.push(event);
|
||||
if (this.progressEvents.length > MAX_PROGRESS_EVENTS) {
|
||||
this.progressEvents.splice(0, this.progressEvents.length - MAX_PROGRESS_EVENTS);
|
||||
}
|
||||
this.emit('scan:progress', event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a finding and update the target's services/vulnerabilities.
|
||||
* This is the intelligence pipeline that feeds data between phases.
|
||||
@@ -826,6 +891,8 @@ export class TempestCommand extends EventEmitter<CommandEvents> {
|
||||
* CLI work; it only fires on truly-hung dispatches. Override via
|
||||
* T3MP3ST_TASK_TIMEOUT_MS.
|
||||
*/
|
||||
private progressEvents: ScanProgressEvent[] = [];
|
||||
|
||||
/**
|
||||
* Resolve the dispatch timeout from the environment, falling back to the
|
||||
* provider-specific default. Guards against a non-numeric / non-positive override.
|
||||
@@ -951,6 +1018,7 @@ export class TempestCommand extends EventEmitter<CommandEvents> {
|
||||
|
||||
// Auto-spawn an operator if none exists for this archetype
|
||||
if (!operator) {
|
||||
if (this.llm.getProvider() === 'local-agent') continue;
|
||||
const allOps = this.cell.getAllOperators();
|
||||
const archetypeCount = allOps.filter(op => op.archetype === task.operatorType).length;
|
||||
// Spawn up to 3 operators per archetype for parallelism
|
||||
@@ -1099,6 +1167,9 @@ export class TempestCommand extends EventEmitter<CommandEvents> {
|
||||
* Auto-spawn operators needed for a given kill chain phase
|
||||
*/
|
||||
private autoSpawnForPhase(phase: KillChainPhase): void {
|
||||
if (this.llm.getProvider() === 'local-agent') {
|
||||
return;
|
||||
}
|
||||
const phaseOperators: Record<string, OperatorArchetype[]> = {
|
||||
[KillChainPhase.RECON]: ['recon'],
|
||||
[KillChainPhase.WEAPONIZE]: ['scanner'],
|
||||
@@ -1139,6 +1210,7 @@ export class TempestCommand extends EventEmitter<CommandEvents> {
|
||||
this.on('detection:triggered', (data) => broadcast('detection', data));
|
||||
this.on('mission:phase_changed', (data) => broadcast('phase_changed', data));
|
||||
this.on('approval:decision', (data) => broadcast('arsenal.approval', data));
|
||||
this.on('scan:progress', (data) => broadcast('scan:progress', data));
|
||||
this.on('tick', (count) => {
|
||||
// Broadcast status every 5 ticks to avoid flooding
|
||||
if (typeof count === 'number' && count % 5 === 0) {
|
||||
@@ -1232,8 +1304,19 @@ export class TempestCommand extends EventEmitter<CommandEvents> {
|
||||
opsec: ReturnType<OpsecController['getStats']>;
|
||||
activeMission: string | null;
|
||||
stallReason: string | null;
|
||||
progress: ScanProgressEvent[];
|
||||
tasks: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
phase: string;
|
||||
status: string;
|
||||
operatorType: string;
|
||||
assignedTo?: string;
|
||||
result?: { success: boolean; output?: string; error?: string; findings?: string[] };
|
||||
}>;
|
||||
} {
|
||||
const activeMission = this.mission.getActiveMission();
|
||||
const taskQueue = this.mission.getTaskQueue();
|
||||
|
||||
return {
|
||||
name: this.name,
|
||||
@@ -1246,6 +1329,23 @@ export class TempestCommand extends EventEmitter<CommandEvents> {
|
||||
opsec: this.opsec.getStats(),
|
||||
activeMission: activeMission?.id || null,
|
||||
stallReason: this.stallReason,
|
||||
progress: [...this.progressEvents],
|
||||
tasks: activeMission
|
||||
? taskQueue.getForMission(activeMission.id).map(task => ({
|
||||
id: task.id,
|
||||
name: task.name,
|
||||
phase: task.phase,
|
||||
status: task.status,
|
||||
operatorType: task.operatorType,
|
||||
assignedTo: task.assignedTo,
|
||||
result: task.result ? {
|
||||
success: task.result.success,
|
||||
output: task.result.output,
|
||||
error: task.result.error,
|
||||
findings: task.result.findings,
|
||||
} : undefined,
|
||||
}))
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+36
-5
@@ -22,6 +22,7 @@ import {
|
||||
import type { LLMBackbone } from '../llm/index.js';
|
||||
import type { Arsenal } from '../arsenal/index.js';
|
||||
import type { AgentLoop } from '../agent/index.js';
|
||||
import type { ToolResult } from '../types/index.js';
|
||||
import type { Target } from '../types/index.js';
|
||||
import { OPERATOR_SYSTEM_PROMPTS } from '../prompts/index.js';
|
||||
import { gateLiveFinding } from '../evidence/gate.js';
|
||||
@@ -36,6 +37,9 @@ export interface OperatorEvents {
|
||||
'task:completed': { task: Task; result: TaskResult };
|
||||
'task:failed': { task: Task; error: string };
|
||||
'task:decomposed': { parent: Task; subtasks: Task[]; reason: string };
|
||||
'agent:thinking': { task: Task; content: string };
|
||||
'agent:tool_call': { task: Task; name: string; args: Record<string, unknown>; source?: 'agent' | 'backend_seeded' };
|
||||
'agent:tool_result': { task: Task; name: string; result: ToolResult; source?: 'agent' | 'backend_seeded' };
|
||||
'finding:discovered': { finding: Finding };
|
||||
'finding:gate-blocked': { finding: Finding; reasons: string[] };
|
||||
'credential:harvested': { credential: Credential };
|
||||
@@ -422,11 +426,38 @@ export class OperatorAgent extends EventEmitter<OperatorEvents> {
|
||||
// Pass the white-box source excerpt (if any) so the model sees the target's
|
||||
// real source alongside the task; empty string keeps black-box behavior.
|
||||
if (this.agentLoop && this.llm) {
|
||||
// [Phase-2] Register as live on the board and pull the shared situation report so this
|
||||
// operator sees teammates' verified leads/claims — it builds on them instead of running blind.
|
||||
this.board?.heartbeat(this.id, 'hunting', task.name);
|
||||
const sharedContext = this.board?.situationReport(this.id);
|
||||
const result = await this.agentLoop.run(task, this.profile.systemPrompt, target, this.whiteboxSource, sharedContext);
|
||||
const onThinking = ({ content }: { content: string }): void => {
|
||||
this.emit('agent:thinking', { task, content });
|
||||
};
|
||||
const onToolCall = ({ name, args, source }: { name: string; args: Record<string, unknown>; source?: 'agent' | 'backend_seeded' }): void => {
|
||||
this.emit('agent:tool_call', { task, name, args, source });
|
||||
};
|
||||
const onToolResult = ({ name, result, source }: { name: string; result: ToolResult; source?: 'agent' | 'backend_seeded' }): void => {
|
||||
this.emit('agent:tool_result', { task, name, result, source });
|
||||
};
|
||||
const canForwardAgentEvents =
|
||||
typeof this.agentLoop.on === 'function' &&
|
||||
typeof this.agentLoop.off === 'function';
|
||||
if (canForwardAgentEvents) {
|
||||
this.agentLoop.on('agent:thinking', onThinking);
|
||||
this.agentLoop.on('agent:tool_call', onToolCall);
|
||||
this.agentLoop.on('agent:tool_result', onToolResult);
|
||||
}
|
||||
|
||||
let result;
|
||||
try {
|
||||
// [Phase-2] Register as live on the board and pull the shared situation report so this
|
||||
// operator sees teammates' verified leads/claims — it builds on them instead of running blind.
|
||||
this.board?.heartbeat(this.id, 'hunting', task.name);
|
||||
const sharedContext = this.board?.situationReport(this.id);
|
||||
result = await this.agentLoop.run(task, this.profile.systemPrompt, target, this.whiteboxSource, sharedContext);
|
||||
} finally {
|
||||
if (canForwardAgentEvents) {
|
||||
this.agentLoop.off('agent:thinking', onThinking);
|
||||
this.agentLoop.off('agent:tool_call', onToolCall);
|
||||
this.agentLoop.off('agent:tool_result', onToolResult);
|
||||
}
|
||||
}
|
||||
|
||||
// Convert agent findings to operator findings.
|
||||
// PROVENANCE-HONEST: only a tool-backed finding gets tool-output evidence (the raw
|
||||
|
||||
+3
-1
@@ -6293,7 +6293,7 @@ app.post('/api/mission/resume', (_req: Request, res: Response) => {
|
||||
app.get('/api/mission/status', (_req: Request, res: Response) => {
|
||||
const cmd = getTempestCommand();
|
||||
if (!cmd) {
|
||||
res.json({ active: false });
|
||||
res.json({ active: false, progress: [], tasks: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -6323,6 +6323,8 @@ app.get('/api/mission/status', (_req: Request, res: Response) => {
|
||||
targets: status.targets,
|
||||
vault: status.vault,
|
||||
opsec: status.opsec,
|
||||
tasks: status.tasks,
|
||||
progress: status.progress,
|
||||
findings: findings.map(f => ({
|
||||
id: f.id,
|
||||
title: f.title,
|
||||
|
||||
@@ -534,6 +534,21 @@ export interface Appendix {
|
||||
// EVENT TYPES
|
||||
// =============================================================================
|
||||
|
||||
export interface ScanProgressEvent {
|
||||
id: string;
|
||||
timestamp: number;
|
||||
kind: 'task_started' | 'thinking' | 'tool_call' | 'tool_result' | 'task_completed' | 'task_failed';
|
||||
operatorId: string;
|
||||
callsign: string;
|
||||
archetype: OperatorArchetype;
|
||||
taskId?: string;
|
||||
taskName?: string;
|
||||
toolName?: string;
|
||||
source?: 'agent' | 'backend_seeded';
|
||||
detail: string;
|
||||
success?: boolean;
|
||||
}
|
||||
|
||||
export interface CommandEvents {
|
||||
'command:started': void;
|
||||
'command:stopped': void;
|
||||
@@ -547,6 +562,7 @@ export interface CommandEvents {
|
||||
'target:owned': { target: Target; operatorId: string };
|
||||
'detection:triggered': DetectionEvent;
|
||||
'mission:phase_changed': { missionId: string; phase: KillChainPhase };
|
||||
'scan:progress': ScanProgressEvent;
|
||||
'abort:recommended': string;
|
||||
/** A capability-approval gate decision (allowed/denied) on an intrusive/dangerous tool — bridged to
|
||||
* the dashboard's live approval/audit feed. Structural match for arsenal/approval.ts ApprovalRecord. */
|
||||
|
||||
Reference in new issue
Block a user