@securecode-ai/mcp 0.8.0 → 0.9.1

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.
@@ -54,7 +54,7 @@ export interface AgentScanBatchFileResult {
54
54
  costSpentUsd: number;
55
55
  error?: AgentScanBatchFileError;
56
56
  }
57
- export type AgentScanBatchStopReason = 'completed' | 'architecture-failed' | 'architecture-incomplete' | 'insufficient-credits' | 'scan-incomplete' | 'scan-failed' | 'cancelled';
57
+ export type AgentScanBatchStopReason = 'completed' | 'architecture-failed' | 'architecture-incomplete' | 'insufficient-credits' | 'scan-incomplete' | 'scan-failed' | 'llm-degraded' | 'cancelled';
58
58
  export interface AgentScanBatchResult {
59
59
  status: 'completed' | 'incomplete' | 'failed' | 'preflight-failed' | 'cancelled';
60
60
  stopReason: AgentScanBatchStopReason;
@@ -1 +1 @@
1
- {"version":3,"file":"agentScanBatchProtocol.js","sourceRoot":"","sources":["../../src/attack/agentScanBatchProtocol.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"agentScanBatchProtocol.js","sourceRoot":"","sources":["../../src/attack/agentScanBatchProtocol.ts"],"names":[],"mappings":"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"}
@@ -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.
@@ -331,11 +355,49 @@ async function runAgentScan(ctx, target, options = {}) {
331
355
  let lastFinishRejectionReasons = [];
332
356
  const attemptedRecoveryFingerprints = new Set();
333
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
+ };
334
396
  while (true) {
335
397
  // Wall clock check
336
398
  if (Date.now() - startTime > wallClockMs) {
337
399
  (0, scanState_1.terminateScan)(scanState, 'wall_clock', `Wall clock limit (${wallClockMs}ms) exceeded.`);
338
- return {
400
+ return closeRun({
339
401
  status: 'incomplete',
340
402
  findings: [],
341
403
  transcript,
@@ -347,12 +409,12 @@ async function runAgentScan(ctx, target, options = {}) {
347
409
  summary: `Wall clock limit (${wallClockMs}ms) exceeded.`,
348
410
  investigationNotes: [],
349
411
  coverageGaps: [],
350
- };
412
+ });
351
413
  }
352
414
  // Abort check
353
415
  if (options.signal?.aborted) {
354
416
  (0, scanState_1.terminateScan)(scanState, 'cancelled', 'Cancelled by user.');
355
- return {
417
+ return closeRun({
356
418
  status: 'cancelled',
357
419
  findings: [],
358
420
  transcript,
@@ -364,7 +426,7 @@ async function runAgentScan(ctx, target, options = {}) {
364
426
  summary: 'Cancelled by user.',
365
427
  investigationNotes: [],
366
428
  coverageGaps: [],
367
- };
429
+ });
368
430
  }
369
431
  // Build action constraint for blocked-read recovery.
370
432
  // 0-1 blocked reads: normal (no constraint)
@@ -451,7 +513,7 @@ async function runAgentScan(ctx, target, options = {}) {
451
513
  observation: errMsg,
452
514
  });
453
515
  if (consecutiveErrors >= 3 || budget.stepsRemaining <= 0) {
454
- return {
516
+ return closeRun({
455
517
  status: 'incomplete',
456
518
  findings: [],
457
519
  transcript,
@@ -463,7 +525,7 @@ async function runAgentScan(ctx, target, options = {}) {
463
525
  summary: `Step budget exhausted after ${consecutiveErrors} consecutive malformed API responses. Last error: ${vErr}`,
464
526
  investigationNotes: [],
465
527
  coverageGaps: [],
466
- };
528
+ });
467
529
  }
468
530
  continue;
469
531
  }
@@ -488,7 +550,7 @@ async function runAgentScan(ctx, target, options = {}) {
488
550
  priority: 'medium',
489
551
  });
490
552
  }
491
- return {
553
+ return closeRun({
492
554
  status: 'completed',
493
555
  findings: sanitizeFindings(finish.findings),
494
556
  investigationNotes: finish.investigationNotes ?? [],
@@ -500,10 +562,10 @@ async function runAgentScan(ctx, target, options = {}) {
500
562
  costSpentUsd,
501
563
  terminationReason: 'agent_finish',
502
564
  summary: finish.summary,
503
- };
565
+ });
504
566
  }
505
567
  console.warn(`[Agent Scan Loop] Agent run expired (API server restarted?). Stopping scan.`);
506
- return {
568
+ return closeRun({
507
569
  status: 'failed',
508
570
  findings: [],
509
571
  transcript,
@@ -515,11 +577,11 @@ async function runAgentScan(ctx, target, options = {}) {
515
577
  error: 'API server restarted mid-scan — the run was lost. Please retry the scan.',
516
578
  investigationNotes: [],
517
579
  coverageGaps: [],
518
- };
580
+ });
519
581
  }
520
582
  // Detect abort — the user cancelled. Don't treat as error.
521
583
  if (options.signal?.aborted || /aborted/i.test(errMsg)) {
522
- return {
584
+ return closeRun({
523
585
  status: 'cancelled',
524
586
  findings: [],
525
587
  transcript,
@@ -531,7 +593,7 @@ async function runAgentScan(ctx, target, options = {}) {
531
593
  summary: 'Cancelled by user.',
532
594
  investigationNotes: [],
533
595
  coverageGaps: [],
534
- };
596
+ });
535
597
  }
536
598
  // Detect timeout / network errors — retry once with aggressive compaction
537
599
  if (!aggressiveCompaction && /timeout|ETIMEDOUT|ESOCKETTIMEDOUT|socket hang up|ECONNRESET|fetch failed/i.test(errMsg)) {
@@ -558,7 +620,7 @@ async function runAgentScan(ctx, target, options = {}) {
558
620
  observation: errorMsg,
559
621
  });
560
622
  if (consecutiveErrors >= 3 || budget.stepsRemaining <= 0) {
561
- return {
623
+ return closeRun({
562
624
  status: 'incomplete',
563
625
  findings: [],
564
626
  transcript,
@@ -570,13 +632,14 @@ async function runAgentScan(ctx, target, options = {}) {
570
632
  summary: `Step budget exhausted after ${consecutiveErrors} consecutive API errors. Last error: ${errMsg}`,
571
633
  investigationNotes: [],
572
634
  coverageGaps: [],
573
- };
635
+ });
574
636
  }
575
637
  continue;
576
638
  }
577
639
  costSpentUsd += stepResp.costUsd || 0;
578
640
  scanState.budget.costSpentUsd = costSpentUsd;
579
641
  consecutiveErrors = 0;
642
+ llmHealth.recordStepTelemetry(!!stepResp.degraded || !!stepResp.fallbackFired);
580
643
  trace.logStepRequested(stepResp.model, stepResp.tokens, stepResp.costUsd, stepResp.latencyMs);
581
644
  // Transition from planning to surveying on first successful step
582
645
  if (scanState.phase === 'planning') {
@@ -631,7 +694,7 @@ async function runAgentScan(ctx, target, options = {}) {
631
694
  ? 'incomplete'
632
695
  : (stepResp.degraded ? 'incomplete' : 'completed');
633
696
  trace.logRunCompleted(status);
634
- return {
697
+ return closeRun({
635
698
  status,
636
699
  findings: [],
637
700
  transcript,
@@ -645,9 +708,46 @@ async function runAgentScan(ctx, target, options = {}) {
645
708
  : 'Agent completed without explicit finish.',
646
709
  investigationNotes: [],
647
710
  coverageGaps: [],
648
- };
711
+ });
649
712
  }
650
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
+ }
651
751
  stepsTaken++;
652
752
  budget.stepsRemaining = stepResp.stepsRemaining;
653
753
  scanState.budget.stepsUsed = stepsTaken;
@@ -792,7 +892,7 @@ async function runAgentScan(ctx, target, options = {}) {
792
892
  candidateStore.setUnproven(candidate.id, `Converted to note: missing proof dimensions ${missingDims.join(', ')}`);
793
893
  }
794
894
  }
795
- return {
895
+ return closeRun({
796
896
  status: 'completed',
797
897
  findings: sanitizeFindings(action.findings),
798
898
  investigationNotes,
@@ -805,7 +905,7 @@ async function runAgentScan(ctx, target, options = {}) {
805
905
  terminationReason: gateResult.mode === 'forced-incomplete' ? 'forced_incomplete' : 'agent_finish',
806
906
  summary: action.summary,
807
907
  qualityMetrics: qualityTracker.getMetrics(),
808
- };
908
+ });
809
909
  }
810
910
  // Finish rejected — continue investigation
811
911
  trace.logToolBlocked('finish', `Finish rejected: ${gateResult.reasons.map(r => r.description).join('; ')}`);
@@ -1104,45 +1204,13 @@ async function runAgentScan(ctx, target, options = {}) {
1104
1204
  console.warn(`[Agent Scan Loop] ${consecutiveBlockedReads} consecutive blocked reads — no recovery available, terminating as incomplete.`);
1105
1205
  transcript.push({ action, observation });
1106
1206
  trace.logRunCompleted('incomplete');
1107
- const incompleteSteps = investigationState.getIncompleteSteps();
1108
- const autoGaps = incompleteSteps.map(step => ({
1109
- title: `Investigation step not completed: ${step}`,
1110
- 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.`,
1111
- file: target.filePath,
1112
- requiredEvidence: [`Complete the ${step} step before concluding no vulnerabilities exist`],
1113
- suggestedNextAction: step === 'config-inspection' ? 'read_config'
1114
- : step === 'policy-check' ? 'check_policy'
1115
- : step === 'cross-file-flow' ? 'trace_flow_cross_file'
1116
- : step === 'route-discovery' ? 'get_endpoints'
1117
- : step === 'auth-symbol-search' ? 'search_code'
1118
- : 'continue investigation',
1119
- priority: 'high',
1120
- }));
1121
- const unresolvedTasks = investigationState.getUnresolvedTasks();
1122
- const taskGaps = unresolvedTasks.map(task => ({
1123
- title: `Architecture risk unresolved: ${task.claim}`,
1124
- detail: `This architecture-risk investigation task was not resolved: ${task.claim}. Required evidence: ${task.requiredEvidence.join('; ')}.`,
1125
- file: task.targetFiles[0] || target.filePath,
1126
- requiredEvidence: task.requiredEvidence,
1127
- suggestedNextAction: task.requiredTools[0] || 'continue investigation',
1128
- priority: 'high',
1129
- }));
1130
- const uncoveredRanges = investigationState.getUncoveredRanges(target.filePath);
1131
- const rangeGaps = uncoveredRanges.map(r => ({
1132
- title: `Unread range: lines ${r.start}-${r.end} of ${target.filePath}`,
1133
- detail: `This range was never read during the investigation. Vulnerabilities in this range were not checked.`,
1134
- file: target.filePath,
1135
- requiredEvidence: [`Read lines ${r.start}-${r.end} and analyze for vulnerabilities`],
1136
- suggestedNextAction: 'read_file',
1137
- priority: 'high',
1138
- }));
1139
- const allGaps = [...autoGaps, ...taskGaps, ...rangeGaps];
1207
+ const allGaps = buildCoverageGaps();
1140
1208
  (0, scanState_1.terminateScan)(scanState, 'blocked_read_recovery', `Agent stuck re-reading files. ${allGaps.length} coverage gaps.`);
1141
1209
  qualityTracker.recordForcedTermination('blocked_read_recovery');
1142
1210
  const covSummary = investigationState.getCoverageSummary(target.filePath);
1143
1211
  qualityTracker.recordCoverage(covSummary.totalLines, covSummary.coveredLines, covSummary.uncoveredRangeCount, covSummary.largestUncoveredRange);
1144
1212
  qualityTracker.recordBudget(stepsTaken, stepsGranted, extensionsGranted, costSpentUsd, budget.costCapUsd, wallClockMs, Date.now() - startTime);
1145
- return {
1213
+ return closeRun({
1146
1214
  status: 'incomplete',
1147
1215
  findings: [],
1148
1216
  transcript,
@@ -1154,7 +1222,7 @@ async function runAgentScan(ctx, target, options = {}) {
1154
1222
  summary: `Investigation cut short — agent was stuck re-reading files. ${allGaps.length} coverage gaps identified.`,
1155
1223
  investigationNotes: [],
1156
1224
  coverageGaps: allGaps,
1157
- };
1225
+ });
1158
1226
  }
1159
1227
  }
1160
1228
  }
@@ -1354,7 +1422,7 @@ async function runAgentScan(ctx, target, options = {}) {
1354
1422
  suggestedNextAction: 'read_file',
1355
1423
  priority: 'high',
1356
1424
  }));
1357
- return {
1425
+ return closeRun({
1358
1426
  status: 'incomplete',
1359
1427
  findings: [],
1360
1428
  transcript,
@@ -1366,7 +1434,7 @@ async function runAgentScan(ctx, target, options = {}) {
1366
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).`,
1367
1435
  investigationNotes: [],
1368
1436
  coverageGaps: [...autoGaps, ...taskGaps, ...rangeGaps],
1369
- };
1437
+ });
1370
1438
  }
1371
1439
  }
1372
1440
  else {
@@ -1437,7 +1505,7 @@ async function runAgentScan(ctx, target, options = {}) {
1437
1505
  const covSummary = investigationState.getCoverageSummary(target.filePath);
1438
1506
  qualityTracker.recordCoverage(covSummary.totalLines, covSummary.coveredLines, covSummary.uncoveredRangeCount, covSummary.largestUncoveredRange);
1439
1507
  qualityTracker.recordBudget(stepsTaken, stepsGranted, extensionsGranted, costSpentUsd, budget.costCapUsd, wallClockMs, Date.now() - startTime);
1440
- return {
1508
+ return closeRun({
1441
1509
  status: 'incomplete',
1442
1510
  findings: [],
1443
1511
  transcript,
@@ -1449,13 +1517,23 @@ async function runAgentScan(ctx, target, options = {}) {
1449
1517
  summary: 'Investigation incomplete — scheduler determined no executable work remains during recovery.',
1450
1518
  investigationNotes: [],
1451
1519
  coverageGaps: [],
1452
- };
1520
+ });
1453
1521
  }
1454
1522
  }
1455
1523
  }
1456
1524
  }
1457
1525
  }
1458
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
+ }
1459
1537
  return {
1460
1538
  status: 'failed',
1461
1539
  findings: [],