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(),
};
}