opencode-api-security-testing 4.0.1 → 5.2.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/src/index.ts CHANGED
@@ -6,18 +6,127 @@ import { existsSync, readFileSync } from "fs";
6
6
  const SKILL_DIR = "skills/api-security-testing";
7
7
  const CORE_DIR = `${SKILL_DIR}/core`;
8
8
  const AGENTS_DIR = ".config/opencode/agents";
9
+ const CONFIG_FILE = "api-security-testing.config.json";
10
+
11
+ // 默认配置
12
+ const DEFAULT_CONFIG = {
13
+ agents: {
14
+ "api-cyber-supervisor": { model: "anthropic/claude-sonnet-4-20250514", temperature: 0.3 },
15
+ "api-probing-miner": { model: "anthropic/claude-haiku-4-20250514", temperature: 0.5 },
16
+ "api-resource-specialist": { model: "anthropic/claude-haiku-4-20250514", temperature: 0.4 },
17
+ "api-vuln-verifier": { model: "anthropic/claude-haiku-4-20250514", temperature: 0.2 }
18
+ },
19
+ model_fallback: {
20
+ supervisor: [
21
+ "anthropic/claude-sonnet-4-20250514",
22
+ "openai/gpt-4o",
23
+ "google/gemini-2.0-flash",
24
+ "anthropic/claude-haiku-4-20250514"
25
+ ],
26
+ subagent: [
27
+ "anthropic/claude-haiku-4-20250514",
28
+ "openai/gpt-4o-mini",
29
+ "google/gemini-2.0-flash-lite"
30
+ ]
31
+ },
32
+ cyber_supervisor: {
33
+ enabled: true,
34
+ auto_trigger: true,
35
+ max_retries: 5,
36
+ give_up_patterns: [
37
+ "无法解决", "不能", "需要手动", "环境问题",
38
+ "超出范围", "建议用户", "I cannot", "I'm unable",
39
+ "out of scope", "manual"
40
+ ]
41
+ },
42
+ collection_mode: "auto" // auto | static | dynamic
43
+ };
9
44
 
10
45
  // 赛博监工配置
