Merge remote-tracking branch 'upstream/main' into pr-45

# Conflicts:
#	src/config/index.ts
#	src/types/index.ts
This commit is contained in:
Joseph Magly committed 2026-07-16 12:11:16 -04:00
commit 5a12593baa
93 files changed
+9379 -649

No files matched your search

+148 -14
View File
@@ -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,
}))
: [],
};
}