Merge remote-tracking branch 'upstream/main' into pr-45
# Conflicts: # src/config/index.ts # src/types/index.ts
This commit is contained in:
93 files changed
+9379
-649
No files matched your search
+148
-14
@@ -209,9 +209,12 @@ import type {
|
||||
TempestConfig,
|
||||
LLMConfig,
|
||||
RuntimeHooks,
|
||||
LLMProvider,
|
||||
OperatorArchetype,
|
||||
CommandEvents,
|
||||
Finding,
|
||||
ScanProgressEvent,
|
||||
Task,
|
||||
} from './types/index.js';
|
||||
|
||||
// Re-export commonly used types
|
||||
@@ -269,6 +272,10 @@ import {
|
||||
// TEMPEST COMMAND
|
||||
// =============================================================================
|
||||
|
||||
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
|
||||
*/
|
||||
@@ -323,6 +330,7 @@ export class TempestCommand extends EventEmitter<CommandEvents> {
|
||||
private tickInterval: NodeJS.Timeout | null = null;
|
||||
private tickCount: number = 0;
|
||||
private hooks: RuntimeHooks;
|
||||
private readonly taskTimeoutMs: number;
|
||||
|
||||
/**
|
||||
* White-box source context (security-prioritized code excerpt), set by the
|
||||
@@ -357,6 +365,7 @@ export class TempestCommand extends EventEmitter<CommandEvents> {
|
||||
super();
|
||||
this.name = config.name;
|
||||
this.hooks = config.hooks || {};
|
||||
this.taskTimeoutMs = TempestCommand.resolveTaskTimeoutMs(config.llm.provider);
|
||||
|
||||
// Initialize LLM backbone
|
||||
this.llm = new LLMBackbone(config.llm);
|
||||
@@ -539,6 +548,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 });
|
||||
@@ -623,6 +666,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.
|
||||
@@ -787,6 +858,7 @@ export class TempestCommand extends EventEmitter<CommandEvents> {
|
||||
public resume(): void {
|
||||
if (!this.running || !this.paused) return;
|
||||
this.paused = false;
|
||||
this.stallReason = null;
|
||||
this.emit('command:resumed');
|
||||
}
|
||||
|
||||
@@ -815,29 +887,34 @@ export class TempestCommand extends EventEmitter<CommandEvents> {
|
||||
* GENEROUS per-dispatch wall-clock backstop (ms). If a single task dispatch stays
|
||||
* in-flight longer than this, the tick loop force-resolves it as a timeout so
|
||||
* pendingOrActive can reach 0 and the mission can advance/complete even when an
|
||||
* operator promise wedges. Deliberately large (default 5 min) so it NEVER kills a
|
||||
* legitimately-slow-but-working LLM task (e.g. opus planning ~165s); it only fires
|
||||
* on truly-hung dispatches. Override via T3MP3ST_TASK_TIMEOUT_MS.
|
||||
* operator promise wedges. Deliberately large (default 5 min for API models,
|
||||
* 30 min for local-agent backends) so it does not kill legitimately slow local
|
||||
* CLI work; it only fires on truly-hung dispatches. Override via
|
||||
* T3MP3ST_TASK_TIMEOUT_MS.
|
||||
*/
|
||||
private readonly taskTimeoutMs: number = TempestCommand.resolveTaskTimeoutMs();
|
||||
private progressEvents: ScanProgressEvent[] = [];
|
||||
|
||||
/**
|
||||
* Resolve the dispatch timeout from the environment, falling back to the 5-minute
|
||||
* default. Guards against a non-numeric / non-positive override.
|
||||
* Resolve the dispatch timeout from the environment, falling back to the
|
||||
* provider-specific default. Guards against a non-numeric / non-positive override.
|
||||
*/
|
||||
private static resolveTaskTimeoutMs(): number {
|
||||
private static resolveTaskTimeoutMs(provider?: LLMProvider): number {
|
||||
const DEFAULT_TASK_TIMEOUT_MS = 300000; // 5 minutes — generous backstop, not a deadline
|
||||
const LOCAL_AGENT_TASK_TIMEOUT_MS = 1800000; // local CLI agents can need multiple slow turns
|
||||
const raw = process.env.T3MP3ST_TASK_TIMEOUT_MS;
|
||||
if (raw != null && raw.trim() !== '') {
|
||||
const parsed = Number(raw);
|
||||
if (Number.isFinite(parsed) && parsed > 0) return parsed;
|
||||
}
|
||||
return DEFAULT_TASK_TIMEOUT_MS;
|
||||
return provider === 'local-agent' ? LOCAL_AGENT_TASK_TIMEOUT_MS : DEFAULT_TASK_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
/** Track whether we've seeded initial tasks for the current mission */
|
||||
private taskSeeded: boolean = false;
|
||||
|
||||
/** Human-readable reason a mission was paused because required work failed. */
|
||||
private stallReason: string | null = null;
|
||||
|
||||
/**
|
||||
* Main tick loop — seeds tasks, dispatches to operators, advances phases
|
||||
*/
|
||||
@@ -890,13 +967,28 @@ export class TempestCommand extends EventEmitter<CommandEvents> {
|
||||
);
|
||||
const inFlight = allMissionTasks.filter(t => this.activeDispatches.has(t.id));
|
||||
|
||||
// If we have tasks, all are done, and nothing is in-flight → advance phase
|
||||
// If we have tasks, all are done, and nothing is in-flight → advance phase.
|
||||
// Failed required tasks are terminal, but they are not successful progress.
|
||||
// Stall instead of walking the phase bar forward with no backend/model work.
|
||||
if (allMissionTasks.length > 0 && pendingOrActive.length === 0 && inFlight.length === 0) {
|
||||
const failedCurrentPhase = allMissionTasks.filter(
|
||||
t => t.phase === mission.currentPhase && t.status === 'failed'
|
||||
);
|
||||
if (failedCurrentPhase.length > 0) {
|
||||
const firstError = failedCurrentPhase[0].result?.error;
|
||||
this.stallReason = `stalled in ${mission.currentPhase}: ${failedCurrentPhase.length} required task(s) failed` +
|
||||
(firstError ? ` — ${firstError}` : '');
|
||||
this.paused = true;
|
||||
this.emit('command:paused');
|
||||
return;
|
||||
}
|
||||
|
||||
const phaseIndex = mission.phases.indexOf(mission.currentPhase);
|
||||
if (phaseIndex === -1) return; // Guard: phase not found (race condition)
|
||||
if (phaseIndex < mission.phases.length - 1) {
|
||||
// Advance to next phase and generate tasks
|
||||
this.mission.advancePhase(mission.id);
|
||||
this.stallReason = null;
|
||||
const targets = this.targetEnv.getAllTargets();
|
||||
for (const target of targets) {
|
||||
this.mission.generateNextPhaseTasks(target.address);
|
||||
@@ -927,6 +1019,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
|
||||
@@ -973,8 +1066,12 @@ export class TempestCommand extends EventEmitter<CommandEvents> {
|
||||
// double-fire the completion hook.
|
||||
if (!this.activeDispatches.has(task.id)) return;
|
||||
this.clearDispatch(task.id);
|
||||
taskQueue.complete(task.id, result);
|
||||
this.hooks.onTaskCompleted?.(task);
|
||||
if (result.success === false) {
|
||||
taskQueue.fail(task.id, result.error || result.output || 'task returned unsuccessful result');
|
||||
} else {
|
||||
taskQueue.complete(task.id, result);
|
||||
this.hooks.onTaskCompleted?.(task);
|
||||
}
|
||||
}).catch((_error) => {
|
||||
if (!this.activeDispatches.has(task.id)) return;
|
||||
this.clearDispatch(task.id);
|
||||
@@ -1008,8 +1105,8 @@ export class TempestCommand extends EventEmitter<CommandEvents> {
|
||||
* never advances, and completeMission() is never called: the mission HANGS.
|
||||
*
|
||||
* On every tick we scan the in-flight dispatches and force-resolve any that have
|
||||
* exceeded the GENEROUS wall-clock backstop (taskTimeoutMs, default 5 min), or
|
||||
* that exhibit the exact wedge symptom (owning operator is `executing`/`tasked`
|
||||
* exceeded the GENEROUS wall-clock backstop (taskTimeoutMs), or that exhibit
|
||||
* the exact wedge symptom (owning operator is `executing`/`tasked`
|
||||
* but its currentTask is null — i.e. it has silently dropped the task). For each
|
||||
* we: (1) mark the task failed/timed-out in the queue, (2) clear its dispatch
|
||||
* bookkeeping, and (3) reset the owning operator back to idle so it can take new
|
||||
@@ -1071,6 +1168,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'],
|
||||
@@ -1111,6 +1211,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) {
|
||||
@@ -1136,8 +1237,11 @@ export class TempestCommand extends EventEmitter<CommandEvents> {
|
||||
// Attach the agent loop scoped to this archetype's SPECIALIZED role toolkit (defaultTools =
|
||||
// the curated per-operator tool allowlist). toolCategories stays as a coarse fallback.
|
||||
const profile = ARCHETYPE_PROFILES[archetype];
|
||||
const maxIterations = this.llm.getProvider() === 'local-agent'
|
||||
? LOCAL_AGENT_MAX_ITERATIONS
|
||||
: DEFAULT_AGENT_MAX_ITERATIONS;
|
||||
const agentLoop = new AgentLoop(this.llm, this.arsenal, {
|
||||
maxIterations: 15,
|
||||
maxIterations,
|
||||
maxTokens: 50000,
|
||||
toolCategories: profile.toolCategories,
|
||||
tools: profile.defaultTools,
|
||||
@@ -1200,8 +1304,20 @@ export class TempestCommand extends EventEmitter<CommandEvents> {
|
||||
vault: ReturnType<EvidenceVault['getStats']>;
|
||||
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,
|
||||
@@ -1213,6 +1329,24 @@ export class TempestCommand extends EventEmitter<CommandEvents> {
|
||||
vault: this.vault.getStats(),
|
||||
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,
|
||||
}))
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user