opencode-api-security-testing 2.1.0 → 2.1.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.
Files changed (61) hide show
  1. package/SKILL.md +1797 -0
  2. package/core/advanced_recon.py +788 -0
  3. package/core/agentic_analyzer.py +445 -0
  4. package/core/analyzers/api_parser.py +210 -0
  5. package/core/analyzers/response_analyzer.py +212 -0
  6. package/core/analyzers/sensitive_finder.py +184 -0
  7. package/core/api_fuzzer.py +422 -0
  8. package/core/api_interceptor.py +525 -0
  9. package/core/api_parser.py +955 -0
  10. package/core/browser_tester.py +479 -0
  11. package/core/cloud_storage_tester.py +1330 -0
  12. package/core/collectors/__init__.py +23 -0
  13. package/core/collectors/api_path_finder.py +300 -0
  14. package/core/collectors/browser_collect.py +645 -0
  15. package/core/collectors/browser_collector.py +411 -0
  16. package/core/collectors/http_client.py +111 -0
  17. package/core/collectors/js_collector.py +490 -0
  18. package/core/collectors/js_parser.py +780 -0
  19. package/core/collectors/url_collector.py +319 -0
  20. package/core/context_manager.py +682 -0
  21. package/core/deep_api_tester_v35.py +844 -0
  22. package/core/deep_api_tester_v55.py +366 -0
  23. package/core/dynamic_api_analyzer.py +532 -0
  24. package/core/http_client.py +179 -0
  25. package/core/models.py +296 -0
  26. package/core/orchestrator.py +890 -0
  27. package/core/prerequisite.py +227 -0
  28. package/core/reasoning_engine.py +1042 -0
  29. package/core/response_classifier.py +606 -0
  30. package/core/runner.py +938 -0
  31. package/core/scan_engine.py +599 -0
  32. package/core/skill_executor.py +435 -0
  33. package/core/skill_executor_v2.py +670 -0
  34. package/core/skill_executor_v3.py +704 -0
  35. package/core/smart_analyzer.py +687 -0
  36. package/core/strategy_pool.py +707 -0
  37. package/core/testers/auth_tester.py +264 -0
  38. package/core/testers/idor_tester.py +200 -0
  39. package/core/testers/sqli_tester.py +211 -0
  40. package/core/testing_loop.py +655 -0
  41. package/core/utils/base_path_dict.py +255 -0
  42. package/core/utils/payload_lib.py +167 -0
  43. package/core/utils/ssrf_detector.py +220 -0
  44. package/core/verifiers/vuln_verifier.py +536 -0
  45. package/package.json +17 -13
  46. package/references/asset-discovery.md +119 -612
  47. package/references/graphql-guidance.md +65 -641
  48. package/references/intake.md +84 -0
  49. package/references/report-template.md +131 -38
  50. package/references/rest-guidance.md +55 -526
  51. package/references/severity-model.md +52 -264
  52. package/references/test-matrix.md +65 -263
  53. package/references/validation.md +53 -400
  54. package/scripts/postinstall.js +46 -0
  55. package/agents/cyber-supervisor.md +0 -55
  56. package/agents/probing-miner.md +0 -42
  57. package/agents/resource-specialist.md +0 -31
  58. package/commands/api-security-testing-scan.md +0 -59
  59. package/commands/api-security-testing-test.md +0 -49
  60. package/commands/api-security-testing.md +0 -72
  61. package/tsconfig.json +0 -17
