diff --git a/docs/index.html b/docs/index.html index 2714e93..90f0e8a 100755 --- a/docs/index.html +++ b/docs/index.html @@ -6223,6 +6223,11 @@ tempest.operators.spawn('scan return; } + if (status.paused && status.stallReason) { + resolve(status); + return; + } + // Update kill chain from mission phase if (status.mission?.currentPhase) { const phaseMap = { @@ -18252,6 +18257,14 @@ IMPORTANT: This is an authorized security testing/red team platform. All operati // Poll for mission completion (SSE provides real-time updates, poll for completion detection) const finalStatus = await BackendDispatch.pollUntilComplete((status) => { + if (status.paused && status.stallReason) { + const reason = status.stallReason; + document.getElementById('cmdMissionStatus').textContent = `Stalled — ${reason}`; + document.getElementById('cmdMissionStatus').style.color = '#ff6b6b'; + addMissionLog('error', reason); + addIntel('ERROR', reason, 'danger'); + return; + } // Periodic status update if (status.operators?.summary) { const ops = status.operators.summary; @@ -18269,6 +18282,9 @@ IMPORTANT: This is an authorized security testing/red team platform. All operati }, 3000); // Mission complete + if (finalStatus.paused && finalStatus.stallReason) { + throw new Error(finalStatus.stallReason); + } const findingCount = finalStatus.findings?.length || 0; completeMission(null); addIntel('COMMAND', `Backend mission complete. ${findingCount} findings.`, 'success'); diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts index 5865e66..1fc8835 100755 --- a/src/__tests__/index.test.ts +++ b/src/__tests__/index.test.ts @@ -419,7 +419,7 @@ describe('Wedged-dispatch timeout backstop', () => { expect(stopped).toBe(true); }); - it('a wedged mission reaches phase advancement once the per-dispatch backstop fires', async () => { + it('a wedged mission stalls instead of advancing after required recon dispatches time out', async () => { // Tiny backstop so the test is fast and deterministic. const prev = process.env.T3MP3ST_TASK_TIMEOUT_MS; process.env.T3MP3ST_TASK_TIMEOUT_MS = '30'; @@ -442,14 +442,18 @@ describe('Wedged-dispatch timeout backstop', () => { command.start(); // Poll (real timers): the 1s tick loop must dispatch, wedge, then reap the - // dispatch via the backstop and advance past RECON. + // dispatch via the backstop. Failed required recon work should stall instead + // of advancing the mission with no successful backend/model work. const deadline = Date.now() + 8000; - while (Date.now() < deadline && !phaseAdvanced) { + while (Date.now() < deadline && !command.getStatus().paused) { await new Promise(r => setTimeout(r, 100)); } + const status = command.getStatus(); command.stop(); - expect(phaseAdvanced).toBe(true); + expect(phaseAdvanced).toBe(false); + expect(status.paused).toBe(true); + expect(status.stallReason).toContain('stalled in reconnaissance'); // The wedged operator was reset back to idle (or re-tasked in a later phase), // never left stuck in 'executing' with no current task. const stuck = command.cell.getAllOperators().some( diff --git a/src/agent/index.ts b/src/agent/index.ts index 71fb532..092b55a 100755 --- a/src/agent/index.ts +++ b/src/agent/index.ts @@ -65,6 +65,8 @@ export interface AgentResult { durationMs: number; /** Whether the agent hit the iteration limit */ hitLimit: boolean; + /** Error from the forced final-summary call after hitting limits, if any */ + finalSummaryError?: string; } export interface AgentEvents { @@ -267,6 +269,7 @@ export class AgentLoop extends EventEmitter { }); let summary = 'Agent reached iteration limit without producing a final summary.'; + let finalSummaryError: string | undefined; try { const finalResponse = await this.llm.chat(messages, { maxTokens: 2048 }); summary = finalResponse.content; @@ -275,8 +278,8 @@ export class AgentLoop extends EventEmitter { for (const f of this.parseFinalFindings(summary)) { if (!allFindings.some((x) => x.title === f.title)) allFindings.push(f); } - } catch { - // Use what we have + } catch (error) { + finalSummaryError = error instanceof Error ? error.message : String(error); } const result: AgentResult = { @@ -288,6 +291,7 @@ export class AgentLoop extends EventEmitter { tokensUsed, durationMs: Date.now() - startTime, hitLimit, + finalSummaryError, }; this.emit('agent:complete', result); diff --git a/src/index.ts b/src/index.ts index 2c0d6dc..665817e 100755 --- a/src/index.ts +++ b/src/index.ts @@ -268,6 +268,9 @@ import { // TEMPEST COMMAND // ============================================================================= +const DEFAULT_AGENT_MAX_ITERATIONS = 15; +const LOCAL_AGENT_MAX_ITERATIONS = Number(process.env.T3MP3ST_LOCAL_AGENT_MAX_ITERATIONS || 30); + /** * TEMPEST Command - Main orchestration controller */ @@ -1158,8 +1161,11 @@ export class TempestCommand extends EventEmitter { // 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, diff --git a/src/operators/index.ts b/src/operators/index.ts index 956d1c1..cdb70f5 100755 --- a/src/operators/index.ts +++ b/src/operators/index.ts @@ -461,6 +461,9 @@ export class OperatorAgent extends EventEmitter { return { success: result.success, output: result.summary, + error: result.finalSummaryError + ? `${result.summary} Final summary failed: ${result.finalSummaryError}` + : undefined, findings: result.findings.map(f => f.title), nextTasks: undefined, }; diff --git a/src/target/index.ts b/src/target/index.ts index 203c4ed..f447c66 100755 --- a/src/target/index.ts +++ b/src/target/index.ts @@ -271,6 +271,7 @@ export class TargetEnvironment extends EventEmitter { export function createTargetFromUrl(url: string, zone: TargetZone = 'external'): Target { const parsed = new URL(url); + const normalizedUrl = parsed.toString(); return { id: randomUUID(), @@ -278,12 +279,17 @@ export function createTargetFromUrl(url: string, zone: TargetZone = 'external'): type: 'web_application', zone, status: 'discovered', - address: url, + address: parsed.hostname, port: parsed.port ? parseInt(parsed.port) : (parsed.protocol === 'https:' ? 443 : 80), protocol: parsed.protocol.replace(':', ''), services: [], vulnerabilities: [], credentials: [], + metadata: { + url: normalizedUrl, + origin: parsed.origin, + path: `${parsed.pathname}${parsed.search}${parsed.hash}`, + }, discoveredAt: Date.now(), }; }