@pwddd/skills-scanner 1.0.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.
Potentially problematic release.
This version of @pwddd/skills-scanner might be problematic. Click here for more details.
- package/README.md +392 -0
- package/index.ts +373 -0
- package/openclaw.plugin.json +60 -0
- package/package.json +49 -0
- package/skills/skills-scanner/SKILL.md +180 -0
- package/skills/skills-scanner/scan.py +373 -0
- package/src/commands.ts +277 -0
- package/src/config.ts +170 -0
- package/src/cron.ts +143 -0
- package/src/deps.ts +73 -0
- package/src/prompt-guidance.ts +25 -0
- package/src/report.ts +100 -0
- package/src/scanner.ts +50 -0
- package/src/state.ts +70 -0
- package/src/types.ts +48 -0
- package/src/watcher.ts +125 -0
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
# /// script
|
|
2
|
+
# dependencies = [
|
|
3
|
+
# "requests>=2.31.0",
|
|
4
|
+
# ]
|
|
5
|
+
# ///
|
|
6
|
+
"""
|
|
7
|
+
OpenClaw Skills 安全扫描器 (HTTP 客户端)
|
|
8
|
+
通过 HTTP API 调用远程 skill-scanner-api 服务
|
|
9
|
+
|
|
10
|
+
注意:此脚本必须使用 venv 中的 Python 运行
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import sys
|
|
14
|
+
import os
|
|
15
|
+
import json
|
|
16
|
+
import argparse
|
|
17
|
+
import tempfile
|
|
18
|
+
import zipfile
|
|
19
|
+
import time
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Optional, Dict, Any, List
|
|
22
|
+
|
|
23
|
+
# 依赖检查
|
|
24
|
+
try:
|
|
25
|
+
import requests
|
|
26
|
+
except ImportError as e:
|
|
27
|
+
print("❌ requests 未安装。")
|
|
28
|
+
print(f" 导入错误: {e}")
|
|
29
|
+
print(" 请运行: uv pip install requests")
|
|
30
|
+
sys.exit(1)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# 配置
|
|
34
|
+
DEFAULT_API_URL = "http://localhost:8000"
|
|
35
|
+
REQUEST_TIMEOUT = 180 # 3 分钟
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# 颜色输出
|
|
39
|
+
USE_COLOR = sys.stdout.isatty()
|
|
40
|
+
|
|
41
|
+
def c(text, code):
|
|
42
|
+
return f"\033[{code}m{text}\033[0m" if USE_COLOR else text
|
|
43
|
+
|
|
44
|
+
RED = lambda t: c(t, "31")
|
|
45
|
+
YELLOW = lambda t: c(t, "33")
|
|
46
|
+
GREEN = lambda t: c(t, "32")
|
|
47
|
+
CYAN = lambda t: c(t, "36")
|
|
48
|
+
BOLD = lambda t: c(t, "1")
|
|
49
|
+
DIM = lambda t: c(t, "2")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# HTTP 客户端
|
|
53
|
+
class SkillScannerClient:
|
|
54
|
+
"""skill-scanner HTTP API 客户端"""
|
|
55
|
+
|
|
56
|
+
def __init__(self, base_url: str = DEFAULT_API_URL):
|
|
57
|
+
self.base_url = base_url.rstrip('/')
|
|
58
|
+
self.session = requests.Session()
|
|
59
|
+
|
|
60
|
+
def health_check(self) -> Dict[str, Any]:
|
|
61
|
+
"""健康检查,返回详细信息"""
|
|
62
|
+
try:
|
|
63
|
+
response = self.session.get(f"{self.base_url}/health", timeout=5)
|
|
64
|
+
response.raise_for_status()
|
|
65
|
+
return {
|
|
66
|
+
'status': 'healthy',
|
|
67
|
+
'data': response.json()
|
|
68
|
+
}
|
|
69
|
+
except requests.exceptions.ConnectionError:
|
|
70
|
+
return {
|
|
71
|
+
'status': 'unreachable',
|
|
72
|
+
'error': f'无法连接到 {self.base_url}'
|
|
73
|
+
}
|
|
74
|
+
except requests.exceptions.Timeout:
|
|
75
|
+
return {
|
|
76
|
+
'status': 'timeout',
|
|
77
|
+
'error': '请求超时'
|
|
78
|
+
}
|
|
79
|
+
except Exception as e:
|
|
80
|
+
return {
|
|
81
|
+
'status': 'error',
|
|
82
|
+
'error': str(e)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
def scan_upload(
|
|
86
|
+
self,
|
|
87
|
+
skill_path: str,
|
|
88
|
+
policy: str = "balanced",
|
|
89
|
+
use_llm: bool = False,
|
|
90
|
+
use_behavioral: bool = False
|
|
91
|
+
) -> Dict[str, Any]:
|
|
92
|
+
"""上传 ZIP 文件扫描(单个 Skill)
|
|
93
|
+
|
|
94
|
+
API: POST /scan-upload
|
|
95
|
+
- 上传 ZIP 文件
|
|
96
|
+
- 服务器解压并查找 SKILL.md
|
|
97
|
+
- 返回扫描结果
|
|
98
|
+
"""
|
|
99
|
+
with tempfile.NamedTemporaryFile(suffix='.zip', delete=False) as tmp_zip:
|
|
100
|
+
zip_path = tmp_zip.name
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
self._create_zip(skill_path, zip_path)
|
|
104
|
+
|
|
105
|
+
with open(zip_path, 'rb') as f:
|
|
106
|
+
files = {'file': (os.path.basename(skill_path) + '.zip', f, 'application/zip')}
|
|
107
|
+
data = {
|
|
108
|
+
'policy': policy,
|
|
109
|
+
'use_llm': str(use_llm).lower(),
|
|
110
|
+
'use_behavioral': str(use_behavioral).lower()
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
response = self.session.post(
|
|
114
|
+
f"{self.base_url}/scan-upload",
|
|
115
|
+
files=files,
|
|
116
|
+
data=data,
|
|
117
|
+
timeout=REQUEST_TIMEOUT
|
|
118
|
+
)
|
|
119
|
+
response.raise_for_status()
|
|
120
|
+
return response.json()
|
|
121
|
+
finally:
|
|
122
|
+
if os.path.exists(zip_path):
|
|
123
|
+
os.unlink(zip_path)
|
|
124
|
+
|
|
125
|
+
def scan_batch_upload(
|
|
126
|
+
self,
|
|
127
|
+
skill_paths: List[str],
|
|
128
|
+
policy: str = "balanced",
|
|
129
|
+
use_llm: bool = False,
|
|
130
|
+
use_behavioral: bool = False
|
|
131
|
+
) -> List[Dict[str, Any]]:
|
|
132
|
+
"""批量上传多个 Skill(客户端循环)"""
|
|
133
|
+
results = []
|
|
134
|
+
|
|
135
|
+
for i, skill_path in enumerate(skill_paths, 1):
|
|
136
|
+
print(f"[{i}/{len(skill_paths)}] 正在扫描: {skill_path}")
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
result = self.scan_upload(
|
|
140
|
+
skill_path,
|
|
141
|
+
policy=policy,
|
|
142
|
+
use_llm=use_llm,
|
|
143
|
+
use_behavioral=use_behavioral
|
|
144
|
+
)
|
|
145
|
+
results.append({
|
|
146
|
+
'path': skill_path,
|
|
147
|
+
'success': True,
|
|
148
|
+
'result': result
|
|
149
|
+
})
|
|
150
|
+
status = "✓" if result.get('is_safe', False) else "✗"
|
|
151
|
+
print(f" {status} {result.get('skill_name', 'Unknown')}: {result.get('findings_count', 0)} 个发现")
|
|
152
|
+
except Exception as e:
|
|
153
|
+
results.append({
|
|
154
|
+
'path': skill_path,
|
|
155
|
+
'success': False,
|
|
156
|
+
'error': str(e)
|
|
157
|
+
})
|
|
158
|
+
print(f" ✗ 失败: {e}")
|
|
159
|
+
|
|
160
|
+
return results
|
|
161
|
+
|
|
162
|
+
@staticmethod
|
|
163
|
+
def _create_zip(source_dir: str, zip_path: str):
|
|
164
|
+
"""创建 ZIP 文件"""
|
|
165
|
+
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
|
|
166
|
+
source_path = Path(source_dir)
|
|
167
|
+
for file_path in source_path.rglob('*'):
|
|
168
|
+
if file_path.is_file():
|
|
169
|
+
arcname = file_path.relative_to(source_path.parent)
|
|
170
|
+
zipf.write(file_path, arcname)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# 格式化输出
|
|
174
|
+
def format_scan_result(result: Dict[str, Any], detailed: bool = False) -> str:
|
|
175
|
+
"""格式化扫描结果"""
|
|
176
|
+
lines = []
|
|
177
|
+
|
|
178
|
+
skill_name = result.get('skill_name', 'Unknown')
|
|
179
|
+
is_safe = result.get('is_safe', False)
|
|
180
|
+
max_severity = result.get('max_severity', 'NONE')
|
|
181
|
+
findings_count = result.get('findings_count', 0)
|
|
182
|
+
|
|
183
|
+
status_icon = GREEN("✓") if is_safe else RED("✗")
|
|
184
|
+
lines.append(f"{status_icon} {BOLD(skill_name)}")
|
|
185
|
+
lines.append(f" 严重性: {_severity_color(max_severity)}")
|
|
186
|
+
lines.append(f" 发现数: {findings_count}")
|
|
187
|
+
|
|
188
|
+
if detailed and findings_count > 0:
|
|
189
|
+
findings = result.get('findings', [])
|
|
190
|
+
lines.append("")
|
|
191
|
+
lines.append(BOLD("发现详情:"))
|
|
192
|
+
for i, finding in enumerate(findings[:10], 1):
|
|
193
|
+
severity = finding.get('severity', 'UNKNOWN')
|
|
194
|
+
category = finding.get('category', 'Unknown')
|
|
195
|
+
description = finding.get('description', 'No description')
|
|
196
|
+
lines.append(f" {i}. [{_severity_color(severity)}] {category}")
|
|
197
|
+
lines.append(f" {description}")
|
|
198
|
+
|
|
199
|
+
if len(findings) > 10:
|
|
200
|
+
lines.append(f" ... 还有 {len(findings) - 10} 条发现")
|
|
201
|
+
|
|
202
|
+
return "\n".join(lines)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def format_batch_result(result: Dict[str, Any]) -> str:
|
|
206
|
+
"""格式化批量扫描结果"""
|
|
207
|
+
lines = []
|
|
208
|
+
|
|
209
|
+
total = result.get('total_skills_scanned', 0)
|
|
210
|
+
safe = result.get('safe_skills', 0)
|
|
211
|
+
unsafe = result.get('unsafe_skills', 0)
|
|
212
|
+
|
|
213
|
+
lines.append(BOLD("批量扫描结果"))
|
|
214
|
+
lines.append(f" 总计: {total} 个 Skills")
|
|
215
|
+
lines.append(f" 安全: {GREEN(str(safe))}")
|
|
216
|
+
lines.append(f" 问题: {RED(str(unsafe))}")
|
|
217
|
+
|
|
218
|
+
if unsafe > 0:
|
|
219
|
+
skills = result.get('skills', [])
|
|
220
|
+
unsafe_skills = [s for s in skills if not s.get('is_safe', True)]
|
|
221
|
+
lines.append("")
|
|
222
|
+
lines.append(BOLD("问题 Skills:"))
|
|
223
|
+
for skill in unsafe_skills[:10]:
|
|
224
|
+
name = skill.get('skill_name', 'Unknown')
|
|
225
|
+
severity = skill.get('max_severity', 'UNKNOWN')
|
|
226
|
+
count = skill.get('findings_count', 0)
|
|
227
|
+
lines.append(f" • {name} [{_severity_color(severity)}] - {count} 条发现")
|
|
228
|
+
|
|
229
|
+
return "\n".join(lines)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _severity_color(severity: str) -> str:
|
|
233
|
+
"""严重性着色"""
|
|
234
|
+
severity_upper = severity.upper()
|
|
235
|
+
if severity_upper in ('CRITICAL', 'HIGH'):
|
|
236
|
+
return RED(severity_upper)
|
|
237
|
+
elif severity_upper == 'MEDIUM':
|
|
238
|
+
return YELLOW(severity_upper)
|
|
239
|
+
elif severity_upper == 'LOW':
|
|
240
|
+
return CYAN(severity_upper)
|
|
241
|
+
else:
|
|
242
|
+
return DIM(severity_upper)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
# 命令行接口
|
|
246
|
+
def main():
|
|
247
|
+
parser = argparse.ArgumentParser(
|
|
248
|
+
description="OpenClaw Skills 安全扫描器 (HTTP 客户端)",
|
|
249
|
+
formatter_class=argparse.RawDescriptionHelpFormatter
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
parser.add_argument(
|
|
253
|
+
'--api-url',
|
|
254
|
+
default=DEFAULT_API_URL,
|
|
255
|
+
help=f'API 服务地址 (默认: {DEFAULT_API_URL})'
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
subparsers = parser.add_subparsers(dest='command', help='命令')
|
|
259
|
+
|
|
260
|
+
# scan 命令
|
|
261
|
+
scan_parser = subparsers.add_parser('scan', help='扫描单个 Skill(上传 ZIP)')
|
|
262
|
+
scan_parser.add_argument('path', help='Skill 目录路径')
|
|
263
|
+
scan_parser.add_argument('--detailed', action='store_true', help='显示详细发现')
|
|
264
|
+
scan_parser.add_argument('--behavioral', action='store_true', help='启用行为分析')
|
|
265
|
+
scan_parser.add_argument('--llm', action='store_true', help='启用 LLM 分析')
|
|
266
|
+
scan_parser.add_argument('--policy', default='balanced', choices=['strict', 'balanced', 'permissive'], help='扫描策略')
|
|
267
|
+
scan_parser.add_argument('--json', metavar='FILE', help='输出 JSON 到文件')
|
|
268
|
+
|
|
269
|
+
# batch 命令(客户端批量上传)
|
|
270
|
+
batch_parser = subparsers.add_parser('batch', help='批量扫描多个 Skills(客户端循环)')
|
|
271
|
+
batch_parser.add_argument('paths', nargs='+', help='多个 Skill 目录路径')
|
|
272
|
+
batch_parser.add_argument('--behavioral', action='store_true', help='启用行为分析')
|
|
273
|
+
batch_parser.add_argument('--llm', action='store_true', help='启用 LLM 分析')
|
|
274
|
+
batch_parser.add_argument('--policy', default='balanced', choices=['strict', 'balanced', 'permissive'], help='扫描策略')
|
|
275
|
+
batch_parser.add_argument('--json', metavar='FILE', help='输出 JSON 到文件')
|
|
276
|
+
|
|
277
|
+
# health 命令
|
|
278
|
+
subparsers.add_parser('health', help='健康检查')
|
|
279
|
+
|
|
280
|
+
args = parser.parse_args()
|
|
281
|
+
|
|
282
|
+
if not args.command:
|
|
283
|
+
parser.print_help()
|
|
284
|
+
sys.exit(1)
|
|
285
|
+
|
|
286
|
+
client = SkillScannerClient(args.api_url)
|
|
287
|
+
|
|
288
|
+
try:
|
|
289
|
+
if args.command == 'health':
|
|
290
|
+
health_result = client.health_check()
|
|
291
|
+
|
|
292
|
+
if health_result['status'] == 'healthy':
|
|
293
|
+
print(GREEN("✓") + " API 服务正常")
|
|
294
|
+
|
|
295
|
+
data = health_result.get('data', {})
|
|
296
|
+
if data:
|
|
297
|
+
print(f" 版本: {data.get('version', 'Unknown')}")
|
|
298
|
+
analyzers = data.get('analyzers_available', [])
|
|
299
|
+
if analyzers:
|
|
300
|
+
print(f" 可用分析器: {', '.join(analyzers)}")
|
|
301
|
+
print(json.dumps(data))
|
|
302
|
+
|
|
303
|
+
sys.exit(0)
|
|
304
|
+
else:
|
|
305
|
+
print(RED("✗") + f" API 服务不可用: {args.api_url}")
|
|
306
|
+
error = health_result.get('error', '未知错误')
|
|
307
|
+
print(f" 错误: {error}")
|
|
308
|
+
sys.exit(1)
|
|
309
|
+
|
|
310
|
+
elif args.command == 'scan':
|
|
311
|
+
print(f"正在扫描: {args.path}")
|
|
312
|
+
result = client.scan_upload(
|
|
313
|
+
args.path,
|
|
314
|
+
policy=args.policy,
|
|
315
|
+
use_llm=args.llm,
|
|
316
|
+
use_behavioral=args.behavioral
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
if args.json:
|
|
320
|
+
with open(args.json, 'w') as f:
|
|
321
|
+
json.dump(result, f, indent=2)
|
|
322
|
+
print(f"结果已保存到: {args.json}")
|
|
323
|
+
else:
|
|
324
|
+
print(format_scan_result(result, args.detailed))
|
|
325
|
+
|
|
326
|
+
sys.exit(0 if result.get('is_safe', False) else 1)
|
|
327
|
+
|
|
328
|
+
elif args.command == 'batch':
|
|
329
|
+
print(f"正在批量扫描 {len(args.paths)} 个 Skills...")
|
|
330
|
+
results = client.scan_batch_upload(
|
|
331
|
+
args.paths,
|
|
332
|
+
policy=args.policy,
|
|
333
|
+
use_llm=args.llm,
|
|
334
|
+
use_behavioral=args.behavioral
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
total = len(results)
|
|
338
|
+
success = sum(1 for r in results if r['success'])
|
|
339
|
+
failed = total - success
|
|
340
|
+
|
|
341
|
+
if args.json:
|
|
342
|
+
with open(args.json, 'w') as f:
|
|
343
|
+
json.dump(results, f, indent=2)
|
|
344
|
+
print(f"\n结果已保存到: {args.json}")
|
|
345
|
+
|
|
346
|
+
print(f"\n批量扫描完成: {success}/{total} 成功, {failed} 失败")
|
|
347
|
+
sys.exit(0 if failed == 0 else 1)
|
|
348
|
+
|
|
349
|
+
except requests.exceptions.ConnectionError:
|
|
350
|
+
print(RED("✗") + f" 无法连接到 API 服务: {args.api_url}")
|
|
351
|
+
print("请确保 skill-scanner-api 服务正在运行")
|
|
352
|
+
sys.exit(1)
|
|
353
|
+
except requests.exceptions.Timeout:
|
|
354
|
+
print(RED("✗") + " 请求超时")
|
|
355
|
+
sys.exit(1)
|
|
356
|
+
except requests.exceptions.HTTPError as e:
|
|
357
|
+
print(RED("✗") + f" HTTP 错误: {e}")
|
|
358
|
+
if e.response is not None:
|
|
359
|
+
try:
|
|
360
|
+
error_detail = e.response.json()
|
|
361
|
+
print(f"详情: {error_detail}")
|
|
362
|
+
except:
|
|
363
|
+
print(f"响应: {e.response.text}")
|
|
364
|
+
sys.exit(1)
|
|
365
|
+
except Exception as e:
|
|
366
|
+
print(RED("✗") + f" 错误: {e}")
|
|
367
|
+
import traceback
|
|
368
|
+
traceback.print_exc()
|
|
369
|
+
sys.exit(1)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
if __name__ == '__main__':
|
|
373
|
+
main()
|
package/src/commands.ts
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Command handlers module
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { existsSync, execSync } from "node:fs";
|
|
6
|
+
import { join, basename } from "node:path";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
import { exec } from "node:child_process";
|
|
9
|
+
import { runScan } from "./scanner.js";
|
|
10
|
+
import { buildDailyReport } from "./report.js";
|
|
11
|
+
import { loadState, saveState, expandPath } from "./state.js";
|
|
12
|
+
import { isVenvReady } from "./deps.js";
|
|
13
|
+
import { generateConfigGuide } from "./config.js";
|
|
14
|
+
import { ensureCronJob } from "./cron.js";
|
|
15
|
+
import type { ScannerConfig } from "./types.js";
|
|
16
|
+
|
|
17
|
+
const execAsync = promisify(exec);
|
|
18
|
+
|
|
19
|
+
export function createCommandHandlers(
|
|
20
|
+
cfg: ScannerConfig,
|
|
21
|
+
apiUrl: string,
|
|
22
|
+
scanDirs: string[],
|
|
23
|
+
behavioral: boolean,
|
|
24
|
+
useLLM: boolean,
|
|
25
|
+
policy: string,
|
|
26
|
+
preInstallScan: string,
|
|
27
|
+
onUnsafe: string,
|
|
28
|
+
venvPython: string,
|
|
29
|
+
scanScript: string,
|
|
30
|
+
logger: any
|
|
31
|
+
) {
|
|
32
|
+
async function handleScanCommand(args: string): Promise<any> {
|
|
33
|
+
if (!args) {
|
|
34
|
+
return {
|
|
35
|
+
text: "用法:`/skills-scanner scan <路径> [--detailed] [--behavioral] [--recursive] [--report]`",
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (!isVenvReady(venvPython)) {
|
|
40
|
+
return { text: "⏳ Python 依赖尚未就绪,请稍后重试或查看日志" };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const parts = args.split(/\s+/);
|
|
44
|
+
const targetPath = expandPath(parts.find((p) => !p.startsWith("--")) ?? "");
|
|
45
|
+
const detailed = parts.includes("--detailed");
|
|
46
|
+
const useBehav = parts.includes("--behavioral") || behavioral;
|
|
47
|
+
const recursive = parts.includes("--recursive");
|
|
48
|
+
const isReport = parts.includes("--report");
|
|
49
|
+
|
|
50
|
+
if (!targetPath) {
|
|
51
|
+
return { text: "❌ 请指定扫描路径" };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (!existsSync(targetPath)) {
|
|
55
|
+
return { text: `❌ 路径不存在: ${targetPath}` };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const isSingleSkill = existsSync(join(targetPath, "SKILL.md"));
|
|
59
|
+
|
|
60
|
+
if (isReport) {
|
|
61
|
+
if (scanDirs.length === 0) {
|
|
62
|
+
return { text: "⚠️ 未找到可扫描目录,请检查配置" };
|
|
63
|
+
}
|
|
64
|
+
const report = await buildDailyReport(
|
|
65
|
+
scanDirs,
|
|
66
|
+
useBehav,
|
|
67
|
+
apiUrl,
|
|
68
|
+
useLLM,
|
|
69
|
+
policy,
|
|
70
|
+
logger,
|
|
71
|
+
venvPython,
|
|
72
|
+
scanScript
|
|
73
|
+
);
|
|
74
|
+
return { text: report };
|
|
75
|
+
} else if (isSingleSkill) {
|
|
76
|
+
const res = await runScan(venvPython, scanScript, "scan", targetPath, {
|
|
77
|
+
detailed,
|
|
78
|
+
behavioral: useBehav,
|
|
79
|
+
apiUrl,
|
|
80
|
+
useLLM,
|
|
81
|
+
policy,
|
|
82
|
+
});
|
|
83
|
+
const icon = res.exitCode === 0 ? "✅" : "❌";
|
|
84
|
+
return { text: `${icon} 扫描完成\n\`\`\`\n${res.output}\n\`\`\`` };
|
|
85
|
+
} else {
|
|
86
|
+
const res = await runScan(venvPython, scanScript, "batch", targetPath, {
|
|
87
|
+
recursive,
|
|
88
|
+
detailed,
|
|
89
|
+
behavioral: useBehav,
|
|
90
|
+
apiUrl,
|
|
91
|
+
useLLM,
|
|
92
|
+
policy,
|
|
93
|
+
});
|
|
94
|
+
const icon = res.exitCode === 0 ? "✅" : "❌";
|
|
95
|
+
return { text: `${icon} 批量扫描完成\n\`\`\`\n${res.output}\n\`\`\`` };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function handleStatusCommand(): Promise<any> {
|
|
100
|
+
const state = loadState() as any;
|
|
101
|
+
const alerts: string[] = state.pendingAlerts ?? [];
|
|
102
|
+
|
|
103
|
+
const lines = [
|
|
104
|
+
"📋 *Skills Scanner 状态*",
|
|
105
|
+
`API 地址: ${apiUrl}`,
|
|
106
|
+
`Python 依赖: ${isVenvReady(venvPython) ? "✅ 就绪" : "❌ 未就绪"}`,
|
|
107
|
+
`安装前扫描: ${preInstallScan === "on" ? `✅ 监听中 (${onUnsafe})` : "❌ 已禁用"}`,
|
|
108
|
+
`扫描策略: ${policy}`,
|
|
109
|
+
`LLM 分析: ${useLLM ? "✅ 启用" : "❌ 禁用"}`,
|
|
110
|
+
`行为分析: ${behavioral ? "✅ 启用" : "❌ 禁用"}`,
|
|
111
|
+
`上次扫描: ${state.lastScanAt ? new Date(state.lastScanAt).toLocaleString("zh-CN") : "从未"}`,
|
|
112
|
+
`扫描目录:\n${scanDirs.map((d) => ` • ${d}`).join("\n")}`,
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
if (isVenvReady(venvPython)) {
|
|
116
|
+
lines.push("", "🔍 *API 服务检查*");
|
|
117
|
+
try {
|
|
118
|
+
const cmd = `"${venvPython}" "${scanScript}" --api-url "${apiUrl}" health`;
|
|
119
|
+
const env = { ...process.env };
|
|
120
|
+
delete env.http_proxy;
|
|
121
|
+
delete env.https_proxy;
|
|
122
|
+
delete env.HTTP_PROXY;
|
|
123
|
+
delete env.HTTPS_PROXY;
|
|
124
|
+
delete env.all_proxy;
|
|
125
|
+
delete env.ALL_PROXY;
|
|
126
|
+
|
|
127
|
+
const { stdout, stderr } = await execAsync(cmd, { timeout: 5000, env });
|
|
128
|
+
const output = (stdout + stderr).trim();
|
|
129
|
+
|
|
130
|
+
if (output.includes("✓") || output.includes("OK")) {
|
|
131
|
+
lines.push(`API 服务: ✅ 正常`);
|
|
132
|
+
const jsonMatch = output.match(/\{[\s\S]*\}/);
|
|
133
|
+
if (jsonMatch) {
|
|
134
|
+
try {
|
|
135
|
+
const healthData = JSON.parse(jsonMatch[0]);
|
|
136
|
+
if (healthData.analyzers_available) {
|
|
137
|
+
lines.push(`可用分析器: ${healthData.analyzers_available.join(", ")}`);
|
|
138
|
+
}
|
|
139
|
+
} catch {}
|
|
140
|
+
}
|
|
141
|
+
} else {
|
|
142
|
+
lines.push(`API 服务: ❌ 不可用`);
|
|
143
|
+
}
|
|
144
|
+
} catch (err: any) {
|
|
145
|
+
lines.push(`API 服务: ❌ 连接失败`);
|
|
146
|
+
lines.push(`错误: ${err.message}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (alerts.length > 0) {
|
|
151
|
+
lines.push("", `🔔 *待查告警 (${alerts.length} 条):*`);
|
|
152
|
+
alerts.slice(-5).forEach((a) => lines.push(` ${a}`));
|
|
153
|
+
saveState({ ...state, pendingAlerts: [] });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
lines.push("", "🕐 *定时任务*");
|
|
157
|
+
if (state.cronJobId && state.cronJobId !== "manual-created") {
|
|
158
|
+
lines.push(`状态: ✅ 已注册 (${state.cronJobId})`);
|
|
159
|
+
} else {
|
|
160
|
+
lines.push("状态: ❌ 未注册");
|
|
161
|
+
lines.push("💡 使用 `/skills-scanner cron register` 注册");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return { text: lines.join("\n") };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function handleConfigCommand(args: string): Promise<any> {
|
|
168
|
+
const action = args.trim().toLowerCase() || "show";
|
|
169
|
+
|
|
170
|
+
if (action === "show" || action === "") {
|
|
171
|
+
const configGuide = generateConfigGuide(
|
|
172
|
+
cfg,
|
|
173
|
+
apiUrl,
|
|
174
|
+
scanDirs,
|
|
175
|
+
behavioral,
|
|
176
|
+
useLLM,
|
|
177
|
+
policy,
|
|
178
|
+
preInstallScan,
|
|
179
|
+
onUnsafe
|
|
180
|
+
);
|
|
181
|
+
return { text: "```\n" + configGuide + "\n```" };
|
|
182
|
+
} else if (action === "reset") {
|
|
183
|
+
const state = loadState() as any;
|
|
184
|
+
saveState({ ...state, configReviewed: false });
|
|
185
|
+
return {
|
|
186
|
+
text: "✅ 配置审查标记已重置\n下次重启 Gateway 时将再次显示配置向导",
|
|
187
|
+
};
|
|
188
|
+
} else {
|
|
189
|
+
return { text: "用法: `/skills-scanner config [show|reset]`" };
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function handleCronCommand(args: string): Promise<any> {
|
|
194
|
+
const action = args.trim().toLowerCase() || "status";
|
|
195
|
+
const state = loadState() as any;
|
|
196
|
+
|
|
197
|
+
if (action === "register") {
|
|
198
|
+
const oldJobId = state.cronJobId;
|
|
199
|
+
if (oldJobId && oldJobId !== "manual-created") {
|
|
200
|
+
try {
|
|
201
|
+
execSync(`openclaw cron remove ${oldJobId}`, { encoding: "utf-8", timeout: 5000 });
|
|
202
|
+
} catch {}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
saveState({ ...state, cronJobId: undefined });
|
|
206
|
+
await ensureCronJob(logger);
|
|
207
|
+
|
|
208
|
+
const newState = loadState() as any;
|
|
209
|
+
if (newState.cronJobId) {
|
|
210
|
+
return { text: `✅ 定时任务注册成功\n任务 ID: ${newState.cronJobId}` };
|
|
211
|
+
} else {
|
|
212
|
+
return { text: "❌ 定时任务注册失败,请查看日志" };
|
|
213
|
+
}
|
|
214
|
+
} else if (action === "unregister") {
|
|
215
|
+
if (!state.cronJobId) {
|
|
216
|
+
return { text: "⚠️ 未找到已注册的定时任务" };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
execSync(`openclaw cron remove ${state.cronJobId}`, {
|
|
221
|
+
encoding: "utf-8",
|
|
222
|
+
timeout: 5000,
|
|
223
|
+
});
|
|
224
|
+
saveState({ ...state, cronJobId: undefined });
|
|
225
|
+
return { text: `✅ 定时任务已删除: ${state.cronJobId}` };
|
|
226
|
+
} catch (err: any) {
|
|
227
|
+
return { text: `❌ 删除失败: ${err.message}` };
|
|
228
|
+
}
|
|
229
|
+
} else {
|
|
230
|
+
const lines = ["🕐 *定时任务状态*"];
|
|
231
|
+
if (state.cronJobId && state.cronJobId !== "manual-created") {
|
|
232
|
+
lines.push(`任务 ID: ${state.cronJobId}`);
|
|
233
|
+
lines.push(`执行时间: 每天 08:00 (Asia/Shanghai)`);
|
|
234
|
+
lines.push("状态: ✅ 已注册");
|
|
235
|
+
} else {
|
|
236
|
+
lines.push("状态: ❌ 未注册");
|
|
237
|
+
lines.push("", "💡 使用 `/skills-scanner cron register` 注册");
|
|
238
|
+
}
|
|
239
|
+
return { text: lines.join("\n") };
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function getHelpText(): string {
|
|
244
|
+
return [
|
|
245
|
+
"🔍 *Skills Scanner - 帮助*",
|
|
246
|
+
"",
|
|
247
|
+
"═══ 扫描命令 ═══",
|
|
248
|
+
"`/skills-scanner scan <路径> [选项]`",
|
|
249
|
+
"",
|
|
250
|
+
"选项:",
|
|
251
|
+
"• `--detailed` - 显示详细发现",
|
|
252
|
+
"• `--behavioral` - 启用行为分析",
|
|
253
|
+
"• `--recursive` - 递归扫描子目录",
|
|
254
|
+
"• `--report` - 生成日报格式",
|
|
255
|
+
"",
|
|
256
|
+
"示例:",
|
|
257
|
+
"```",
|
|
258
|
+
"/skills-scanner scan ~/.openclaw/skills/my-skill",
|
|
259
|
+
"/skills-scanner scan ~/.openclaw/skills --recursive",
|
|
260
|
+
"/skills-scanner scan ~/.openclaw/skills --report",
|
|
261
|
+
"```",
|
|
262
|
+
"",
|
|
263
|
+
"═══ 其他命令 ═══",
|
|
264
|
+
"• `/skills-scanner status` - 查看状态",
|
|
265
|
+
"• `/skills-scanner config [show|reset]` - 配置管理",
|
|
266
|
+
"• `/skills-scanner cron [register|unregister|status]` - 定时任务管理",
|
|
267
|
+
].join("\n");
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return {
|
|
271
|
+
handleScanCommand,
|
|
272
|
+
handleStatusCommand,
|
|
273
|
+
handleConfigCommand,
|
|
274
|
+
handleCronCommand,
|
|
275
|
+
getHelpText,
|
|
276
|
+
};
|
|
277
|
+
}
|