@@ -0,0 +1,536 @@
1
+ """
2
+ 漏洞验证器 - 多维度验证
3
+ 确认发现的漏洞是否真实,排除误报
4
+
5
+ 验证维度:
6
+ 1. 响应类型维度:JSON vs HTML vs Empty vs Redirect
7
+ 2. 状态码维度:200 vs 4xx vs 5xx
8
+ 3. 响应长度维度:长度变化检测
9
+ 4. WAF拦截维度:WAF/安全设备拦截检测
10
+ 5. 敏感信息维度:敏感字段泄露检测
11
+ 6. SQL注入维度:SQL错误特征检测
12
+ 7. IDOR维度:用户数据越权检测
13
+ 8. 一致性维度:多次请求响应一致性检测
14
+ 9. 时间维度:时间盲注检测
15
+ 10. 业务数据维度:业务数据真实性检测
16
+ """
17
+
18
+ import requests
19
+ import json
20
+ import time
21
+ import re
22
+
23
+ requests.packages.urllib3.disable_warnings()
24
+
25
+
26
+ def vuln_verifier(config):
27
+ """
28
+ 多维度漏洞验证
29
+
30
+ 输入:
31
+ type: "sqli" | "idor" | "auth_bypass" | "info_leak"
32
+ original_request: object
33
+ suspicious_response: object
34
+ baseline_response?: object - 基线响应(正常请求的响应)
35
+
36
+ 输出:
37
+ verified: boolean
38
+ is_false_positive: boolean
39
+ reason: string
40
+ dimensions: object - 各维度验证结果
41
+ """
42
+ vuln_type = config.get('type')
43
+ original_request = config.get('original_request', {})
44
+ suspicious_response = config.get('suspicious_response', {})
45
+ baseline_response = config.get('baseline_response', {})
46
+
47
+ # 收集各维度验证结果
48
+ dimensions = {}
49
+ is_false_positive = False
50
+ reasons = []
51
+
52
+ # ========== 维度1: 响应类型验证 ==========
53
+ resp_type_result = verify_response_type(suspicious_response)
54
+ dimensions['response_type'] = resp_type_result
55
+ if resp_type_result['is_waf_or_block']:
56
+ is_false_positive = True
57
+ reasons.append(f"响应类型为{resp_type_result['type']},可能是拦截")
58
+
59
+ # ========== 维度2: 状态码验证 ==========
60
+ status_result = verify_status_code(suspicious_response)
61
+ dimensions['status_code'] = status_result
62
+
63
+ # ========== 维度3: 响应长度验证 ==========
64
+ length_result = verify_response_length(suspicious_response, baseline_response)
65
+ dimensions['response_length'] = length_result
66
+ if length_result['is_empty']:
67
+ is_false_positive = True
68
+ reasons.append("响应为空或过短")
69
+
70
+ # ========== 维度4: WAF拦截验证 ==========
71
+ waf_result = verify_waf_block(suspicious_response)
72
+ dimensions['waf_block'] = waf_result
73
+ if waf_result['detected']:
74
+ is_false_positive = True
75
+ reasons.append(f"检测到WAF/拦截: {waf_result['reason']}")
76
+
77
+ # ========== 维度5: 敏感信息验证 ==========
78
+ sensitive_result = verify_sensitive_info(suspicious_response)
79
+ dimensions['sensitive_info'] = sensitive_result
80
+
81
+ # ========== 维度6: 一致性验证 ==========
82
+ if baseline_response:
83
+ consistency_result = verify_consistency(suspicious_response, baseline_response)
84
+ dimensions['consistency'] = consistency_result
85
+ if not consistency_result['is_consistent']:
86
+ is_false_positive = True
87
+ reasons.append("响应与基线不一致,可能是偶发")
88
+
89
+ # ========== 维度7: 基于漏洞类型的专项验证 ==========
90
+ if vuln_type == 'sqli':
91
+ # SQL注入专项验证
92
+ sqli_result = verify_sqli_dimension(suspicious_response)
93
+ dimensions['sqli'] = sqli_result
94
+
95
+ # SQL注入必须满足:响应是JSON + 包含SQL错误特征
96
+ if not sqli_result['has_sql_error']:
97
+ is_false_positive = True
98
+ reasons.append("未发现SQL错误特征")
99
+
100
+ elif vuln_type == 'idor':
101
+ # IDOR专项验证
102
+ idor_result = verify_idor_dimension(suspicious_response)
103
+ dimensions['idor'] = idor_result
104
+
105
+ # IDOR必须满足:返回业务数据 + 不同ID返回不同数据
106
+ if not idor_result['has_user_data']:
107
+ is_false_positive = True
108
+ reasons.append("未发现用户/业务数据")
109
+
110
+ elif vuln_type == 'auth_bypass':
111
+ # 认证绕过专项验证
112
+ auth_result = verify_auth_bypass(suspicious_response)
113
+ dimensions['auth_bypass'] = auth_result
114
+
115
+ if not auth_result['bypassed']:
116
+ is_false_positive = True
117
+ reasons.append("认证绕过未确认")
118
+
119
+ elif vuln_type == 'info_leak':
120
+ # 信息泄露专项验证
121
+ leak_result = verify_info_leak(suspicious_response)
122
+ dimensions['info_leak'] = leak_result
123
+
124
+ if not leak_result['has_leak']:
125
+ is_false_positive = True
126
+ reasons.append("未发现信息泄露")
127
+
128
+ # ========== 最终判定 ==========
129
+ verified = not is_false_positive
130
+
131
+ return {
132
+ 'verified': verified,
133
+ 'is_false_positive': is_false_positive,
134
+ 'reason': '; '.join(reasons) if reasons else '验证通过' if verified else '多项验证失败',
135
+ 'dimensions': dimensions
136
+ }
137
+
138
+
139
+ # ========== 维度1: 响应类型验证 ==========
140
+ def verify_response_type(response):
141
+ """
142
+ 验证响应类型
143
+ 维度说明:JSON=真实API,HTML=WAF/路由/拦截
144
+ """
145
+ body = response.get('body', '')
146
+ headers = response.get('headers', {})
147
+ content_type = headers.get('Content-Type', '')
148
+
149
+ result = {
150
+ 'type': 'unknown',
151
+ 'is_json': False,
152
+ 'is_html': False,
153
+ 'is_empty': False,
154
+ 'is_waf_or_block': False
155
+ }
156
+
157
+ # 检查是否是JSON
158
+ if 'application/json' in content_type.lower():
159
+ try:
160
+ json.loads(body)
161
+ result['is_json'] = True
162
+ result['type'] = 'json'
163
+ except:
164
+ pass
165
+
166
+ if not result['is_json'] and body.strip().startswith('{'):
167
+ try:
168
+ json.loads(body)
169
+ result['is_json'] = True
170
+ result['type'] = 'json'
171
+ except:
172
+ pass
173
+
174
+ # 检查是否是HTML(可能是WAF/路由/拦截)
175
+ if '<!doctype html>' in body.lower() or '<html' in body.lower():
176
+ result['is_html'] = True
177
+ result['type'] = 'html'
178
+ result['is_waf_or_block'] = True
179
+
180
+ # 检查是否为空
181
+ if len(body) < 50:
182
+ result['is_empty'] = True
183
+ result['type'] = 'empty'
184
+ result['is_waf_or_block'] = True
185
+
186
+ return result
187
+
188
+
189
+ # ========== 维度2: 状态码验证 ==========
190
+ def verify_status_code(response):
191
+ """
192
+ 验证状态码
193
+ 维度说明:200=成功,4xx=客户端错误,5xx=服务端错误
194
+ """
195
+ status = response.get('status', 0)
196
+
197
+ result = {
198
+ 'status': status,
199
+ 'is_success': status == 200,
200
+ 'is_client_error': 400 <= status < 500,
201
+ 'is_server_error': status >= 500,
202
+ 'is_redirect': 300 <= status < 400
203
+ }
204
+
205
+ return result
206
+
207
+
208
+ # ========== 维度3: 响应长度验证 ==========
209
+ def verify_response_length(response, baseline=None):
210
+ """
211
+ 验证响应长度
212
+ 维度说明:过短可能是拦截,过长可能是完整数据
213
+ """
214
+ body = response.get('body', '')
215
+ length = len(body)
216
+
217
+ result = {
218
+ 'length': length,
219
+ 'is_empty': length < 50,
220
+ 'is_reasonable': 50 <= length <= 50000,
221
+ 'is_too_long': length > 50000
222
+ }
223
+
224
+ # 与基线对比
225
+ if baseline:
226
+ baseline_length = len(baseline.get('body', ''))
227
+ if baseline_length > 0:
228
+ diff_ratio = abs(length - baseline_length) / max(length, baseline_length)
229
+ result['diff_ratio'] = diff_ratio
230
+ result['significantly_different'] = diff_ratio > 0.8
231
+
232
+ return result
233
+
234
+
235
+ # ========== 维度4: WAF拦截验证 ==========
236
+ def verify_waf_block(response):
237
+ """
238
+ 验证WAF/安全设备拦截
239
+ 维度说明:检测响应是否为WAF或安全设备的拦截页面
240
+ """
241
+ body = response.get('body', '').lower()
242
+ headers = response.get('headers', {})
243
+
244
+ waf_indicators = {
245
+ 'waf': ['waf', 'web应用防火墙', '安全防护', '防火墙'],
246
+ 'block': ['拦截', 'blocked', 'forbidden', '访问受限', 'blocked by'],
247
+ 'security': ['安全中心', '安全狗', '云盾', '安全狗'],
248
+ 'cdn': ['cdn', 'content filter']
249
+ }
250
+
251
+ detected_type = None
252
+ detected_indicators = []
253
+
254
+ for wtype, indicators in waf_indicators.items():
255
+ for indicator in indicators:
256
+ if indicator in body:
257
+ detected_type = wtype
258
+ detected_indicators.append(indicator)
259
+
260
+ # 检查header
261
+ if not detected_type:
262
+ x_powered = headers.get('X-Powered-By', '').lower()
263
+ for wtype, indicators in waf_indicators.items():
264
+ for indicator in indicators:
265
+ if indicator in x_powered:
266
+ detected_type = wtype
267
+ detected_indicators.append(indicator)
268
+
269
+ return {
270
+ 'detected': detected_type is not None,
271
+ 'type': detected_type,
272
+ 'indicators': detected_indicators,
273
+ 'reason': f"检测到{detected_type}: {', '.join(detected_indicators)}" if detected_type else None
274
+ }
275
+
276
+
277
+ # ========== 维度5: 敏感信息验证 ==========
278
+ def verify_sensitive_info(response):
279
+ """
280
+ 验证敏感信息泄露
281
+ 维度说明:检测响应中是否包含敏感字段
282
+ """
283
+ body = response.get('body', '')
284
+
285
+ sensitive_fields = {
286
+ 'password': r'password["\']?\s*[:=]\s*["\']([^"\']{1,50})["\']',
287
+ 'token': r'(?:token|Token|TOKEN)["\']?\s*[:=]\s*["\']([^"\']{10,200})["\']',
288
+ 'jwt': r'eyJ[A-Za-z0-9-_]+\.eyJ[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+',
289
+ 'api_key': r'api[_-]?key["\']?\s*[:=]\s*["\']([^"\']{10,100})["\']',
290
+ 'secret': r'secret["\']?\s*[:=]\s*["\']([^"\']{1,100})["\']',
291
+ 'phone': r'1[3-9]\d{9}',
292
+ 'email': r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}'
293
+ }
294
+
295
+ found = {}
296
+
297
+ try:
298
+ # 尝试JSON解析
299
+ data = json.loads(body)
300
+ body_for_search = json.dumps(data)
301
+ except:
302
+ body_for_search = str(body)
303
+
304
+ for field_name, pattern in sensitive_fields.items():
305
+ matches = re.findall(pattern, body_for_search, re.I)
306
+ if matches:
307
+ # 过滤测试数据
308
+ filtered = [m for m in matches if not is_test_data(m)]
309
+ if filtered:
310
+ found[field_name] = len(filtered)
311
+ except:
312
+ pass
313
+
314
+ return {
315
+ 'found': found,
316
+ 'has_sensitive': len(found) > 0,
317
+ 'count': sum(found.values())
318
+ }
319
+
320
+
321
+ # ========== 维度6: 一致性验证 ==========
322
+ def verify_consistency(response1, response2):
323
+ """
324
+ 验证响应一致性
325
+ 维度说明:多次请求响应应该一致,否则可能是偶发
326
+ """
327
+ status1 = response1.get('status', 0)
328
+ status2 = response2.get('status', 0)
329
+ body1 = response1.get('body', '')
330
+ body2 = response2.get('body', '')
331
+
332
+ result = {
333
+ 'status_consistent': status1 == status2,
334
+ 'body_similar': True,
335
+ 'is_consistent': True
336
+ }
337
+
338
+ # 检查状态码
339
+ if status1 != status2:
340
+ result['status_consistent'] = False
341
+ result['is_consistent'] = False
342
+
343
+ # 检查body相似度
344
+ if body1 and body2:
345
+ len1, len2 = len(body1), len(body2)
346
+ if max(len1, len2) > 0:
347
+ diff_ratio = abs(len1 - len2) / max(len1, len2)
348
+ result['body_diff_ratio'] = diff_ratio
349
+ if diff_ratio > 0.5:
350
+ result['body_similar'] = False
351
+ result['is_consistent'] = False
352
+
353
+ return result
354
+
355
+
356
+ # ========== 维度7: SQL注入专项验证 ==========
357
+ def verify_sqli_dimension(response):
358
+ """
359
+ SQL注入专项验证
360
+ 必须满足:响应是JSON + 包含SQL错误特征
361
+ """
362
+ body = response.get('body', '')
363
+
364
+ # SQL错误特征
365
+ sql_errors = [
366
+ 'sql syntax', 'syntax error', 'mysql', 'postgresql', 'oracle',
367
+ 'sqlite', 'sqlstate', 'microsoft sql', 'sql error', 'sqlsrv',
368
+ 'odbc driver', 'mariadb', 'access denied for',
369
+ 'sql_injection', 'sql injection'
370
+ ]
371
+
372
+ has_sql_error = False
373
+ detected_error = None
374
+
375
+ body_lower = body.lower()
376
+ for error in sql_errors:
377
+ if error in body_lower:
378
+ has_sql_error = True
379
+ detected_error = error
380
+ break
381
+
382
+ # 检查是否是JSON格式的错误响应
383
+ is_json_error = False
384
+ try:
385
+ data = json.loads(body)
386
+ if isinstance(data, dict):
387
+ msg = str(data.get('msg', '')).lower()
388
+ for error in sql_errors:
389
+ if error in msg:
390
+ is_json_error = True
391
+ break
392
+ # 检查code是否为错误码
393
+ if data.get('code') and data.get('code') not in [200, 0, '200', '0']:
394
+ is_json_error = True
395
+ except:
396
+ pass
397
+
398
+ return {
399
+ 'has_sql_error': has_sql_error,
400
+ 'error_type': detected_error,
401
+ 'is_json_error': is_json_error,
402
+ 'confirmed': has_sql_error and is_json_error
403
+ }
404
+
405
+
406
+ # ========== 维度8: IDOR专项验证 ==========
407
+ def verify_idor_dimension(response):
408
+ """
409
+ IDOR专项验证
410
+ 必须满足:返回业务数据 + 数据随ID变化
411
+ """
412
+ body = response.get('body', '')
413
+
414
+ # 业务字段
415
+ business_fields = [
416
+ 'user', 'username', 'userId', 'user_id', 'name',
417
+ 'phone', 'mobile', 'email',
418
+ 'order', 'orderId', 'order_no', 'orderNo',
419
+ 'balance', 'amount', 'money',
420
+ 'id', '_id', 'createBy', 'create_by'
421
+ ]
422
+
423
+ has_user_data = False
424
+ matched_fields = []
425
+
426
+ try:
427
+ data = json.loads(body)
428
+ data_str = json.dumps(data).lower()
429
+
430
+ for field in business_fields:
431
+ if field.lower() in data_str:
432
+ has_user_data = True
433
+ matched_fields.append(field)
434
+ except:
435
+ pass
436
+
437
+ return {
438
+ 'has_user_data': has_user_data,
439
+ 'matched_fields': matched_fields,
440
+ 'confirmed': has_user_data
441
+ }
442
+
443
+
444
+ # ========== 维度9: 认证绕过专项验证 ==========
445
+ def verify_auth_bypass(response):
446
+ """
447
+ 认证绕过专项验证
448
+ 必须满足:返回token或session
449
+ """
450
+ body = response.get('body', '')
451
+
452
+ has_token = False
453
+ has_session = False
454
+
455
+ # token特征
456
+ token_patterns = [
457
+ r'token["\']?\s*[:=]\s*["\']([^"\']{10,})',
458
+ r'access_token["\']?\s*[:=]\s*["\']([^"\']{10,})',
459
+ r'session_id["\']?\s*[:=]\s*["\']([^"\']{10,})',
460
+ r'Bearer\s+[a-zA-Z0-9\-_\.]+'
461
+ ]
462
+
463
+ for pattern in token_patterns:
464
+ if re.search(pattern, body, re.I):
465
+ has_token = True
466
+ break
467
+
468
+ # 检查是否是成功登录的响应
469
+ try:
470
+ data = json.loads(body)
471
+ if data.get('success') == True or data.get('code') == 0:
472
+ if data.get('token') or data.get('data', {}).get('token'):
473
+ has_token = True
474
+ except:
475
+ pass
476
+
477
+ return {
478
+ 'has_token': has_token,
479
+ 'has_session': has_session,
480
+ 'bypassed': has_token
481
+ }
482
+
483
+
484
+ # ========== 维度10: 信息泄露专项验证 ==========
485
+ def verify_info_leak(response):
486
+ """
487
+ 信息泄露专项验证
488
+ 必须满足:返回非公开的业务信息
489
+ """
490
+ body = response.get('body', '')
491
+
492
+ # 非公开信息特征
493
+ private_info = [
494
+ 'password', 'secret', 'api_key', 'apiKey',
495
+ 'token', 'session', 'private',
496
+ 'phone', 'email', 'id_card', '身份证'
497
+ ]
498
+
499
+ found = []
500
+
501
+ body_lower = body.lower()
502
+ for info in private_info:
503
+ if info in body_lower:
504
+ found.append(info)
505
+
506
+ return {
507
+ 'found': found,
508
+ 'has_leak': len(found) > 0,
509
+ 'confirmed': len(found) > 0
510
+ }
511
+
512
+
513
+ # ========== 辅助函数 ==========
514
+ def is_test_data(value):
515
+ """判断是否是测试数据"""
516
+ test_patterns = [
517
+ 'test', 'TEST', 'Test',
518
+ 'xxx', 'xxx.xxx',
519
+ 'null', 'undefined',
520
+ 'example', 'sample',
521
+ 'placeholder', 'demo'
522
+ ]
523
+ value_lower = str(value).lower()
524
+ return any(t in value_lower for t in test_patterns)
525
+
526
+
527
+ if __name__ == '__main__':
528
+ # 测试
529
+ result = vuln_verifier({
530
+ 'type': 'sqli',
531
+ 'original_request': {'url': 'http://example.com/api/login', 'method': 'POST'},
532
+ 'suspicious_response': {'status': 200, 'body': '{"error": "success"}'}
533
+ })
534
+ print(f"Verified: {result['verified']}")
535
+ print(f"False Positive: {result['is_false_positive']}")
536
+ print(f"Dimensions: {list(result['dimensions'].keys())}")
package/package.json CHANGED
@@ -1,20 +1,19 @@
1
1
  {
2
2
  "name": "opencode-api-security-testing",
3
- "version": "2.1.0",
3
+ "version": "2.1.1",
4
4
  "description": "API Security Testing Plugin for OpenCode - Automated vulnerability scanning and penetration testing",
5
5
  "type": "module",
6
- "main": "dist/index.js",
7
- "types": "dist/index.d.ts",
8
- "exports": {
9
- ".": {
10
- "types": "./dist/index.d.ts",
11
- "import": "./dist/index.js"
12
- }
13
- },
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "files": [
9
+ "src/",
10
+ "core/",
11
+ "references/",
12
+ "SKILL.md",
13
+ "scripts/"
14
+ ],
14
15
  "scripts": {
15
- "build": "bun build src/index.ts --outdir dist --target bun --format esm && tsc --emitDeclarationOnly",
16
- "dev": "bun build src/index.ts --outdir dist --target bun --format esm --watch",
17
- "typecheck": "tsc --noEmit"
16
+ "postinstall": "node scripts/postinstall.js"
18
17
  },
19
18
  "keywords": [
20
19
  "opencode",
@@ -24,8 +23,13 @@
24
23
  "pentest",
25
24
  "vulnerability-scanning"
26
25
  ],
27
- "author": "",
26
+ "author": "steveopen1",
28
27
  "license": "MIT",
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "https://github.com/steveopen1/skill-play"
31
+ },
32
+ "homepage": "https://github.com/steveopen1/skill-play/tree/main/agent-plugins/OPENCODE/api-security-testing",
29
33
  "peerDependencies": {
30
34
  "@opencode-ai/plugin": "^1.1.19",
31
35
  "@opencode-ai/sdk": "^1.1.19"