@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.
Files changed (75) hide show
  1. package/README.md +32 -3
  2. package/dist/api/types.d.ts +27 -0
  3. package/dist/approval/policy.js +7 -2
  4. package/dist/approval/policy.js.map +1 -1
  5. package/dist/attack/agentCorroboration.d.ts +14 -0
  6. package/dist/attack/agentCorroboration.js +98 -0
  7. package/dist/attack/agentCorroboration.js.map +1 -0
  8. package/dist/attack/agentLoop.js +9 -6
  9. package/dist/attack/agentLoop.js.map +1 -1
  10. package/dist/attack/agentScanBatchProtocol.d.ts +1 -1
  11. package/dist/attack/agentScanBatchProtocol.js.map +1 -1
  12. package/dist/attack/agentScanExecutor.d.ts +6 -2
  13. package/dist/attack/agentScanExecutor.js +62 -21
  14. package/dist/attack/agentScanExecutor.js.map +1 -1
  15. package/dist/attack/agentScanLoop.js +265 -151
  16. package/dist/attack/agentScanLoop.js.map +1 -1
  17. package/dist/attack/agentScanProtocol.d.ts +14 -5
  18. package/dist/attack/agentScanProtocol.js.map +1 -1
  19. package/dist/attack/architectureScoutLoop.js +1 -1
  20. package/dist/attack/architectureScoutLoop.js.map +1 -1
  21. package/dist/attack/investigationState.d.ts +32 -4
  22. package/dist/attack/investigationState.js +98 -10
  23. package/dist/attack/investigationState.js.map +1 -1
  24. package/dist/attack/llmHealth.d.ts +53 -0
  25. package/dist/attack/llmHealth.js +100 -0
  26. package/dist/attack/llmHealth.js.map +1 -0
  27. package/dist/attack/observationLimits.d.ts +12 -0
  28. package/dist/attack/observationLimits.js +16 -0
  29. package/dist/attack/observationLimits.js.map +1 -0
  30. package/dist/attack/policy.d.ts +1 -0
  31. package/dist/attack/policy.js +1 -0
  32. package/dist/attack/policy.js.map +1 -1
  33. package/dist/attack/probeRules.d.ts +51 -0
  34. package/dist/attack/probeRules.js +291 -0
  35. package/dist/attack/probeRules.js.map +1 -0
  36. package/dist/attack/protocol.d.ts +16 -0
  37. package/dist/attack/qualityMetrics.js +3 -0
  38. package/dist/attack/qualityMetrics.js.map +1 -1
  39. package/dist/attack/report.d.ts +3 -1
  40. package/dist/attack/report.js +4 -2
  41. package/dist/attack/report.js.map +1 -1
  42. package/dist/attack/runtimeProbe.d.ts +71 -0
  43. package/dist/attack/runtimeProbe.js +300 -0
  44. package/dist/attack/runtimeProbe.js.map +1 -0
  45. package/dist/attack/scanScheduler.d.ts +7 -0
  46. package/dist/attack/scanScheduler.js +58 -33
  47. package/dist/attack/scanScheduler.js.map +1 -1
  48. package/dist/attack/scanState.d.ts +1 -1
  49. package/dist/attack/scanState.js.map +1 -1
  50. package/dist/audit/findingReviewQueue.d.ts +1 -1
  51. package/dist/audit/findingReviewQueue.js.map +1 -1
  52. package/dist/auth/credentialStore.js +1 -1
  53. package/dist/auth/credentialStore.js.map +1 -1
  54. package/dist/auth/keychain.js +80 -80
  55. package/dist/auth/keychain.js.map +1 -1
  56. package/dist/mcp/server.js +5 -1
  57. package/dist/mcp/server.js.map +1 -1
  58. package/dist/mcp/tools.js +8 -9
  59. package/dist/mcp/tools.js.map +1 -1
  60. package/dist/project-map/architectureContext.d.ts +1 -1
  61. package/dist/project-map/architectureContext.js +1 -1
  62. package/dist/project-map/scanCache.d.ts +6 -1
  63. package/dist/project-map/scanCache.js +6 -1
  64. package/dist/project-map/scanCache.js.map +1 -1
  65. package/dist/tools/agentScan.d.ts +9 -1
  66. package/dist/tools/agentScan.js +160 -7
  67. package/dist/tools/agentScan.js.map +1 -1
  68. package/dist/tools/agentScanBatch.js +7 -1
  69. package/dist/tools/agentScanBatch.js.map +1 -1
  70. package/dist/tools/attack.js +1 -1
  71. package/dist/tools/attack.js.map +1 -1
  72. package/dist/tools/map.js +1 -1
  73. package/dist/utils/verificationSandbox.js +43 -2
  74. package/dist/utils/verificationSandbox.js.map +1 -1
  75. 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 unread ranges exist,
298
- // require the next unread range; if no unread ranges remain,
299
- // forbid read_file entirely and require an analysis tool.
300
- // 3+ blocked reads: MCP selects a deterministic recovery action via scheduler
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 && consecutiveBlockedReads < 3) {
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
- if (readResult.totalLines > 0 && !readResult.truncated) {
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 incompleteSteps = investigationState.getIncompleteSteps();
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: when the agent runs an action on
1119
- // a file that matches a work item's target files, add evidence
1120
- // and resolve the work item if it has enough evidence.
1121
- if (!wasBlocked && workItemQueue.size() > 0) {
1122
- const actionFile = action.filePath || action.path || target.filePath;
1123
- const actionFileNorm = String(actionFile).replace(/\\/g, '/').toLowerCase();
1124
- const evidenceKindMap = {
1125
- read_file: 'source-range',
1126
- search_code: 'symbol-reference',
1127
- trace_flow: 'cross-file-flow',
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 && !readResult.truncated) {
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
- recoveryMadeProgress = true;
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: [],