@securecode-ai/mcp 0.6.1 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -3
- package/dist/api/types.d.ts +27 -0
- package/dist/approval/policy.js +7 -2
- package/dist/approval/policy.js.map +1 -1
- package/dist/attack/agentCorroboration.d.ts +14 -0
- package/dist/attack/agentCorroboration.js +98 -0
- package/dist/attack/agentCorroboration.js.map +1 -0
- package/dist/attack/agentLoop.js +9 -6
- package/dist/attack/agentLoop.js.map +1 -1
- package/dist/attack/agentScanBatchProtocol.d.ts +1 -1
- package/dist/attack/agentScanBatchProtocol.js.map +1 -1
- package/dist/attack/agentScanExecutor.d.ts +6 -2
- package/dist/attack/agentScanExecutor.js +62 -21
- package/dist/attack/agentScanExecutor.js.map +1 -1
- package/dist/attack/agentScanLoop.js +265 -151
- package/dist/attack/agentScanLoop.js.map +1 -1
- package/dist/attack/agentScanProtocol.d.ts +14 -5
- package/dist/attack/agentScanProtocol.js.map +1 -1
- package/dist/attack/architectureScoutLoop.js +1 -1
- package/dist/attack/architectureScoutLoop.js.map +1 -1
- package/dist/attack/investigationState.d.ts +32 -4
- package/dist/attack/investigationState.js +98 -10
- package/dist/attack/investigationState.js.map +1 -1
- package/dist/attack/llmHealth.d.ts +53 -0
- package/dist/attack/llmHealth.js +100 -0
- package/dist/attack/llmHealth.js.map +1 -0
- package/dist/attack/observationLimits.d.ts +12 -0
- package/dist/attack/observationLimits.js +16 -0
- package/dist/attack/observationLimits.js.map +1 -0
- package/dist/attack/policy.d.ts +1 -0
- package/dist/attack/policy.js +1 -0
- package/dist/attack/policy.js.map +1 -1
- package/dist/attack/probeRules.d.ts +51 -0
- package/dist/attack/probeRules.js +291 -0
- package/dist/attack/probeRules.js.map +1 -0
- package/dist/attack/protocol.d.ts +16 -0
- package/dist/attack/qualityMetrics.js +3 -0
- package/dist/attack/qualityMetrics.js.map +1 -1
- package/dist/attack/report.d.ts +3 -1
- package/dist/attack/report.js +4 -2
- package/dist/attack/report.js.map +1 -1
- package/dist/attack/runtimeProbe.d.ts +71 -0
- package/dist/attack/runtimeProbe.js +300 -0
- package/dist/attack/runtimeProbe.js.map +1 -0
- package/dist/attack/scanScheduler.d.ts +7 -0
- package/dist/attack/scanScheduler.js +58 -33
- package/dist/attack/scanScheduler.js.map +1 -1
- package/dist/attack/scanState.d.ts +1 -1
- package/dist/attack/scanState.js.map +1 -1
- package/dist/audit/findingReviewQueue.d.ts +1 -1
- package/dist/audit/findingReviewQueue.js.map +1 -1
- package/dist/auth/credentialStore.js +1 -1
- package/dist/auth/credentialStore.js.map +1 -1
- package/dist/auth/keychain.js +80 -80
- package/dist/auth/keychain.js.map +1 -1
- package/dist/mcp/server.js +5 -1
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/tools.js +8 -9
- package/dist/mcp/tools.js.map +1 -1
- package/dist/project-map/architectureContext.d.ts +1 -1
- package/dist/project-map/architectureContext.js +1 -1
- package/dist/project-map/scanCache.d.ts +6 -1
- package/dist/project-map/scanCache.js +6 -1
- package/dist/project-map/scanCache.js.map +1 -1
- package/dist/tools/agentScan.d.ts +9 -1
- package/dist/tools/agentScan.js +160 -7
- package/dist/tools/agentScan.js.map +1 -1
- package/dist/tools/agentScanBatch.js +7 -1
- package/dist/tools/agentScanBatch.js.map +1 -1
- package/dist/tools/attack.js +1 -1
- package/dist/tools/attack.js.map +1 -1
- package/dist/tools/map.js +1 -1
- package/dist/utils/verificationSandbox.js +43 -2
- package/dist/utils/verificationSandbox.js.map +1 -1
- package/package.json +62 -62
|
@@ -39,6 +39,7 @@ const workItem_1 = require("./workItem");
|
|
|
39
39
|
const handlerInventory_1 = require("../project-map/handlerInventory");
|
|
40
40
|
const candidateStore_1 = require("./candidateStore");
|
|
41
41
|
const scanScheduler_1 = require("./scanScheduler");
|
|
42
|
+
const llmHealth_1 = require("./llmHealth");
|
|
42
43
|
const finishGate_1 = require("./finishGate");
|
|
43
44
|
const qualityMetrics_1 = require("./qualityMetrics");
|
|
44
45
|
const agentScanExecutor_2 = require("./agentScanExecutor");
|
|
@@ -61,6 +62,24 @@ async function runAgentScan(ctx, target, options = {}) {
|
|
|
61
62
|
let stepsGranted = budget.stepsGranted;
|
|
62
63
|
let extensionsGranted = budget.extensionsGranted;
|
|
63
64
|
let meaningfulProgressSinceLastExtension = false;
|
|
65
|
+
let activeRunId = null;
|
|
66
|
+
// Best-effort run close: on any exit that is not a delivered
|
|
67
|
+
// agent_finish, tell the API to mark the run terminal immediately
|
|
68
|
+
// instead of waiting up to 30 minutes for the janitor. The janitor
|
|
69
|
+
// remains the fallback (refund happens there, not here).
|
|
70
|
+
const closeRun = async (result) => {
|
|
71
|
+
if (activeRunId && result.terminationReason !== 'agent_finish') {
|
|
72
|
+
try {
|
|
73
|
+
await client.postJson('/agent/scan/close', {
|
|
74
|
+
runId: activeRunId,
|
|
75
|
+
status: result.status,
|
|
76
|
+
terminationReason: result.terminationReason,
|
|
77
|
+
}, options.signal);
|
|
78
|
+
}
|
|
79
|
+
catch { /* best-effort — janitor will expire the run */ }
|
|
80
|
+
}
|
|
81
|
+
return result;
|
|
82
|
+
};
|
|
64
83
|
try {
|
|
65
84
|
const startRespRaw = await client.postJson('/agent/scan/start', {}, options.signal);
|
|
66
85
|
const startValidation = (0, protocolValidator_1.validateStartResponse)(startRespRaw);
|
|
@@ -80,8 +99,13 @@ async function runAgentScan(ctx, target, options = {}) {
|
|
|
80
99
|
};
|
|
81
100
|
}
|
|
82
101
|
const startResp = startValidation.value;
|
|
102
|
+
activeRunId = startResp.runId;
|
|
83
103
|
const trace = new agentTrace_1.AgentTraceLogger(ctx.workspaceRoot, startResp.runId);
|
|
84
104
|
trace.logRunStarted();
|
|
105
|
+
// LLM health monitor — trips on degraded-model pathologies
|
|
106
|
+
// (repeat bursts, A/B alternation, provider degraded streaks) that
|
|
107
|
+
// the consecutive-blocked counters miss.
|
|
108
|
+
const llmHealth = (0, llmHealth_1.createLlmHealthMonitor)();
|
|
85
109
|
// Authoritative scan state — the single source of truth for phase,
|
|
86
110
|
// budget, recovery, and lifecycle. Local counters below are kept in
|
|
87
111
|
// sync with this state until they are fully replaced.
|
|
@@ -241,6 +265,80 @@ async function runAgentScan(ctx, target, options = {}) {
|
|
|
241
265
|
return 8;
|
|
242
266
|
}
|
|
243
267
|
}
|
|
268
|
+
// Feed an EXECUTED action into the control plane: credit matching
|
|
269
|
+
// work items and record ledger evidence so requirements actually
|
|
270
|
+
// advance. Used for both model actions and deterministic-recovery
|
|
271
|
+
// executions — before recovery was wired in, scheduler-proposed
|
|
272
|
+
// actions executed without recording evidence, so the scheduler
|
|
273
|
+
// (a pure function of unchanged state) re-proposed the identical
|
|
274
|
+
// action until the run died on duplicate-recovery rejections.
|
|
275
|
+
// The ledger's content fingerprint dedups identical model/recovery
|
|
276
|
+
// evidence, so no double-counting occurs; work-item evidence ids
|
|
277
|
+
// carry a `:recovery` marker for traceability.
|
|
278
|
+
function recordActionEvidence(executedAction, stepIndex, source) {
|
|
279
|
+
const actionFile = String(executedAction.filePath || executedAction.path || target.filePath);
|
|
280
|
+
const actionFileNorm = actionFile.replace(/\\/g, '/').toLowerCase();
|
|
281
|
+
const evidenceKindMap = {
|
|
282
|
+
read_file: 'source-range',
|
|
283
|
+
search_code: 'symbol-reference',
|
|
284
|
+
trace_flow: 'cross-file-flow',
|
|
285
|
+
trace_flow_cross_file: 'cross-file-flow',
|
|
286
|
+
check_guard: 'guard-result',
|
|
287
|
+
check_policy: 'policy-result',
|
|
288
|
+
get_endpoints: 'handler-inventory',
|
|
289
|
+
list_imports: 'symbol-reference',
|
|
290
|
+
find_definition: 'symbol-definition',
|
|
291
|
+
find_references: 'symbol-reference',
|
|
292
|
+
find_tests: 'test-location',
|
|
293
|
+
run_tests: 'test-result',
|
|
294
|
+
read_config: 'config-result',
|
|
295
|
+
call_graph: 'cross-file-flow',
|
|
296
|
+
};
|
|
297
|
+
if (workItemQueue.size() > 0) {
|
|
298
|
+
const evidenceKind = evidenceKindMap[executedAction.type] || 'source-range';
|
|
299
|
+
for (const item of workItemQueue.getExecutable()) {
|
|
300
|
+
const matchesFile = item.targetFiles.length === 0 ||
|
|
301
|
+
item.targetFiles.some(f => f.replace(/\\/g, '/').toLowerCase() === actionFileNorm);
|
|
302
|
+
if (matchesFile) {
|
|
303
|
+
const evidenceId = source === 'recovery'
|
|
304
|
+
? `${executedAction.type}:${actionFileNorm}:${stepIndex}:recovery`
|
|
305
|
+
: `${executedAction.type}:${actionFileNorm}:${stepIndex}`;
|
|
306
|
+
workItemQueue.addEvidence(item.id, evidenceId);
|
|
307
|
+
for (const req of item.requirements) {
|
|
308
|
+
if (!req.acceptedKinds.includes(evidenceKind))
|
|
309
|
+
continue;
|
|
310
|
+
if (req.targetFiles && req.targetFiles.length > 0) {
|
|
311
|
+
const reqMatches = req.targetFiles.some(f => f.replace(/\\/g, '/').toLowerCase() === actionFileNorm);
|
|
312
|
+
if (!reqMatches)
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
if (req.requiredTools && req.requiredTools.length > 0) {
|
|
316
|
+
if (!req.requiredTools.includes(executedAction.type))
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
workItemQueue.addEvidenceForRequirement(item.id, req.id, evidenceId);
|
|
320
|
+
}
|
|
321
|
+
if (workItemQueue.isFullyResolved(item.id)) {
|
|
322
|
+
workItemQueue.resolve(item.id);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
const kind = evidenceKindMap[executedAction.type];
|
|
328
|
+
if (kind) {
|
|
329
|
+
evidenceLedger.recordEvidence({
|
|
330
|
+
kind: kind,
|
|
331
|
+
tool: executedAction.type,
|
|
332
|
+
filePath: actionFile,
|
|
333
|
+
range: executedAction.type === 'read_file'
|
|
334
|
+
? { start: executedAction.startLine || 1, end: executedAction.endLine || 1 }
|
|
335
|
+
: undefined,
|
|
336
|
+
symbol: executedAction.symbol || executedAction.pattern || undefined,
|
|
337
|
+
outcome: 'positive',
|
|
338
|
+
transcriptStep: stepIndex,
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
}
|
|
244
342
|
// Track non-read tool calls to prevent the agent from looping on the
|
|
245
343
|
// same search_code/trace_flow call repeatedly. Keyed by (type + args).
|
|
246
344
|
const toolCallCounts = new Map();
|
|
@@ -257,11 +355,49 @@ async function runAgentScan(ctx, target, options = {}) {
|
|
|
257
355
|
let lastFinishRejectionReasons = [];
|
|
258
356
|
const attemptedRecoveryFingerprints = new Set();
|
|
259
357
|
const RECOVERY_FAILURE_LIMIT = 3;
|
|
358
|
+
// Coverage-gap snapshot for abnormal terminations — documents what
|
|
359
|
+
// the investigation did NOT get to, so "no findings" is never read
|
|
360
|
+
// as "clean" after a cut-short run.
|
|
361
|
+
const buildCoverageGaps = () => {
|
|
362
|
+
const incompleteSteps = investigationState.getIncompleteSteps();
|
|
363
|
+
const autoGaps = incompleteSteps.map(step => ({
|
|
364
|
+
title: `Investigation step not completed: ${step}`,
|
|
365
|
+
detail: `The agent was terminated after repeated blocked reads without completing this required investigation step: ${step}. The investigation was incomplete and vulnerabilities may have been missed.`,
|
|
366
|
+
file: target.filePath,
|
|
367
|
+
requiredEvidence: [`Complete the ${step} step before concluding no vulnerabilities exist`],
|
|
368
|
+
suggestedNextAction: step === 'config-inspection' ? 'read_config'
|
|
369
|
+
: step === 'policy-check' ? 'check_policy'
|
|
370
|
+
: step === 'cross-file-flow' ? 'trace_flow_cross_file'
|
|
371
|
+
: step === 'route-discovery' ? 'get_endpoints'
|
|
372
|
+
: step === 'auth-symbol-search' ? 'search_code'
|
|
373
|
+
: 'continue investigation',
|
|
374
|
+
priority: 'high',
|
|
375
|
+
}));
|
|
376
|
+
const unresolvedTasks = investigationState.getUnresolvedTasks();
|
|
377
|
+
const taskGaps = unresolvedTasks.map(task => ({
|
|
378
|
+
title: `Architecture risk unresolved: ${task.claim}`,
|
|
379
|
+
detail: `This architecture-risk investigation task was not resolved: ${task.claim}. Required evidence: ${task.requiredEvidence.join('; ')}.`,
|
|
380
|
+
file: task.targetFiles[0] || target.filePath,
|
|
381
|
+
requiredEvidence: task.requiredEvidence,
|
|
382
|
+
suggestedNextAction: task.requiredTools[0] || 'continue investigation',
|
|
383
|
+
priority: 'high',
|
|
384
|
+
}));
|
|
385
|
+
const uncoveredRanges = investigationState.getUncoveredRanges(target.filePath);
|
|
386
|
+
const rangeGaps = uncoveredRanges.map(r => ({
|
|
387
|
+
title: `Unread range: lines ${r.start}-${r.end} of ${target.filePath}`,
|
|
388
|
+
detail: `This range was never read during the investigation. Vulnerabilities in this range were not checked.`,
|
|
389
|
+
file: target.filePath,
|
|
390
|
+
requiredEvidence: [`Read lines ${r.start}-${r.end} and analyze for vulnerabilities`],
|
|
391
|
+
suggestedNextAction: 'read_file',
|
|
392
|
+
priority: 'high',
|
|
393
|
+
}));
|
|
394
|
+
return [...autoGaps, ...taskGaps, ...rangeGaps];
|
|
395
|
+
};
|
|
260
396
|
while (true) {
|
|
261
397
|
// Wall clock check
|
|
262
398
|
if (Date.now() - startTime > wallClockMs) {
|
|
263
399
|
(0, scanState_1.terminateScan)(scanState, 'wall_clock', `Wall clock limit (${wallClockMs}ms) exceeded.`);
|
|
264
|
-
return {
|
|
400
|
+
return closeRun({
|
|
265
401
|
status: 'incomplete',
|
|
266
402
|
findings: [],
|
|
267
403
|
transcript,
|
|
@@ -273,12 +409,12 @@ async function runAgentScan(ctx, target, options = {}) {
|
|
|
273
409
|
summary: `Wall clock limit (${wallClockMs}ms) exceeded.`,
|
|
274
410
|
investigationNotes: [],
|
|
275
411
|
coverageGaps: [],
|
|
276
|
-
};
|
|
412
|
+
});
|
|
277
413
|
}
|
|
278
414
|
// Abort check
|
|
279
415
|
if (options.signal?.aborted) {
|
|
280
416
|
(0, scanState_1.terminateScan)(scanState, 'cancelled', 'Cancelled by user.');
|
|
281
|
-
return {
|
|
417
|
+
return closeRun({
|
|
282
418
|
status: 'cancelled',
|
|
283
419
|
findings: [],
|
|
284
420
|
transcript,
|
|
@@ -290,16 +426,18 @@ async function runAgentScan(ctx, target, options = {}) {
|
|
|
290
426
|
summary: 'Cancelled by user.',
|
|
291
427
|
investigationNotes: [],
|
|
292
428
|
coverageGaps: [],
|
|
293
|
-
};
|
|
429
|
+
});
|
|
294
430
|
}
|
|
295
431
|
// Build action constraint for blocked-read recovery.
|
|
296
432
|
// 0-1 blocked reads: normal (no constraint)
|
|
297
|
-
// 2 blocked reads: recovery mode — if
|
|
298
|
-
//
|
|
299
|
-
//
|
|
300
|
-
//
|
|
433
|
+
// 2+ blocked reads: recovery mode — if the scheduler has a
|
|
434
|
+
// deterministic action, require it; otherwise forbid
|
|
435
|
+
// read_file entirely and require an analysis tool. The
|
|
436
|
+
// constraint keeps being sent while blocked reads continue,
|
|
437
|
+
// so the model always has guidance while the MCP's own
|
|
438
|
+
// deterministic recovery (3+ blocked) executes.
|
|
301
439
|
let actionConstraint;
|
|
302
|
-
if (consecutiveBlockedReads >= 2
|
|
440
|
+
if (consecutiveBlockedReads >= 2) {
|
|
303
441
|
const schedDecision = (0, scanScheduler_1.schedule)({
|
|
304
442
|
state: scanState,
|
|
305
443
|
evidence: evidenceLedger,
|
|
@@ -375,7 +513,7 @@ async function runAgentScan(ctx, target, options = {}) {
|
|
|
375
513
|
observation: errMsg,
|
|
376
514
|
});
|
|
377
515
|
if (consecutiveErrors >= 3 || budget.stepsRemaining <= 0) {
|
|
378
|
-
return {
|
|
516
|
+
return closeRun({
|
|
379
517
|
status: 'incomplete',
|
|
380
518
|
findings: [],
|
|
381
519
|
transcript,
|
|
@@ -387,7 +525,7 @@ async function runAgentScan(ctx, target, options = {}) {
|
|
|
387
525
|
summary: `Step budget exhausted after ${consecutiveErrors} consecutive malformed API responses. Last error: ${vErr}`,
|
|
388
526
|
investigationNotes: [],
|
|
389
527
|
coverageGaps: [],
|
|
390
|
-
};
|
|
528
|
+
});
|
|
391
529
|
}
|
|
392
530
|
continue;
|
|
393
531
|
}
|
|
@@ -412,7 +550,7 @@ async function runAgentScan(ctx, target, options = {}) {
|
|
|
412
550
|
priority: 'medium',
|
|
413
551
|
});
|
|
414
552
|
}
|
|
415
|
-
return {
|
|
553
|
+
return closeRun({
|
|
416
554
|
status: 'completed',
|
|
417
555
|
findings: sanitizeFindings(finish.findings),
|
|
418
556
|
investigationNotes: finish.investigationNotes ?? [],
|
|
@@ -424,10 +562,10 @@ async function runAgentScan(ctx, target, options = {}) {
|
|
|
424
562
|
costSpentUsd,
|
|
425
563
|
terminationReason: 'agent_finish',
|
|
426
564
|
summary: finish.summary,
|
|
427
|
-
};
|
|
565
|
+
});
|
|
428
566
|
}
|
|
429
567
|
console.warn(`[Agent Scan Loop] Agent run expired (API server restarted?). Stopping scan.`);
|
|
430
|
-
return {
|
|
568
|
+
return closeRun({
|
|
431
569
|
status: 'failed',
|
|
432
570
|
findings: [],
|
|
433
571
|
transcript,
|
|
@@ -439,11 +577,11 @@ async function runAgentScan(ctx, target, options = {}) {
|
|
|
439
577
|
error: 'API server restarted mid-scan — the run was lost. Please retry the scan.',
|
|
440
578
|
investigationNotes: [],
|
|
441
579
|
coverageGaps: [],
|
|
442
|
-
};
|
|
580
|
+
});
|
|
443
581
|
}
|
|
444
582
|
// Detect abort — the user cancelled. Don't treat as error.
|
|
445
583
|
if (options.signal?.aborted || /aborted/i.test(errMsg)) {
|
|
446
|
-
return {
|
|
584
|
+
return closeRun({
|
|
447
585
|
status: 'cancelled',
|
|
448
586
|
findings: [],
|
|
449
587
|
transcript,
|
|
@@ -455,7 +593,7 @@ async function runAgentScan(ctx, target, options = {}) {
|
|
|
455
593
|
summary: 'Cancelled by user.',
|
|
456
594
|
investigationNotes: [],
|
|
457
595
|
coverageGaps: [],
|
|
458
|
-
};
|
|
596
|
+
});
|
|
459
597
|
}
|
|
460
598
|
// Detect timeout / network errors — retry once with aggressive compaction
|
|
461
599
|
if (!aggressiveCompaction && /timeout|ETIMEDOUT|ESOCKETTIMEDOUT|socket hang up|ECONNRESET|fetch failed/i.test(errMsg)) {
|
|
@@ -482,7 +620,7 @@ async function runAgentScan(ctx, target, options = {}) {
|
|
|
482
620
|
observation: errorMsg,
|
|
483
621
|
});
|
|
484
622
|
if (consecutiveErrors >= 3 || budget.stepsRemaining <= 0) {
|
|
485
|
-
return {
|
|
623
|
+
return closeRun({
|
|
486
624
|
status: 'incomplete',
|
|
487
625
|
findings: [],
|
|
488
626
|
transcript,
|
|
@@ -494,13 +632,14 @@ async function runAgentScan(ctx, target, options = {}) {
|
|
|
494
632
|
summary: `Step budget exhausted after ${consecutiveErrors} consecutive API errors. Last error: ${errMsg}`,
|
|
495
633
|
investigationNotes: [],
|
|
496
634
|
coverageGaps: [],
|
|
497
|
-
};
|
|
635
|
+
});
|
|
498
636
|
}
|
|
499
637
|
continue;
|
|
500
638
|
}
|
|
501
639
|
costSpentUsd += stepResp.costUsd || 0;
|
|
502
640
|
scanState.budget.costSpentUsd = costSpentUsd;
|
|
503
641
|
consecutiveErrors = 0;
|
|
642
|
+
llmHealth.recordStepTelemetry(!!stepResp.degraded || !!stepResp.fallbackFired);
|
|
504
643
|
trace.logStepRequested(stepResp.model, stepResp.tokens, stepResp.costUsd, stepResp.latencyMs);
|
|
505
644
|
// Transition from planning to surveying on first successful step
|
|
506
645
|
if (scanState.phase === 'planning') {
|
|
@@ -555,7 +694,7 @@ async function runAgentScan(ctx, target, options = {}) {
|
|
|
555
694
|
? 'incomplete'
|
|
556
695
|
: (stepResp.degraded ? 'incomplete' : 'completed');
|
|
557
696
|
trace.logRunCompleted(status);
|
|
558
|
-
return {
|
|
697
|
+
return closeRun({
|
|
559
698
|
status,
|
|
560
699
|
findings: [],
|
|
561
700
|
transcript,
|
|
@@ -569,9 +708,46 @@ async function runAgentScan(ctx, target, options = {}) {
|
|
|
569
708
|
: 'Agent completed without explicit finish.',
|
|
570
709
|
investigationNotes: [],
|
|
571
710
|
coverageGaps: [],
|
|
572
|
-
};
|
|
711
|
+
});
|
|
573
712
|
}
|
|
574
713
|
const action = stepResp.next;
|
|
714
|
+
llmHealth.recordModelAction((0, scanScheduler_1.actionFingerprint)(action));
|
|
715
|
+
const healthTrip = llmHealth.evaluate();
|
|
716
|
+
if (healthTrip.tripped) {
|
|
717
|
+
// Degraded-model circuit breaker — the model is looping
|
|
718
|
+
// (repeat bursts / alternation) or the provider reports a
|
|
719
|
+
// sustained degraded streak. Continuing burns budget without
|
|
720
|
+
// producing evidence; terminate with documented gaps instead.
|
|
721
|
+
console.warn(`[Agent Scan Loop] LLM health trip (${healthTrip.signal}) at step ${stepsTaken + 1}: ${healthTrip.detail}`);
|
|
722
|
+
(0, scanState_1.terminateScan)(scanState, 'llm_degraded', healthTrip.detail);
|
|
723
|
+
qualityTracker.recordForcedTermination('llm_degraded');
|
|
724
|
+
transcript.push({
|
|
725
|
+
action: {
|
|
726
|
+
type: 'system_event',
|
|
727
|
+
eventType: 'error',
|
|
728
|
+
message: `Scan terminated: the model server appears degraded (${healthTrip.detail})`,
|
|
729
|
+
},
|
|
730
|
+
observation: healthTrip.detail,
|
|
731
|
+
});
|
|
732
|
+
trace.logRunCompleted('incomplete');
|
|
733
|
+
const allGaps = buildCoverageGaps();
|
|
734
|
+
const covSummary = investigationState.getCoverageSummary(target.filePath);
|
|
735
|
+
qualityTracker.recordCoverage(covSummary.totalLines, covSummary.coveredLines, covSummary.uncoveredRangeCount, covSummary.largestUncoveredRange);
|
|
736
|
+
qualityTracker.recordBudget(stepsTaken, stepsGranted, extensionsGranted, costSpentUsd, budget.costCapUsd, wallClockMs, Date.now() - startTime);
|
|
737
|
+
return closeRun({
|
|
738
|
+
status: 'incomplete',
|
|
739
|
+
findings: [],
|
|
740
|
+
transcript,
|
|
741
|
+
stepsUsed: stepsTaken,
|
|
742
|
+
stepsGranted,
|
|
743
|
+
extensionsGranted,
|
|
744
|
+
costSpentUsd,
|
|
745
|
+
terminationReason: 'llm_degraded',
|
|
746
|
+
summary: `Model server degraded — ${healthTrip.detail} ${allGaps.length} coverage gaps identified. Retry the scan later; no credits are lost (the run is auto-refunded).`,
|
|
747
|
+
investigationNotes: [],
|
|
748
|
+
coverageGaps: allGaps,
|
|
749
|
+
});
|
|
750
|
+
}
|
|
575
751
|
stepsTaken++;
|
|
576
752
|
budget.stepsRemaining = stepResp.stepsRemaining;
|
|
577
753
|
scanState.budget.stepsUsed = stepsTaken;
|
|
@@ -716,7 +892,7 @@ async function runAgentScan(ctx, target, options = {}) {
|
|
|
716
892
|
candidateStore.setUnproven(candidate.id, `Converted to note: missing proof dimensions ${missingDims.join(', ')}`);
|
|
717
893
|
}
|
|
718
894
|
}
|
|
719
|
-
return {
|
|
895
|
+
return closeRun({
|
|
720
896
|
status: 'completed',
|
|
721
897
|
findings: sanitizeFindings(action.findings),
|
|
722
898
|
investigationNotes,
|
|
@@ -729,7 +905,7 @@ async function runAgentScan(ctx, target, options = {}) {
|
|
|
729
905
|
terminationReason: gateResult.mode === 'forced-incomplete' ? 'forced_incomplete' : 'agent_finish',
|
|
730
906
|
summary: action.summary,
|
|
731
907
|
qualityMetrics: qualityTracker.getMetrics(),
|
|
732
|
-
};
|
|
908
|
+
});
|
|
733
909
|
}
|
|
734
910
|
// Finish rejected — continue investigation
|
|
735
911
|
trace.logToolBlocked('finish', `Finish rejected: ${gateResult.reasons.map(r => r.description).join('; ')}`);
|
|
@@ -795,6 +971,7 @@ async function runAgentScan(ctx, target, options = {}) {
|
|
|
795
971
|
const abs = require('path').resolve(ctx.workspaceRoot, action.path);
|
|
796
972
|
const content = fs.readFileSync(abs, 'utf8');
|
|
797
973
|
totalLines = content.split('\n').length;
|
|
974
|
+
investigationState.recordLineDensity(action.path, content);
|
|
798
975
|
}
|
|
799
976
|
catch { /* best-effort */ }
|
|
800
977
|
const readValue = investigationState.classifyRead(action.path, action.startLine, action.endLine, totalLines);
|
|
@@ -825,12 +1002,27 @@ async function runAgentScan(ctx, target, options = {}) {
|
|
|
825
1002
|
observation = `BLOCKED: Invalid range for "${action.path}" (startLine=${action.startLine}, endLine=${action.endLine}). The requested range is inverted or out of bounds. Use a valid line range.\n\n${checklist}`;
|
|
826
1003
|
wasBlocked = true;
|
|
827
1004
|
}
|
|
1005
|
+
else if (readValue.classification === 'function-map') {
|
|
1006
|
+
qualityTracker.recordRead('function-map', false);
|
|
1007
|
+
investigationState.recordBlockedRead(action.path);
|
|
1008
|
+
const nextHint = readValue.nextUnreadRange
|
|
1009
|
+
? `\nNext unread range: lines ${readValue.nextUnreadRange.start}-${readValue.nextUnreadRange.end}. Use read_file with startLine=${readValue.nextUnreadRange.start} and endLine=${readValue.nextUnreadRange.end}.`
|
|
1010
|
+
: '';
|
|
1011
|
+
observation = `BLOCKED: The function map for "${action.path}" is already in the transcript above — it delivers no source coverage. Use read_file with explicit startLine/endLine to read the actual code.${nextHint}\n\n${checklist}`;
|
|
1012
|
+
wasBlocked = true;
|
|
1013
|
+
}
|
|
828
1014
|
else {
|
|
829
1015
|
qualityTracker.recordRead(readValue.classification, false);
|
|
830
1016
|
const readResult = await (0, agentScanExecutor_1.executeReadFileAction)(action, ctx);
|
|
831
1017
|
observation = readResult.observation;
|
|
832
1018
|
qualityTracker.recordRead(readValue.classification, readResult.truncated);
|
|
833
|
-
|
|
1019
|
+
// Record coverage for the DELIVERED range only. A ranged
|
|
1020
|
+
// read on a dense file can be cut at the observation
|
|
1021
|
+
// cap — the executor reports the last line actually
|
|
1022
|
+
// delivered, and recording only that range leaves the
|
|
1023
|
+
// cut-off tail re-readable instead of silently marking
|
|
1024
|
+
// it covered.
|
|
1025
|
+
if (readResult.totalLines > 0 && readResult.actualStart > 0 && readResult.actualEnd >= readResult.actualStart) {
|
|
834
1026
|
investigationState.recordActualRead(action.path, readResult.actualStart, readResult.actualEnd, readResult.totalLines, readResult.truncated);
|
|
835
1027
|
const rangeKey = `${normalizedPath}:${readResult.actualStart || 0}:${readResult.actualEnd || 0}`;
|
|
836
1028
|
readFiles.add(rangeKey);
|
|
@@ -842,7 +1034,11 @@ async function runAgentScan(ctx, target, options = {}) {
|
|
|
842
1034
|
observation += `\n\nNOTE: You have read "${action.path}" ${count + 1} times. Consider using search_code, trace_flow, check_guard, or check_policy to analyze the code you've read. If you have enough evidence, call finish to report your findings.\n\n${checklist}`;
|
|
843
1035
|
}
|
|
844
1036
|
}
|
|
845
|
-
else if (readResult.truncated) {
|
|
1037
|
+
else if (readResult.totalLines > 0 && readResult.truncated) {
|
|
1038
|
+
// Function-map read (large file, no range): no
|
|
1039
|
+
// content lines delivered. recordActualRead registers
|
|
1040
|
+
// the fnmap key so an immediate identical repeat is
|
|
1041
|
+
// blocked with a ranged-read hint.
|
|
846
1042
|
investigationState.recordActualRead(action.path, readResult.actualStart, readResult.actualEnd, readResult.totalLines, readResult.truncated);
|
|
847
1043
|
qualityTracker.recordToolUse('read_file');
|
|
848
1044
|
investigationState.recordToolUse('read_file');
|
|
@@ -1008,45 +1204,13 @@ async function runAgentScan(ctx, target, options = {}) {
|
|
|
1008
1204
|
console.warn(`[Agent Scan Loop] ${consecutiveBlockedReads} consecutive blocked reads — no recovery available, terminating as incomplete.`);
|
|
1009
1205
|
transcript.push({ action, observation });
|
|
1010
1206
|
trace.logRunCompleted('incomplete');
|
|
1011
|
-
const
|
|
1012
|
-
const autoGaps = incompleteSteps.map(step => ({
|
|
1013
|
-
title: `Investigation step not completed: ${step}`,
|
|
1014
|
-
detail: `The agent was terminated after repeated blocked reads without completing this required investigation step: ${step}. The investigation was incomplete and vulnerabilities may have been missed.`,
|
|
1015
|
-
file: target.filePath,
|
|
1016
|
-
requiredEvidence: [`Complete the ${step} step before concluding no vulnerabilities exist`],
|
|
1017
|
-
suggestedNextAction: step === 'config-inspection' ? 'read_config'
|
|
1018
|
-
: step === 'policy-check' ? 'check_policy'
|
|
1019
|
-
: step === 'cross-file-flow' ? 'trace_flow_cross_file'
|
|
1020
|
-
: step === 'route-discovery' ? 'get_endpoints'
|
|
1021
|
-
: step === 'auth-symbol-search' ? 'search_code'
|
|
1022
|
-
: 'continue investigation',
|
|
1023
|
-
priority: 'high',
|
|
1024
|
-
}));
|
|
1025
|
-
const unresolvedTasks = investigationState.getUnresolvedTasks();
|
|
1026
|
-
const taskGaps = unresolvedTasks.map(task => ({
|
|
1027
|
-
title: `Architecture risk unresolved: ${task.claim}`,
|
|
1028
|
-
detail: `This architecture-risk investigation task was not resolved: ${task.claim}. Required evidence: ${task.requiredEvidence.join('; ')}.`,
|
|
1029
|
-
file: task.targetFiles[0] || target.filePath,
|
|
1030
|
-
requiredEvidence: task.requiredEvidence,
|
|
1031
|
-
suggestedNextAction: task.requiredTools[0] || 'continue investigation',
|
|
1032
|
-
priority: 'high',
|
|
1033
|
-
}));
|
|
1034
|
-
const uncoveredRanges = investigationState.getUncoveredRanges(target.filePath);
|
|
1035
|
-
const rangeGaps = uncoveredRanges.map(r => ({
|
|
1036
|
-
title: `Unread range: lines ${r.start}-${r.end} of ${target.filePath}`,
|
|
1037
|
-
detail: `This range was never read during the investigation. Vulnerabilities in this range were not checked.`,
|
|
1038
|
-
file: target.filePath,
|
|
1039
|
-
requiredEvidence: [`Read lines ${r.start}-${r.end} and analyze for vulnerabilities`],
|
|
1040
|
-
suggestedNextAction: 'read_file',
|
|
1041
|
-
priority: 'high',
|
|
1042
|
-
}));
|
|
1043
|
-
const allGaps = [...autoGaps, ...taskGaps, ...rangeGaps];
|
|
1207
|
+
const allGaps = buildCoverageGaps();
|
|
1044
1208
|
(0, scanState_1.terminateScan)(scanState, 'blocked_read_recovery', `Agent stuck re-reading files. ${allGaps.length} coverage gaps.`);
|
|
1045
1209
|
qualityTracker.recordForcedTermination('blocked_read_recovery');
|
|
1046
1210
|
const covSummary = investigationState.getCoverageSummary(target.filePath);
|
|
1047
1211
|
qualityTracker.recordCoverage(covSummary.totalLines, covSummary.coveredLines, covSummary.uncoveredRangeCount, covSummary.largestUncoveredRange);
|
|
1048
1212
|
qualityTracker.recordBudget(stepsTaken, stepsGranted, extensionsGranted, costSpentUsd, budget.costCapUsd, wallClockMs, Date.now() - startTime);
|
|
1049
|
-
return {
|
|
1213
|
+
return closeRun({
|
|
1050
1214
|
status: 'incomplete',
|
|
1051
1215
|
findings: [],
|
|
1052
1216
|
transcript,
|
|
@@ -1058,7 +1222,7 @@ async function runAgentScan(ctx, target, options = {}) {
|
|
|
1058
1222
|
summary: `Investigation cut short — agent was stuck re-reading files. ${allGaps.length} coverage gaps identified.`,
|
|
1059
1223
|
investigationNotes: [],
|
|
1060
1224
|
coverageGaps: allGaps,
|
|
1061
|
-
};
|
|
1225
|
+
});
|
|
1062
1226
|
}
|
|
1063
1227
|
}
|
|
1064
1228
|
}
|
|
@@ -1115,96 +1279,21 @@ async function runAgentScan(ctx, target, options = {}) {
|
|
|
1115
1279
|
}
|
|
1116
1280
|
}
|
|
1117
1281
|
}
|
|
1118
|
-
// Link evidence to work items
|
|
1119
|
-
// a file that matches a work item's
|
|
1120
|
-
// and resolve the work item if it has
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
trace_flow_cross_file: 'cross-file-flow',
|
|
1129
|
-
check_guard: 'guard-result',
|
|
1130
|
-
check_policy: 'policy-result',
|
|
1131
|
-
get_endpoints: 'handler-inventory',
|
|
1132
|
-
list_imports: 'symbol-reference',
|
|
1133
|
-
find_definition: 'symbol-definition',
|
|
1134
|
-
find_references: 'symbol-reference',
|
|
1135
|
-
find_tests: 'test-location',
|
|
1136
|
-
run_tests: 'test-result',
|
|
1137
|
-
read_config: 'config-result',
|
|
1138
|
-
call_graph: 'cross-file-flow',
|
|
1139
|
-
};
|
|
1140
|
-
const evidenceKind = evidenceKindMap[action.type] || 'source-range';
|
|
1141
|
-
for (const item of workItemQueue.getExecutable()) {
|
|
1142
|
-
const matchesFile = item.targetFiles.length === 0 ||
|
|
1143
|
-
item.targetFiles.some(f => f.replace(/\\/g, '/').toLowerCase() === actionFileNorm);
|
|
1144
|
-
if (matchesFile) {
|
|
1145
|
-
const evidenceId = `${action.type}:${actionFileNorm}:${stepsTaken}`;
|
|
1146
|
-
workItemQueue.addEvidence(item.id, evidenceId);
|
|
1147
|
-
for (const req of item.requirements) {
|
|
1148
|
-
if (!req.acceptedKinds.includes(evidenceKind))
|
|
1149
|
-
continue;
|
|
1150
|
-
if (req.targetFiles && req.targetFiles.length > 0) {
|
|
1151
|
-
const reqMatches = req.targetFiles.some(f => f.replace(/\\/g, '/').toLowerCase() === actionFileNorm);
|
|
1152
|
-
if (!reqMatches)
|
|
1153
|
-
continue;
|
|
1154
|
-
}
|
|
1155
|
-
if (req.requiredTools && req.requiredTools.length > 0) {
|
|
1156
|
-
if (!req.requiredTools.includes(action.type))
|
|
1157
|
-
continue;
|
|
1158
|
-
}
|
|
1159
|
-
workItemQueue.addEvidenceForRequirement(item.id, req.id, evidenceId);
|
|
1160
|
-
}
|
|
1161
|
-
if (workItemQueue.isFullyResolved(item.id)) {
|
|
1162
|
-
workItemQueue.resolve(item.id);
|
|
1163
|
-
}
|
|
1164
|
-
}
|
|
1165
|
-
}
|
|
1282
|
+
// Link evidence to work items and the evidence ledger: when the
|
|
1283
|
+
// agent runs an action on a file that matches a work item's
|
|
1284
|
+
// target files, add evidence and resolve the work item if it has
|
|
1285
|
+
// enough evidence. The ledger record populates requirements so
|
|
1286
|
+
// the scheduler can suggest actions for unsatisfied requirements
|
|
1287
|
+
// and the finish gate can verify all evidence requirements are
|
|
1288
|
+
// met. Deterministic-recovery executions go through the same
|
|
1289
|
+
// path (see recordActionEvidence).
|
|
1290
|
+
if (!wasBlocked) {
|
|
1291
|
+
recordActionEvidence(action, stepsTaken, 'model');
|
|
1166
1292
|
}
|
|
1167
1293
|
// Re-sync candidates-verified after evidence linking
|
|
1168
1294
|
if (candidateStore.allReadyForJuror()) {
|
|
1169
1295
|
investigationState.markCandidatesVerified();
|
|
1170
1296
|
}
|
|
1171
|
-
// Evidence ledger: record evidence for each action type. This
|
|
1172
|
-
// populates the evidence ledger so the scheduler can suggest
|
|
1173
|
-
// actions for unsatisfied requirements and the finish gate can
|
|
1174
|
-
// verify all evidence requirements are met.
|
|
1175
|
-
if (!wasBlocked) {
|
|
1176
|
-
const actionFile = String(action.filePath || action.path || target.filePath);
|
|
1177
|
-
const evidenceKindMap = {
|
|
1178
|
-
read_file: 'source-range',
|
|
1179
|
-
search_code: 'symbol-reference',
|
|
1180
|
-
trace_flow: 'cross-file-flow',
|
|
1181
|
-
trace_flow_cross_file: 'cross-file-flow',
|
|
1182
|
-
check_guard: 'guard-result',
|
|
1183
|
-
check_policy: 'policy-result',
|
|
1184
|
-
get_endpoints: 'handler-inventory',
|
|
1185
|
-
list_imports: 'symbol-reference',
|
|
1186
|
-
find_definition: 'symbol-definition',
|
|
1187
|
-
find_references: 'symbol-reference',
|
|
1188
|
-
find_tests: 'test-location',
|
|
1189
|
-
run_tests: 'test-result',
|
|
1190
|
-
read_config: 'config-result',
|
|
1191
|
-
call_graph: 'cross-file-flow',
|
|
1192
|
-
};
|
|
1193
|
-
const kind = evidenceKindMap[action.type];
|
|
1194
|
-
if (kind) {
|
|
1195
|
-
evidenceLedger.recordEvidence({
|
|
1196
|
-
kind: kind,
|
|
1197
|
-
tool: action.type,
|
|
1198
|
-
filePath: actionFile,
|
|
1199
|
-
range: action.type === 'read_file'
|
|
1200
|
-
? { start: action.startLine || 1, end: action.endLine || 1 }
|
|
1201
|
-
: undefined,
|
|
1202
|
-
symbol: action.symbol || action.pattern || undefined,
|
|
1203
|
-
outcome: wasBlocked ? 'blocked' : 'positive',
|
|
1204
|
-
transcriptStep: stepsTaken,
|
|
1205
|
-
});
|
|
1206
|
-
}
|
|
1207
|
-
}
|
|
1208
1297
|
// Flow verification: classify trace_flow/trace_flow_cross_file
|
|
1209
1298
|
// results using structured data from the executor, not observation
|
|
1210
1299
|
// text heuristics. This prevents the cross-file-flow checklist step
|
|
@@ -1333,7 +1422,7 @@ async function runAgentScan(ctx, target, options = {}) {
|
|
|
1333
1422
|
suggestedNextAction: 'read_file',
|
|
1334
1423
|
priority: 'high',
|
|
1335
1424
|
}));
|
|
1336
|
-
return {
|
|
1425
|
+
return closeRun({
|
|
1337
1426
|
status: 'incomplete',
|
|
1338
1427
|
findings: [],
|
|
1339
1428
|
transcript,
|
|
@@ -1345,7 +1434,7 @@ async function runAgentScan(ctx, target, options = {}) {
|
|
|
1345
1434
|
summary: `Investigation incomplete — ${RECOVERY_FAILURE_LIMIT} recovery failures without progress. Model blocked ${scanState.recovery.totalModelBlockedActions} time(s), recovery attempted ${scanState.recovery.totalRecoveryAttempts} time(s).`,
|
|
1346
1435
|
investigationNotes: [],
|
|
1347
1436
|
coverageGaps: [...autoGaps, ...taskGaps, ...rangeGaps],
|
|
1348
|
-
};
|
|
1437
|
+
});
|
|
1349
1438
|
}
|
|
1350
1439
|
}
|
|
1351
1440
|
else {
|
|
@@ -1364,9 +1453,17 @@ async function runAgentScan(ctx, target, options = {}) {
|
|
|
1364
1453
|
if (recoveryAction.type === 'read_file') {
|
|
1365
1454
|
const readResult = await (0, agentScanExecutor_1.executeReadFileAction)(recoveryAction, ctx);
|
|
1366
1455
|
recoveryObservation = readResult.observation;
|
|
1367
|
-
if (readResult.totalLines > 0
|
|
1456
|
+
if (readResult.totalLines > 0) {
|
|
1457
|
+
// Record the DELIVERED range only — a ranged
|
|
1458
|
+
// recovery read on a dense file can be cut
|
|
1459
|
+
// at the observation cap; recording only the
|
|
1460
|
+
// delivered lines keeps the cut-off tail
|
|
1461
|
+
// re-readable. Function-map results (0/0)
|
|
1462
|
+
// register the fnmap key without coverage.
|
|
1368
1463
|
investigationState.recordActualRead(recoveryAction.path, readResult.actualStart, readResult.actualEnd, readResult.totalLines, readResult.truncated);
|
|
1369
|
-
|
|
1464
|
+
if (readResult.actualStart > 0 && readResult.actualEnd >= readResult.actualStart) {
|
|
1465
|
+
recoveryMadeProgress = true;
|
|
1466
|
+
}
|
|
1370
1467
|
}
|
|
1371
1468
|
}
|
|
1372
1469
|
else {
|
|
@@ -1375,6 +1472,13 @@ async function runAgentScan(ctx, target, options = {}) {
|
|
|
1375
1472
|
}
|
|
1376
1473
|
qualityTracker.recordToolUse(recoveryAction.type);
|
|
1377
1474
|
investigationState.recordToolUse(recoveryAction.type);
|
|
1475
|
+
// Feed the same evidence/work-item recording as
|
|
1476
|
+
// model actions so scheduler requirements actually
|
|
1477
|
+
// advance — otherwise the scheduler (a pure function
|
|
1478
|
+
// of unchanged state) re-proposes the identical
|
|
1479
|
+
// action and its already-burned fingerprint gets
|
|
1480
|
+
// rejected as a duplicate.
|
|
1481
|
+
recordActionEvidence(recoveryAction, stepsTaken, 'recovery');
|
|
1378
1482
|
if (recoveryMadeProgress) {
|
|
1379
1483
|
scanState.recovery.successfulRecoveryAttempts++;
|
|
1380
1484
|
scanState.recovery.consecutiveRecoveryFailures = 0;
|
|
@@ -1401,7 +1505,7 @@ async function runAgentScan(ctx, target, options = {}) {
|
|
|
1401
1505
|
const covSummary = investigationState.getCoverageSummary(target.filePath);
|
|
1402
1506
|
qualityTracker.recordCoverage(covSummary.totalLines, covSummary.coveredLines, covSummary.uncoveredRangeCount, covSummary.largestUncoveredRange);
|
|
1403
1507
|
qualityTracker.recordBudget(stepsTaken, stepsGranted, extensionsGranted, costSpentUsd, budget.costCapUsd, wallClockMs, Date.now() - startTime);
|
|
1404
|
-
return {
|
|
1508
|
+
return closeRun({
|
|
1405
1509
|
status: 'incomplete',
|
|
1406
1510
|
findings: [],
|
|
1407
1511
|
transcript,
|
|
@@ -1413,13 +1517,23 @@ async function runAgentScan(ctx, target, options = {}) {
|
|
|
1413
1517
|
summary: 'Investigation incomplete — scheduler determined no executable work remains during recovery.',
|
|
1414
1518
|
investigationNotes: [],
|
|
1415
1519
|
coverageGaps: [],
|
|
1416
|
-
};
|
|
1520
|
+
});
|
|
1417
1521
|
}
|
|
1418
1522
|
}
|
|
1419
1523
|
}
|
|
1420
1524
|
}
|
|
1421
1525
|
}
|
|
1422
1526
|
catch (err) {
|
|
1527
|
+
if (activeRunId) {
|
|
1528
|
+
try {
|
|
1529
|
+
await client.postJson('/agent/scan/close', {
|
|
1530
|
+
runId: activeRunId,
|
|
1531
|
+
status: 'failed',
|
|
1532
|
+
terminationReason: 'api_error',
|
|
1533
|
+
}, options.signal);
|
|
1534
|
+
}
|
|
1535
|
+
catch { /* best-effort — janitor will expire the run */ }
|
|
1536
|
+
}
|
|
1423
1537
|
return {
|
|
1424
1538
|
status: 'failed',
|
|
1425
1539
|
findings: [],
|