11
- const CYBER_SUPERVISOR = {
12
- enabled: true,
13
- auto_trigger: true,
14
- max_retries: 5,
15
- give_up_patterns: [
16
- "无法解决", "不能", "需要手动", "环境问题",
17
- "超出范围", "建议用户", "I cannot", "I'm unable",
18
- "out of scope", "manual"
19
- ]
20
- };
46
+ const CYBER_SUPERVISOR = DEFAULT_CONFIG.cyber_supervisor;
47
+
48
+ // 模型回退状态追踪
49
+ const modelFailureCounts = new Map<string, Map<string, number>>();
50
+ const sessionFailures = new Map<string, number>();
51
+
52
+ function getConfigPath(ctx: { directory: string }): string {
53
+ return join(ctx.directory, SKILL_DIR, "assets", CONFIG_FILE);
54
+ }
55
+
56
+ function loadConfig(ctx: { directory: string }): typeof DEFAULT_CONFIG {
57
+ const configPath = getConfigPath(ctx);
58
+ if (existsSync(configPath)) {
59
+ try {
60
+ const content = readFileSync(configPath, "utf-8");
61
+ const loaded = JSON.parse(content);
62
+ return { ...DEFAULT_CONFIG, ...loaded };
63
+ } catch (e) {
64
+ console.log(`[api-security-testing] Failed to load config: ${e}`);
65
+ }
66
+ }
67
+ return DEFAULT_CONFIG;
68
+ }
69
+
70
+ function getModelFailureCount(sessionID: string, modelID: string): number {
71
+ const sessionMap = modelFailureCounts.get(sessionID);
72
+ if (!sessionMap) return 0;
73
+ return sessionMap.get(modelID) || 0;
74
+ }
75
+
76
+ function incrementModelFailure(sessionID: string, modelID: string): void {
77
+ if (!modelFailureCounts.has(sessionID)) {
78
+ modelFailureCounts.set(sessionID, new Map());
79
+ }
80
+ const sessionMap = modelFailureCounts.get(sessionID)!;
81
+ sessionMap.set(modelID, (sessionMap.get(modelID) || 0) + 1);
82
+ }
83
+
84
+ function resetModelFailures(sessionID: string): void {
85
+ modelFailureCounts.delete(sessionID);
86
+ }
87
+
88
+ function getNextFallbackModel(
89
+ config: typeof DEFAULT_CONFIG,
90
+ sessionID: string,
91
+ currentModel: string,
92
+ isSupervisor: boolean
93
+ ): string | null {
94
+ const chain = isSupervisor
95
+ ? config.model_fallback.supervisor
96
+ : config.model_fallback.subagent;
97
+
98
+ const currentIndex = chain.indexOf(currentModel);
99
+ if (currentIndex === -1 || currentIndex >= chain.length - 1) {
100
+ return null; // No fallback available
101
+ }
102
+
103
+ // 检查下一个模型是否也已失败过多
104
+ const nextModel = chain[currentIndex + 1];
105
+ const failures = getModelFailureCount(sessionID, nextModel);
106
+ if (failures >= 3) {
107
+ // 递归查找下一个可用模型
108
+ return getNextFallbackModel(config, sessionID, nextModel, isSupervisor);
109
+ }
110
+
111
+ return nextModel;
112
+ }
113
+
114
+ function detectModelError(output: string): { isModelError: boolean; modelID?: string } {
115
+ const errorPatterns = [
116
+ /model (.+?) (is overloaded|is not available|rate limit|timeout|failed)/i,
117
+ /(.+?) (rate limit exceeded|context length exceeded)/i,
118
+ /API error.*model[:\s]*(.+)/i,
119
+ ];
120
+
121
+ for (const pattern of errorPatterns) {
122
+ const match = output.match(pattern);
123
+ if (match) {
124
+ return { isModelError: true, modelID: match[1]?.trim() };
125
+ }
126
+ }
127
+
128
+ return { isModelError: false };
129
+ }
21
130
 
22
131
  // 压力升级提示词
