@pwddd/skills-scanner 3.0.22 → 4.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.
package/src/scanner.ts CHANGED
@@ -1,54 +1,230 @@
1
- /**
2
- * Scanner module - handles Python script execution
3
- */
4
-
5
- import { promisify } from "node:util";
6
- import { exec } from "node:child_process";
7
- import type { ScanOptions, ScanResult } from "./types.js";
8
-
9
- const execAsync = promisify(exec);
10
-
11
- export async function runScan(
12
- pythonCmd: string | null,
13
- scanScript: string,
14
- mode: "scan" | "batch" | "clawhub",
15
- target: string,
16
- opts: ScanOptions = {}
17
- ): Promise<ScanResult> {
18
- if (!pythonCmd) {
19
- return { exitCode: 1, output: "Python is not available on this host" };
20
- }
21
-
22
- const args = [mode, target];
23
- if (opts.detailed) args.push("--detailed");
24
- if (opts.behavioral) args.push("--behavioral");
25
- if (opts.recursive) args.push("--recursive");
26
- if (opts.useLLM) args.push("--llm");
27
- if (opts.policy) args.push("--policy", opts.policy);
28
- if (opts.jsonOut) args.push("--json", opts.jsonOut);
29
- if (opts.apiUrl) args.unshift("--api-url", opts.apiUrl);
30
-
31
- const cmd = `"${pythonCmd}" "${scanScript}" ${args.map((a) => `"${a}"`).join(" ")}`;
32
-
33
- try {
34
- const env = { ...process.env };
35
- // Remove proxy env vars to avoid connection issues
36
- delete env.http_proxy;
37
- delete env.https_proxy;
38
- delete env.HTTP_PROXY;
39
- delete env.HTTPS_PROXY;
40
- delete env.all_proxy;
41
- delete env.ALL_PROXY;
42
-
43
- const { stdout, stderr } = await execAsync(cmd, {
44
- timeout: 180_000,
45
- env,
46
- });
47
- return { exitCode: 0, output: (stdout + stderr).trim() };
48
- } catch (err: any) {
49
- return {
50
- exitCode: err.code ?? 1,
51
- output: (err.stdout + err.stderr || "").trim() || err.message,
52
- };
53
- }
54
- }
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 CHANGED
@@ -1,71 +1,119 @@
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
-
10
- const STATE_DIR = join(os.homedir(), ".openclaw", "skills-scanner");
11
- const STATE_FILE = join(STATE_DIR, "state.json");
12
-
13
- export function loadState(): ScanState {
14
- try {
15
- return JSON.parse(readFileSync(STATE_FILE, "utf-8"));
16
- } catch {
17
- return {};
18
- }
19
- }
20
-
21
- export function saveState(s: ScanState): void {
22
- mkdirSync(STATE_DIR, { recursive: true });
23
- writeFileSync(STATE_FILE, JSON.stringify(s, null, 2));
24
- }
25
-
26
- export function isFirstRun(cfg: ScannerConfig): boolean {
27
- const state = loadState() as any;
28
-
29
- if (state.configReviewed) {
30
- return false;
31
- }
32
-
33
- const isDefaultConfig =
34
- !cfg.apiUrl &&
35
- (!cfg.scanDirs || cfg.scanDirs.length === 0) &&
36
- cfg.behavioral !== true &&
37
- cfg.useLLM !== true &&
38
- cfg.policy !== "strict" &&
39
- cfg.policy !== "permissive" &&
40
- cfg.preInstallScan !== "off" &&
41
- cfg.onUnsafe !== "delete" &&
42
- cfg.onUnsafe !== "warn";
43
-
44
- return isDefaultConfig;
45
- }
46
-
47
- export function markConfigReviewed(): void {
48
- const state = loadState() as any;
49
- saveState({ ...state, configReviewed: true });
50
- }
51
-
52
- export function expandPath(p: string): string {
53
- return p.replace(/^~/, os.homedir());
54
- }
55
-
56
- export function defaultScanDirs(): string[] {
57
- const dirs = [
58
- join(os.homedir(), ".openclaw", "skills"),
59
- join(os.homedir(), ".openclaw", "workspace", "skills"),
60
- ];
61
-
62
- for (const dir of dirs) {
63
- if (!existsSync(dir)) {
64
- mkdirSync(dir, { recursive: true });
65
- }
66
- }
67
-
68
- return dirs;
69
- }
70
-
71
- export { STATE_DIR, STATE_FILE };
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.behavioral !== true &&
97
+ cfg.useLLM !== true &&
98
+ cfg.policy !== "strict" &&
99
+ cfg.policy !== "permissive" &&
100
+ cfg.onUnsafe !== "delete" &&
101
+ cfg.onUnsafe !== "warn";
102
+
103
+ return isDefaultConfig;
104
+ }
105
+
106
+ export function markConfigReviewed(runtime?: PluginRuntime): void {
107
+ const state = loadState(runtime) as any;
108
+ saveState({ ...state, configReviewed: true }, runtime);
109
+ }
110
+
111
+ export function expandPath(p: string): string {
112
+ return p.replace(/^~/, os.homedir());
113
+ }
114
+
115
+ // Export for backward compatibility
116
+ export { LEGACY_STATE_DIR as STATE_DIR };
117
+ export function STATE_FILE(runtime?: PluginRuntime): string {
118
+ return getStateFile(runtime);
119
+ }
@@ -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
+ }