@pwddd/skills-scanner 1.0.0-beta.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.
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Rate limiter module
3
+ *
4
+ * Prevents API overload by limiting request rate
5
+ */
6
+
7
+ export class RateLimiter {
8
+ private requests: number[] = [];
9
+ private maxPerMinute: number;
10
+ private maxPerHour: number;
11
+
12
+ constructor(maxPerMinute = 60, maxPerHour = 1000) {
13
+ this.maxPerMinute = maxPerMinute;
14
+ this.maxPerHour = maxPerHour;
15
+ }
16
+
17
+ /**
18
+ * Acquire permission to make a request
19
+ * Waits if rate limit is exceeded
20
+ */
21
+ async acquire(): Promise<void> {
22
+ const now = Date.now();
23
+
24
+ // Remove requests older than 1 hour
25
+ this.requests = this.requests.filter((t) => now - t < 60 * 60 * 1000);
26
+
27
+ // Check hourly limit
28
+ if (this.requests.length >= this.maxPerHour) {
29
+ const oldestRequest = this.requests[0];
30
+ const waitTime = 60 * 60 * 1000 - (now - oldestRequest);
31
+ await this.sleep(waitTime);
32
+ return this.acquire(); // Retry after waiting
33
+ }
34
+
35
+ // Check per-minute limit
36
+ const recentRequests = this.requests.filter((t) => now - t < 60 * 1000);
37
+ if (recentRequests.length >= this.maxPerMinute) {
38
+ const oldestRecent = recentRequests[0];
39
+ const waitTime = 60 * 1000 - (now - oldestRecent) + 100; // Add 100ms buffer
40
+ await this.sleep(waitTime);
41
+ return this.acquire(); // Retry after waiting
42
+ }
43
+
44
+ // Record this request
45
+ this.requests.push(now);
46
+ }
47
+
48
+ /**
49
+ * Try to acquire without waiting
50
+ * Returns false if rate limit exceeded
51
+ */
52
+ tryAcquire(): boolean {
53
+ const now = Date.now();
54
+
55
+ // Remove old requests
56
+ this.requests = this.requests.filter((t) => now - t < 60 * 60 * 1000);
57
+
58
+ // Check limits
59
+ const recentRequests = this.requests.filter((t) => now - t < 60 * 1000);
60
+ if (
61
+ this.requests.length >= this.maxPerHour ||
62
+ recentRequests.length >= this.maxPerMinute
63
+ ) {
64
+ return false;
65
+ }
66
+
67
+ // Record this request
68
+ this.requests.push(now);
69
+ return true;
70
+ }
71
+
72
+ /**
73
+ * Get current rate limit status
74
+ */
75
+ getStatus(): {
76
+ requestsLastMinute: number;
77
+ requestsLastHour: number;
78
+ maxPerMinute: number;
79
+ maxPerHour: number;
80
+ } {
81
+ const now = Date.now();
82
+ const recentRequests = this.requests.filter((t) => now - t < 60 * 1000);
83
+
84
+ return {
85
+ requestsLastMinute: recentRequests.length,
86
+ requestsLastHour: this.requests.length,
87
+ maxPerMinute: this.maxPerMinute,
88
+ maxPerHour: this.maxPerHour,
89
+ };
90
+ }
91
+
92
+ /**
93
+ * Reset rate limiter
94
+ */
95
+ reset(): void {
96
+ this.requests = [];
97
+ }
98
+
99
+ private sleep(ms: number): Promise<void> {
100
+ return new Promise((resolve) => setTimeout(resolve, ms));
101
+ }
102
+ }
package/src/report.ts ADDED
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Report generation module
3
+ */
4
+
5
+ import { readFileSync, writeFileSync, mkdirSync, rmSync, existsSync, readdirSync, statSync } from "node:fs";
6
+ import { join, basename } from "node:path";
7
+ import { runScan } from "./scanner.js";
8
+ import { loadState, saveState, STATE_DIR } from "./state.js";
9
+ import type { ScanRecord, PluginLogger } from "./types.js";
10
+
11
+ export async function buildDailyReport(
12
+ dirs: string[],
13
+ behavioral: boolean,
14
+ apiUrl: string,
15
+ useLLM: boolean,
16
+ policy: string,
17
+ logger: PluginLogger
18
+ ): Promise<string> {
19
+ const now = new Date();
20
+ const dateStr = now.toLocaleDateString("en-US", {
21
+ year: "numeric",
22
+ month: "2-digit",
23
+ day: "2-digit",
24
+ });
25
+ const timeStr = now.toLocaleTimeString("en-US", {
26
+ hour: "2-digit",
27
+ minute: "2-digit",
28
+ });
29
+ const jsonOut = join(STATE_DIR, `report-${now.toISOString().slice(0, 10)}.json`);
30
+ mkdirSync(STATE_DIR, { recursive: true });
31
+
32
+ let total = 0;
33
+ let safe = 0;
34
+ let unsafe = 0;
35
+ let errors = 0;
36
+ const unsafeList: string[] = [];
37
+ const allResults: ScanRecord[] = [];
38
+
39
+ for (const dir of dirs) {
40
+ if (!existsSync(dir)) continue;
41
+
42
+ // Find all skills in directory
43
+ const skills: string[] = [];
44
+ try {
45
+ const entries = readdirSync(dir);
46
+ for (const entry of entries) {
47
+ const skillPath = join(dir, entry);
48
+ if (statSync(skillPath).isDirectory() && existsSync(join(skillPath, "SKILL.md"))) {
49
+ skills.push(skillPath);
50
+ }
51
+ }
52
+ } catch {
53
+ continue;
54
+ }
55
+
56
+ for (const skillPath of skills) {
57
+ try {
58
+ const res = await runScan("scan", skillPath, {
59
+ behavioral,
60
+ detailed: false,
61
+ apiUrl,
62
+ useLLM,
63
+ policy,
64
+ });
65
+
66
+ const name = basename(skillPath);
67
+ total++;
68
+
69
+ if (res.exitCode === 0) {
70
+ safe++;
71
+ allResults.push({
72
+ name,
73
+ path: skillPath,
74
+ is_safe: true,
75
+ max_severity: "NONE",
76
+ findings: 0,
77
+ });
78
+ } else {
79
+ unsafe++;
80
+ unsafeList.push(name);
81
+ allResults.push({
82
+ name,
83
+ path: skillPath,
84
+ is_safe: false,
85
+ max_severity: res.data?.max_severity || "UNKNOWN",
86
+ findings: res.data?.findings_count || 0,
87
+ });
88
+ }
89
+ } catch (err: any) {
90
+ errors++;
91
+ allResults.push({
92
+ name: basename(skillPath),
93
+ path: skillPath,
94
+ error: err.message,
95
+ });
96
+ }
97
+ }
98
+ }
99
+
100
+ writeFileSync(jsonOut, JSON.stringify(allResults, null, 2));
101
+ saveState({
102
+ ...loadState(),
103
+ lastScanAt: now.toISOString(),
104
+ lastUnsafeSkills: unsafeList,
105
+ });
106
+
107
+ const lines = [`🔍 *Skills 安全日报* — ${dateStr} ${timeStr}`, "─".repeat(36)];
108
+ if (total === 0) {
109
+ lines.push("📭 未找到任何 Skill,请检查扫描目录。");
110
+ } else {
111
+ lines.push(`📊 扫描总计:${total} 个 Skill`);
112
+ lines.push(`✅ 安全:${safe} 个`);
113
+ lines.push(`❌ 问题:${unsafe} 个`);
114
+ if (errors) lines.push(`⚠️ 错误:${errors} 个`);
115
+ if (unsafe > 0) {
116
+ lines.push("", "🚨 *需要关注的 Skills:*");
117
+ for (const name of unsafeList) {
118
+ const r = allResults.find((x) => x.name === name);
119
+ lines.push(` • ${name} [${r?.max_severity ?? "?"}] — ${r?.findings ?? "?"} 条发现`);
120
+ }
121
+ lines.push("", "💡 运行 `/skills-scanner scan <路径> --detailed` 查看详情");
122
+ } else {
123
+ lines.push("", "🎉 所有 Skills 安全,未发现威胁。");
124
+ }
125
+ }
126
+ lines.push("", `📁 完整报告:${jsonOut}`);
127
+ return lines.join("\n");
128
+ }
package/src/scanner.ts ADDED
@@ -0,0 +1,230 @@
1
+ /**
2
+ * Scanner module - calls skill-scanner-api via HTTP
3
+ */
4
+
5
+ import { SkillScannerApiClient, type ScanResult as ApiScanResult } from "./api-client.js";
6
+ import { recordScan } from "./metrics.js";
7
+ import { createActionableError } from "./error-handler.js";
8
+
9
+ export interface ScanOptions {
10
+ detailed?: boolean;
11
+ behavioral?: boolean;
12
+ recursive?: boolean;
13
+ jsonOut?: string;
14
+ apiUrl?: string;
15
+ useLLM?: boolean;
16
+ policy?: string;
17
+ timeoutMs?: number; // Scan timeout in milliseconds
18
+ stateDir?: string; // State directory for metrics
19
+ }
20
+
21
+ export interface ScanResult {
22
+ exitCode: number;
23
+ output: string;
24
+ data?: ApiScanResult;
25
+ }
26
+
27
+ export async function runScan(
28
+ mode: "scan" | "batch" | "clawhub" | "health",
29
+ target: string,
30
+ opts: ScanOptions = {}
31
+ ): Promise<ScanResult> {
32
+ const apiUrl = opts.apiUrl || "https://110.vemic.com/skills-scanner";
33
+ const timeoutMs = opts.timeoutMs || 180000; // Default 3 minutes
34
+ const policy = (opts.policy || "balanced") as "strict" | "balanced" | "permissive";
35
+ const client = new SkillScannerApiClient(apiUrl, timeoutMs);
36
+
37
+ const startTime = Date.now();
38
+ let success = false;
39
+
40
+ try {
41
+ if (mode === "health") {
42
+ const health = await client.health();
43
+ const lines = [];
44
+ if (health.status === "healthy") {
45
+ lines.push("✅ API 服务正常");
46
+ if (health.version) lines.push(` 版本:${health.version}`);
47
+ if (health.analyzers_available) {
48
+ lines.push(` 可用分析器:${health.analyzers_available.join(", ")}`);
49
+ }
50
+ return { exitCode: 0, output: lines.join("\n"), data: health as any };
51
+ } else {
52
+ lines.push(`❌ API 服务不可用:${apiUrl}`);
53
+ lines.push(` 错误:${health.error || "未知错误"}`);
54
+ return { exitCode: 1, output: lines.join("\n"), data: health as any };
55
+ }
56
+ }
57
+
58
+ if (mode === "clawhub") {
59
+ const result = await client.scanClawHub(target, {
60
+ policy: opts.policy as any,
61
+ useLLM: opts.useLLM,
62
+ useBehavioral: opts.behavioral,
63
+ });
64
+ const output = formatScanResult(result, opts.detailed);
65
+ return {
66
+ exitCode: result.is_safe ? 0 : 1,
67
+ output,
68
+ data: result,
69
+ };
70
+ }
71
+
72
+ if (mode === "scan") {
73
+ const result = await client.scanUpload(target, {
74
+ policy: policy as any,
75
+ useLLM: opts.useLLM,
76
+ useBehavioral: opts.behavioral,
77
+ });
78
+ const output = formatScanResult(result, opts.detailed);
79
+ success = true;
80
+
81
+ // Record metrics
82
+ if (opts.stateDir) {
83
+ recordScan(opts.stateDir, {
84
+ success: true,
85
+ durationMs: Date.now() - startTime,
86
+ policy,
87
+ });
88
+ }
89
+
90
+ return {
91
+ exitCode: result.is_safe ? 0 : 1,
92
+ output,
93
+ data: result,
94
+ };
95
+ }
96
+
97
+ if (mode === "batch") {
98
+ const { readdirSync, statSync } = await import("node:fs");
99
+ const { join } = await import("node:path");
100
+ const { existsSync } = await import("node:fs");
101
+
102
+ if (!existsSync(target)) {
103
+ return { exitCode: 1, output: `路径不存在:${target}` };
104
+ }
105
+
106
+ const skills: string[] = [];
107
+ const entries = readdirSync(target);
108
+ for (const entry of entries) {
109
+ const skillPath = join(target, entry);
110
+ if (statSync(skillPath).isDirectory() && existsSync(join(skillPath, "SKILL.md"))) {
111
+ skills.push(skillPath);
112
+ }
113
+ }
114
+
115
+ if (skills.length === 0) {
116
+ return { exitCode: 1, output: `未找到任何 Skill:${target}` };
117
+ }
118
+
119
+ const results: ApiScanResult[] = [];
120
+ const lines: string[] = [];
121
+ let safe = 0;
122
+ let unsafe = 0;
123
+
124
+ for (let i = 0; i < skills.length; i++) {
125
+ const skillPath = skills[i];
126
+ lines.push(`[${i + 1}/${skills.length}] 正在扫描:${skillPath}`);
127
+
128
+ try {
129
+ const result = await client.scanUpload(skillPath, {
130
+ policy: opts.policy as any,
131
+ useLLM: opts.useLLM,
132
+ useBehavioral: opts.behavioral,
133
+ });
134
+ results.push(result);
135
+ if (result.is_safe) {
136
+ safe++;
137
+ lines.push(` ✅ ${result.skill_name}: 安全`);
138
+ } else {
139
+ unsafe++;
140
+ lines.push(` ❌ ${result.skill_name}: ${result.max_severity} (${result.findings_count} 个发现)`);
141
+ }
142
+ } catch (err: any) {
143
+ results.push({
144
+ skill_name: entry,
145
+ is_safe: false,
146
+ max_severity: "ERROR",
147
+ findings_count: 0,
148
+ error: err.message,
149
+ });
150
+ lines.push(` ❌ ${entry}: 扫描失败 - ${err.message}`);
151
+ }
152
+ }
153
+
154
+ lines.push("");
155
+ lines.push(`批量扫描完成:${safe} 安全,${unsafe} 问题`);
156
+
157
+ if (opts.detailed && unsafe > 0) {
158
+ lines.push("");
159
+ lines.push("问题 Skills:");
160
+ for (const r of results.filter((r) => !r.is_safe)) {
161
+ lines.push(` • ${r.skill_name} [${r.max_severity}] - ${r.findings_count} 条发现`);
162
+ }
163
+ }
164
+
165
+ return {
166
+ exitCode: unsafe === 0 ? 0 : 1,
167
+ output: lines.join("\n"),
168
+ };
169
+ }
170
+
171
+ return { exitCode: 1, output: `未知模式:${mode}` };
172
+ } catch (err: any) {
173
+ // Record failed scan
174
+ if (opts.stateDir && mode !== "health") {
175
+ recordScan(opts.stateDir, {
176
+ success: false,
177
+ durationMs: Date.now() - startTime,
178
+ policy,
179
+ });
180
+ }
181
+
182
+ // Create actionable error message
183
+ const errorMessage = createActionableError(
184
+ mode === "health" ? "健康检查" : "扫描操作",
185
+ err,
186
+ {
187
+ apiUrl,
188
+ path: target,
189
+ mode,
190
+ policy,
191
+ }
192
+ );
193
+
194
+ return {
195
+ exitCode: 1,
196
+ output: errorMessage,
197
+ };
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Format scan result for display
203
+ */
204
+ function formatScanResult(result: ApiScanResult, detailed = false): string {
205
+ const lines: string[] = [];
206
+ const statusIcon = result.is_safe ? "✅" : "❌";
207
+
208
+ lines.push(`${statusIcon} ${result.skill_name}`);
209
+ lines.push(` 严重性:${result.max_severity}`);
210
+ lines.push(` 发现数:${result.findings_count}`);
211
+
212
+ if (detailed && result.findings && result.findings.length > 0) {
213
+ lines.push("");
214
+ lines.push("发现详情:");
215
+ for (let i = 0; i < Math.min(result.findings.length, 10); i++) {
216
+ const finding = result.findings[i];
217
+ lines.push(` ${i + 1}. [${finding.severity}] ${finding.category}`);
218
+ lines.push(` ${finding.description}`);
219
+ if (finding.file) {
220
+ lines.push(` 文件:${finding.file}${finding.line ? `:${finding.line}` : ""}`);
221
+ }
222
+ }
223
+
224
+ if (result.findings.length > 10) {
225
+ lines.push(` ... 还有 ${result.findings.length - 10} 条发现`);
226
+ }
227
+ }
228
+
229
+ return lines.join("\n");
230
+ }
package/src/state.ts ADDED
@@ -0,0 +1,136 @@
1
+ /**
2
+ * State management module
3
+ */
4
+
5
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import os from "node:os";
8
+ import type { ScanState, ScannerConfig } from "./types.js";
9
+ import type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store";
10
+
11
+ // Current state schema version
12
+ const CURRENT_STATE_VERSION = 2;
13
+
14
+ // Legacy fallback path (used when runtime is not available)
15
+ const LEGACY_STATE_DIR = join(os.homedir(), ".openclaw", "skills-scanner");
16
+
17
+ /**
18
+ * Get state directory path using official OpenClaw API
19
+ */
20
+ export function getStateDir(runtime?: PluginRuntime): string {
21
+ if (runtime?.state?.resolveStateDir) {
22
+ const stateDir = runtime.state.resolveStateDir();
23
+ return join(stateDir, "skills-scanner");
24
+ }
25
+ // Fallback to legacy path
26
+ return LEGACY_STATE_DIR;
27
+ }
28
+
29
+ /**
30
+ * Get state file path
31
+ */
32
+ export function getStateFile(runtime?: PluginRuntime): string {
33
+ return join(getStateDir(runtime), "state.json");
34
+ }
35
+
36
+ /**
37
+ * Migrate state from old versions to current version
38
+ */
39
+ function migrateState(state: any): ScanState {
40
+ const version = state.version || 1;
41
+
42
+ if (version === CURRENT_STATE_VERSION) {
43
+ return state;
44
+ }
45
+
46
+ // Migration from v1 to v2
47
+ if (version === 1) {
48
+ // v1 didn't have version field or lastShutdownAt
49
+ return {
50
+ ...state,
51
+ version: 2,
52
+ lastShutdownAt: undefined,
53
+ };
54
+ }
55
+
56
+ // Unknown version - return as-is with current version
57
+ return {
58
+ ...state,
59
+ version: CURRENT_STATE_VERSION,
60
+ };
61
+ }
62
+
63
+ export function loadState(runtime?: PluginRuntime): ScanState {
64
+ try {
65
+ const stateFile = getStateFile(runtime);
66
+ const rawState = JSON.parse(readFileSync(stateFile, "utf-8"));
67
+ return migrateState(rawState);
68
+ } catch {
69
+ return { version: CURRENT_STATE_VERSION };
70
+ }
71
+ }
72
+
73
+ export function saveState(s: ScanState, runtime?: PluginRuntime): void {
74
+ const stateDir = getStateDir(runtime);
75
+ const stateFile = getStateFile(runtime);
76
+
77
+ // Ensure version is set
78
+ const stateWithVersion = {
79
+ ...s,
80
+ version: CURRENT_STATE_VERSION,
81
+ };
82
+
83
+ mkdirSync(stateDir, { recursive: true });
84
+ writeFileSync(stateFile, JSON.stringify(stateWithVersion, null, 2));
85
+ }
86
+
87
+ export function isFirstRun(cfg: ScannerConfig, runtime?: PluginRuntime): boolean {
88
+ const state = loadState(runtime) as any;
89
+
90
+ if (state.configReviewed) {
91
+ return false;
92
+ }
93
+
94
+ const isDefaultConfig =
95
+ !cfg.apiUrl &&
96
+ (!cfg.scanDirs || cfg.scanDirs.length === 0) &&
97
+ cfg.behavioral !== true &&
98
+ cfg.useLLM !== true &&
99
+ cfg.policy !== "strict" &&
100
+ cfg.policy !== "permissive" &&
101
+ cfg.preInstallScan !== "off" &&
102
+ cfg.onUnsafe !== "delete" &&
103
+ cfg.onUnsafe !== "warn";
104
+
105
+ return isDefaultConfig;
106
+ }
107
+
108
+ export function markConfigReviewed(runtime?: PluginRuntime): void {
109
+ const state = loadState(runtime) as any;
110
+ saveState({ ...state, configReviewed: true }, runtime);
111
+ }
112
+
113
+ export function expandPath(p: string): string {
114
+ return p.replace(/^~/, os.homedir());
115
+ }
116
+
117
+ export function defaultScanDirs(): string[] {
118
+ const dirs = [
119
+ join(os.homedir(), ".openclaw", "skills"),
120
+ join(os.homedir(), ".openclaw", "workspace", "skills"),
121
+ ];
122
+
123
+ for (const dir of dirs) {
124
+ if (!existsSync(dir)) {
125
+ mkdirSync(dir, { recursive: true });
126
+ }
127
+ }
128
+
129
+ return dirs;
130
+ }
131
+
132
+ // Export for backward compatibility
133
+ export { LEGACY_STATE_DIR as STATE_DIR };
134
+ export function STATE_FILE(runtime?: PluginRuntime): string {
135
+ return getStateFile(runtime);
136
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Structured logging utilities
3
+ */
4
+
5
+ import type { PluginLogger } from "./types.js";
6
+
7
+ export interface LogContext {
8
+ module?: string;
9
+ action?: string;
10
+ duration?: number;
11
+ [key: string]: any;
12
+ }
13
+
14
+ /**
15
+ * Log with structured context
16
+ */
17
+ export function logInfo(
18
+ logger: PluginLogger,
19
+ message: string,
20
+ context?: LogContext
21
+ ): void {
22
+ if (context) {
23
+ logger.info(message, context);
24
+ } else {
25
+ logger.info(message);
26
+ }
27
+ }
28
+
29
+ export function logDebug(
30
+ logger: PluginLogger,
31
+ message: string,
32
+ context?: LogContext
33
+ ): void {
34
+ if (context) {
35
+ logger.debug(message, context);
36
+ } else {
37
+ logger.debug(message);
38
+ }
39
+ }
40
+
41
+ export function logWarn(
42
+ logger: PluginLogger,
43
+ message: string,
44
+ context?: LogContext
45
+ ): void {
46
+ if (context) {
47
+ logger.warn(message, context);
48
+ } else {
49
+ logger.warn(message);
50
+ }
51
+ }
52
+
53
+ export function logError(
54
+ logger: PluginLogger,
55
+ message: string,
56
+ error?: Error,
57
+ context?: LogContext
58
+ ): void {
59
+ const errorContext = {
60
+ ...context,
61
+ error: error?.message,
62
+ stack: error?.stack,
63
+ };
64
+ logger.error(message, errorContext);
65
+ }
66
+
67
+ /**
68
+ * Log operation with timing
69
+ */
70
+ export async function logOperation<T>(
71
+ logger: PluginLogger,
72
+ operation: string,
73
+ fn: () => Promise<T>,
74
+ context?: LogContext
75
+ ): Promise<T> {
76
+ const startTime = Date.now();
77
+ logDebug(logger, `${operation} started`, context);
78
+
79
+ try {
80
+ const result = await fn();
81
+ const duration = Date.now() - startTime;
82
+ logInfo(logger, `${operation} completed`, {
83
+ ...context,
84
+ duration,
85
+ success: true,
86
+ });
87
+ return result;
88
+ } catch (error: any) {
89
+ const duration = Date.now() - startTime;
90
+ logError(logger, `${operation} failed`, error, {
91
+ ...context,
92
+ duration,
93
+ success: false,
94
+ });
95
+ throw error;
96
+ }
97
+ }