23
132
  const LEVEL_PROMPTS = [
@@ -79,9 +188,6 @@ async function execShell(ctx: unknown, cmd: string): Promise<string> {
79
188
  return result.toString();
80
189
  }
81
190
 
82
- // 赛博监工状态追踪
83
- const sessionFailures = new Map<string, number>();
84
-
85
191
  function getFailureCount(sessionID: string): number {
86
192
  return sessionFailures.get(sessionID) || 0;
87
193
  }
@@ -101,7 +207,8 @@ function detectGiveUpPattern(text: string): boolean {
101
207
  }
102
208
 
103
209
  const ApiSecurityTestingPlugin: Plugin = async (ctx) => {
104
- console.log("[api-security-testing] Plugin loaded v4.0.0");
210
+ const config = loadConfig(ctx);
211
+ console.log(`[api-security-testing] Plugin loaded v4.0.2 - collection_mode: ${config.collection_mode}`);
105
212
 
106
213
  return {
107
214
  tool: {
@@ -169,22 +276,23 @@ print(result)
169
276
  }),
170
277
 
171
278
  browser_collect: tool({
172
- description: "浏览器采集动态内容。参数: url(目标URL)",
279
+ description: "浏览器采集动态内容。参数: url(目标URL), mode(auto/static/dynamic)",
173
280
  args: {
174
281
  url: tool.schema.string(),
282
+ mode: tool.schema.enum(["auto", "static", "dynamic"]).optional(),
175
283
  },
176
284
  async execute(args, ctx) {
177
285
  const deps = checkDeps(ctx);
178
286
  const corePath = getCorePath(ctx);
287
+ const collectionMode = args.mode || config.collection_mode;
179
288
  const cmd = `${deps}python3 -c "
180
289
  import sys
181
290
  sys.path.insert(0, '${corePath}')
182
- from collectors.browser_collect import BrowserCollector
183
- collector = BrowserCollector(headless=True)
184
- endpoints = collector.collect('${args.url}')
185
- print(f'发现 {len(endpoints)} 个端点:')
186
- for ep in endpoints:
187
- print(ep)
291
+ from collectors.browser_collector import BrowserCollectorFacade
292
+ facade = BrowserCollectorFacade(headless=True)
293
+ result = facade.collect_all('${args.url}', {'mode': '${collectionMode}'})
294
+ import json
295
+ print(json.dumps(result, indent=2))
188
296
  "`;
189
297
  return await execShell(ctx, cmd);
190
298
  },
@@ -307,6 +415,171 @@ from testers.auth_tester import AuthTester
307
415
  tester = AuthTester()
308
416
  result = tester.test('${args.endpoint}')
309
417
  print(result)
418
+ "`;
419
+ return await execShell(ctx, cmd);
420
+ },
421
+ }),
422
+
423
+ // 新增: 端点自动发现工具
424
+ endpoint_discover: tool({
425
+ description: "自动发现 API 端点。从 JS 文件、HTML、Sitemap 中提取所有 API 路径",
426
+ args: {
427
+ target: tool.schema.string(),
428
+ depth: tool.schema.enum(["shallow", "deep"]).optional(),
429
+ include_methods: tool.schema.boolean().optional()
430
+ },
431
+ async execute(args, ctx) {
432
+ const deps = checkDeps(ctx);
433
+ const corePath = getCorePath(ctx);
434
+ const target = args.target as string;
435
+ const depth = (args.depth as string) || "shallow";
436
+ const includeMethods = (args.include_methods as boolean) !== false;
437
+ const cmd = `${deps}python3 -c "
438
+ import sys, re, json, urllib.request, ssl
439
+ sys.path.insert(0, '${corePath}')
440
+ ssl._create_default_https_context = ssl._create_unverified_context
441
+ target = '${target}'
442
+ depth = '${depth}'
443
+ endpoints = set()
444
+ js_endpoints = set()
445
+ html_endpoints = set()
446
+ patterns = [r'[\\"\\'](/api/[^\\"\\'\\s?#]+)[\\"\\']', r'[\\"\\'](/v[0-9]+/[^\\"\\'\\s?#]+)[\\"\\']', r'[\\"\\'](/admin/[^\\"\\'\\s?#]+)[\\"\\']', r'[\\"\\'](/auth/[^\\"\\'\\s?#]+)[\\"\\']', r'[\\"\\'](/graphql)[\\"\\']', r'[\\"\\'](/rest/[^\\"\\'\\s?#]+)[\\"\\']', r'axios\\.(get|post|put|delete|patch)\([\\"\\']([^\\"\\'\\s?#]+)[\\"\\']', r'fetch\([\\"\\']([^\\"\\'\\s?#]+)[\\"\\']']
447
+ def safe_fetch(url, timeout=10):
448
+ try:
449
+ req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
450
+ with urllib.request.urlopen(req, timeout=timeout) as r: return r.read().decode('utf-8', errors='ignore')
451
+ except: return None
452
+ def extract(text):
453
+ found = []
454
+ for p in patterns:
455
+ for m in re.findall(p, text):
456
+ ep = m[-1] if isinstance(m, tuple) else m
457
+ ep = ep.strip().lstrip('/')
458
+ if ep and len(ep) > 2 and not ep.startswith(('http', 'data:', '#')):
459
+ if not any(ep.lower().endswith(x) for x in ['.js','.css','.png','.jpg','.gif','.svg','.ico']):
460
+ found.append('/' + ep)
461
+ return found
462
+ parsed = __import__('urllib.parse').urlparse(target)
463
+ base = f'{parsed.scheme}://{parsed.netloc}'
464
+ html = safe_fetch(target)
465
+ if html:
466
+ html_endpoints.update(extract(html))
467
+ for js_url in re.findall(r'<script[^>]+src=[\\"\\']([^\\"\\'\\s?#]+)[\\"\\']', html)[:20 if depth=='shallow' else 50]:
468
+ if js_url.startswith('/'): js_url = base + js_url
469
+ elif not js_url.startswith('http'): js_url = base + '/' + js_url
470
+ js_content = safe_fetch(js_url, timeout=10)
471
+ if js_content: js_endpoints.update(extract(js_content))
472
+ for path in ['/api', '/api/v1', '/api/docs', '/swagger.json', '/graphql', '/admin', '/auth', '/health', '/version']:
473
+ content = safe_fetch(base + path, timeout=5)
474
+ if content and len(content) > 10:
475
+ endpoints.add(path)
476
+ if content.strip().startswith('{'):
477
+ try:
478
+ for ep in re.findall(r'[\\"\\'](/[a-zA-Z0-9_./-]+)[\\"\\']', content[:10000]): endpoints.add(ep)
479
+ except: pass
480
+ if depth == 'deep':
481
+ for js_url in re.findall(r'<script[^>]+src=[\\"\\']([^\\"\\'\\s?#]+)[\\"\\']', html or '')[:50]:
482
+ if js_url.startswith('/'): js_url = base + js_url
483
+ elif not js_url.startswith('http'): js_url = base + '/' + js_url
484
+ js_content = safe_fetch(js_url, timeout=10)
485
+ if js_content:
486
+ for p in [r'[\\"\\'](/[a-zA-Z0-9_./{{}}:-]+)[\\"\\']', r'path:\\s*[\\"\\'](/[a-zA-Z0-9_./{{}}:-]+)[\\"\\']']:
487
+ for m in re.findall(p, js_content):
488
+ ep = m[-1] if isinstance(m, tuple) else m
489
+ if len(ep) > 2 and not ep.startswith(('http', 'data:', '#')): endpoints.add(ep)
490
+ all_eps = sorted(set(endpoints) | set(html_endpoints) | set(js_endpoints))
491
+ clean = []
492
+ seen = set()
493
+ for ep in all_eps:
494
+ ep = ep.split('?')[0].split('#')[0].rstrip('/')
495
+ if ep and ep not in seen and len(ep) > 1:
496
+ seen.add(ep)
497
+ clean.append(ep)
498
+ result = {'target': target, 'total_endpoints': len(clean), 'endpoints': clean, 'sources': {'html': len(html_endpoints), 'javascript': len(js_endpoints), 'common_paths': len(endpoints)}}
499
+ print(json.dumps(result, indent=2, ensure_ascii=False))
500
+ "`;
501
+ return await execShell(ctx, cmd);
502
+ },
503
+ }),
504
+
505
+ // 新增: 报告生成工具
506
+ report_generate: tool({
507
+ description: "生成安全测试报告。支持 Markdown、HTML、JSON 格式,内置 OWASP API Top 10 模板",
508
+ args: {
509
+ format: tool.schema.enum(["markdown", "html", "json"]).optional(),
510
+ template: tool.schema.enum(["owasp-api", "custom"]).optional(),
511
+ target: tool.schema.string().optional(),
512
+ findings: tool.schema.string().optional(),
513
+ severity_filter: tool.schema.enum(["all", "critical", "high", "medium", "low", "info"]).optional()
514
+ },
515
+ async execute(args, ctx) {
516
+ const deps = checkDeps(ctx);
517
+ const corePath = getCorePath(ctx);
518
+ const format = (args.format as string) || "markdown";
519
+ const template = (args.template as string) || "owasp-api";
520
+ const target = (args.target as string) || "Unknown";
521
+ const findings = (args.findings as string) || "";
522
+ const severityFilter = (args.severity_filter as string) || "all";
523
+ const cmd = `${deps}python3 -c "
524
+ import sys, json
525
+ from datetime import datetime
526
+ sys.path.insert(0, '${corePath}')
527
+ format = '${format}'
528
+ target = '${target.replace(/'/g, "\\'")}'
529
+ findings = '''${findings.replace(/'/g, "\\'")}'''
530
+ severity_filter = '${severityFilter}'
531
+ parsed_findings = []
532
+ if findings.strip():
533
+ try: parsed_findings = json.loads(findings)
534
+ except:
535
+ for line in findings.strip().split('\\n'):
536
+ if line.strip():
537
+ try: parsed_findings.append(json.loads(line))
538
+ except: pass
539
+ if severity_filter != 'all' and parsed_findings:
540
+ parsed_findings = [f for f in parsed_findings if f.get('severity','').lower() == severity_filter.lower()]
541
+ sev_counts = {'critical': 0, 'high': 0, 'medium': 0, 'low': 0, 'info': 0}
542
+ for f in parsed_findings:
543
+ s = f.get('severity','info').lower()
544
+ if s in sev_counts: sev_counts[s] += 1
545
+ total = len(parsed_findings)
546
+ risk_score = sev_counts['critical']*10 + sev_counts['high']*7 + sev_counts['medium']*4 + sev_counts['low']*1
547
+ risk_level = 'CRITICAL' if risk_score >= 50 else 'HIGH' if risk_score >= 30 else 'MEDIUM' if risk_score >= 15 else 'LOW' if risk_score > 0 else 'NONE'
548
+ now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
549
+ if format == 'markdown':
550
+ report = f'# API 安全测试报告\\n\\n## 基本信息\\n\\n| 项目 | 值 |\\n|------|------|\\n| 目标 | {target} |\\n| 测试时间 | {now} |\\n| 风险等级 | **{risk_level}** |\\n| 风险分数 | {risk_score}/100 |\\n\\n## 漏洞统计\\n\\n| 严重程度 | 数量 |\\n|---------|------|\\n| Critical | {sev_counts[\\\"critical\\\"]} |\\n| High | {sev_counts[\\\"high\\\"]} |\\n| Medium | {sev_counts[\\\"medium\\\"]} |\\n| Low | {sev_counts[\\\"low\\\"]} |\\n| Info | {sev_counts[\\\"info\\\"]} |\\n| **总计** | **{total}** |\\n\\n## 漏洞详情\\n\\n'
551
+ if not parsed_findings: report += '未发现安全漏洞。\\n\\n'
552
+ else:
553
+ for i, f in enumerate(parsed_findings, 1):
554
+ title = f.get('title', f.get('vulnerability', f'Vulnerability #{i}'))
555
+ sev = f.get('severity', 'Unknown').upper()
556
+ ep = f.get('endpoint', f.get('url', 'N/A'))
557
+ desc = f.get('description', f.get('detail', 'No description'))
558
+ poc = f.get('poc', f.get('proof_of_concept', ''))
559
+ rec = f.get('recommendation', f.get('fix', 'Review and fix'))
560
+ report += f'### {i}. [{sev}] {title}\\n\\n| 属性 | 值 |\\n|------|------|\\n| 端点 | \\\`{ep}\\\` |\\n\\n**描述:**\\n\\n{desc}\\n\\n'
561
+ if poc: report += f'**PoC:**\\n\\n\\\`\\\`\\\`bash\\n{poc}\\n\\\`\\\`\\\`\\n\\n'
562
+ report += f'**修复建议:**\\n\\n{rec}\\n\\n---\\n\\n'
563
+ report += f'\\n## 测试覆盖范围\\n\\n| 测试类别 | 状态 |\\n|---------|------|\\n| SQL 注入 | ✅ 已测试 |\\n| XSS | ✅ 已测试 |\\n| IDOR | ✅ 已测试 |\\n| 认证绕过 | ✅ 已测试 |\\n| 敏感数据 | ✅ 已测试 |\\n| 业务逻辑 | ✅ 已测试 |\\n| 安全配置 | ✅ 已测试 |\\n| 暴力破解 | ✅ 已测试 |\\n| SSRF | ✅ 已测试 |\\n| GraphQL | ✅ 已测试 |\\n\\n---\\n\\n*报告生成时间: {now}*\\n*工具: opencode-api-security-testing v5.1.0*\\n'
564
+ elif format == 'json':
565
+ report = json.dumps({'report': {'target': target, 'generated_at': now, 'tool': 'opencode-api-security-testing', 'version': '5.1.0'}, 'summary': {'total': total, 'risk_score': risk_score, 'risk_level': risk_level, 'severity_counts': sev_counts}, 'findings': parsed_findings}, indent=2, ensure_ascii=False)
566
+ else:
567
+ report = f'<!DOCTYPE html><html><head><meta charset=UTF-8><title>API 安全测试报告 - {target}</title><style>body{{font-family:sans-serif;margin:20px;background:#f5f5f5}}.container{{max-width:1200px;margin:0 auto;background:white;border-radius:8px;padding:20px;box-shadow:0 2px 10px rgba(0,0,0,0.1)}}h1{{color:#333}}.stats{{display:flex;gap:15px;flex-wrap:wrap;margin:20px 0}}.stat{{background:#f8f9fa;padding:15px;border-radius:8px;text-align:center;min-width:120px}}.stat .num{{font-size:32px;font-weight:bold}}.stat.critical .num{{color:#dc3545}}.stat.high .num{{color:#fd7e14}}.stat.medium .num{{color:#ffc107}}.stat.low .num{{color:#28a745}}.finding{{border:1px solid #e9ecef;border-radius:8px;margin:15px 0;overflow:hidden}}.finding-header{{padding:15px;display:flex;align-items:center;gap:10px}}.finding-header.critical{{background:#fff5f5;border-left:4px solid #dc3545}}.finding-header.high{{background:#fff8f0;border-left:4px solid #fd7e14}}.finding-header.medium{{background:#fffdf0;border-left:4px solid #ffc107}}.finding-header.low{{background:#f0fff4;border-left:4px solid #28a745}}.badge{{padding:3px 8px;border-radius:4px;font-size:11px;font-weight:bold;color:white}}.badge.critical{{background:#dc3545}}.badge.high{{background:#fd7e14}}.badge.medium{{background:#ffc107;color:#333}}.badge.low{{background:#28a745}}.finding-body{{padding:15px}}pre{{background:#f8f9fa;padding:10px;border-radius:4px;overflow-x:auto}}</style></head><body><div class=container><h1>API 安全测试报告</h1><p>目标: {target} | 时间: {now} | 风险: <strong>{risk_level}</strong> ({risk_score}/100)</p><div class=stats><div class=stat critical><div class=num>{sev_counts[\\\"critical\\\"]}</div><div>Critical</div></div><div class=stat high><div class=num>{sev_counts[\\\"high\\\"]}</div><div>High</div></div><div class=stat medium><div class=num>{sev_counts[\\\"medium\\\"]}</div><div>Medium</div></div><div class=stat low><div class=num>{sev_counts[\\\"low\\\"]}</div><div>Low</div></div><div class=stat><div class=num>{total}</div><div>Total</div></div></div>'
568
+ if not parsed_findings: report += '<h2 style=color:#28a745>未发现安全漏洞</h2>'
569
+ else:
570
+ for i, f in enumerate(parsed_findings, 1):
571
+ title = f.get('title', f.get('vulnerability', f'#{i}'))
572
+ sev = f.get('severity','unknown').lower()
573
+ ep = f.get('endpoint', f.get('url', 'N/A'))
574
+ desc = f.get('description', f.get('detail', ''))
575
+ poc = f.get('poc', '')
576
+ rec = f.get('recommendation', f.get('fix', ''))
577
+ report += f'<div class=finding><div class=finding-header {sev}><span class=badge {sev}>{sev.upper()}</span><strong>{i}. {title}</strong></div><div class=finding-body><p><strong>端点:</strong> <code>{ep}</code></p><p>{desc}</p>'
578
+ if poc: report += f'<h4>PoC</h4><pre>{poc}</pre>'
579
+ if rec: report += f'<h4>修复建议</h4><p>{rec}</p>'
580
+ report += '</div></div>'
581
+ report += f'<p style=color:#666;font-size:12px;text-align:center;margin-top:20px>opencode-api-security-testing v5.1.0 | {now}</p></div></body></html>'
582
+ print(report)
310
583
  "`;
311
584
  return await execShell(ctx, cmd);
312
585
  },
@@ -328,14 +601,14 @@ print(result)
328
601
  }
329
602
 
330
603
  // 赛博监工压力注入
331
- if (CYBER_SUPERVISOR.enabled && CYBER_SUPERVISOR.auto_trigger) {
604
+ if (config.cyber_supervisor.enabled && config.cyber_supervisor.auto_trigger) {
332
605
  const failures = getFailureCount(sessionID);
333
- if (failures > 0 && failures <= CYBER_SUPERVISOR.max_retries) {
606
+ if (failures > 0 && failures <= config.cyber_supervisor.max_retries) {
334
607
  const level = Math.min(failures - 1, 4);
335
608
  const supervisorPrompt = `
336
609
 
337
610
  [赛博监工 P9 - 压力等级 L${level}]
338
- 当前失败次数: ${failures}/${CYBER_SUPERVISOR.max_retries}
611
+ 当前失败次数: ${failures}/${config.cyber_supervisor.max_retries}
339
612
  ${LEVEL_PROMPTS[level]}
340
613
 
341
614
  记住三条红线:
@@ -355,6 +628,26 @@ ${LEVEL_PROMPTS[level]}
355
628
  // 赛博监工 Hook - tool.execute.after
356
629
  "tool.execute.after": async (input, output) => {
357
630
  const sessionID = input.sessionID;
631
+ const outputText = output.output || "";
632
+
633
+ // 检测模型错误并触发回退
634
+ const modelError = detectModelError(outputText);
635
+ if (modelError.isModelError && modelError.modelID) {
636
+ incrementModelFailure(sessionID, modelError.modelID);
637
+
638
+ // 获取下一个可用模型
639
+ const isSupervisor = input.toolName?.includes("supervisor") ||
640
+ input.toolName?.includes("scan");
641
+ const nextModel = getNextFallbackModel(config, sessionID, modelError.modelID, isSupervisor);
642
+
643
+ if (nextModel) {
644
+ return {
645
+ ...output,
646
+ output: outputText + `\n\n[模型回退] ${modelError.modelID} 失败,已切换到 ${nextModel}`,
647
+ _fallbackModel: nextModel
648
+ };
649
+ }
650
+ }
358
651
 
359
652
  // 检测工具失败
360
653
  if (output.error) {
@@ -362,7 +655,6 @@ ${LEVEL_PROMPTS[level]}
362
655
  }
363
656
 
364
657
  // 检测放弃模式
365
- const outputText = output.output || "";
366
658
  if (detectGiveUpPattern(outputText)) {
367
659
  incrementFailureCount(sessionID);
368
660
  const failures = getFailureCount(sessionID);
@@ -398,6 +690,7 @@ ${LEVEL_PROMPTS[level]}
398
690
 
399
691
  if (sessionID) {
400
692
  resetFailureCount(sessionID);
693
+ resetModelFailures(sessionID);
401
694
  }
402
695
  }
403
696
  },