@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.
package/src/types.ts ADDED
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Skills Scanner 类型定义
3
+ */
4
+
5
+ export interface ScannerConfig {
6
+ apiUrl?: string;
7
+ scanDirs?: string[];
8
+ behavioral?: boolean;
9
+ useLLM?: boolean;
10
+ policy?: "strict" | "balanced" | "permissive";
11
+ preInstallScan?: "on" | "off";
12
+ onUnsafe?: "quarantine" | "delete" | "warn";
13
+ injectSecurityGuidance?: boolean;
14
+ enablePromptInjectionGuard?: boolean;
15
+ enableHighRiskOperationGuard?: boolean;
16
+ enableBeforeInstallHook?: boolean;
17
+ scanTimeoutMs?: number; // Scan timeout in milliseconds (default: 180000)
18
+ reportDir?: string; // Custom report directory
19
+ quarantineDir?: string; // Custom quarantine directory
20
+ }
21
+
22
+ export interface ScanState {
23
+ version?: number; // State schema version
24
+ lastScanAt?: string;
25
+ lastShutdownAt?: string;
26
+ lastUninstallAt?: string; // Track when plugin was uninstalled
27
+ lastUnsafeSkills?: string[];
28
+ configReviewed?: boolean;
29
+ cronJobId?: string;
30
+ pendingAlerts?: string[];
31
+ }
32
+
33
+ export interface ScanOptions {
34
+ detailed?: boolean;
35
+ behavioral?: boolean;
36
+ recursive?: boolean;
37
+ jsonOut?: string;
38
+ apiUrl?: string;
39
+ useLLM?: boolean;
40
+ policy?: string;
41
+ }
42
+
43
+ export interface ScanResult {
44
+ exitCode: number;
45
+ output: string;
46
+ data?: ScanResultData;
47
+ }
48
+
49
+ export interface ScanResultData {
50
+ is_safe?: boolean;
51
+ max_severity?: string;
52
+ findings?: number;
53
+ error?: string;
54
+ }
55
+
56
+ export interface ScanRecord {
57
+ name?: string;
58
+ path?: string;
59
+ is_safe?: boolean;
60
+ error?: string;
61
+ max_severity?: string;
62
+ findings?: number;
63
+ }
64
+
65
+ export type OnUnsafeAction = "quarantine" | "delete" | "warn";
66
+
67
+ export interface CommandResponse {
68
+ text: string;
69
+ }
70
+
71
+ export interface PluginLogger {
72
+ debug(message: string, data?: any): void;
73
+ info(message: string, data?: any): void;
74
+ warn(message: string, data?: any): void;
75
+ error(message: string, data?: any): void;
76
+ }
package/src/watcher.ts ADDED
@@ -0,0 +1,178 @@
1
+ /**
2
+ * File watcher module for pre-installation scanning
3
+ */
4
+
5
+ import { watch as fsWatch, existsSync, renameSync, rmSync } from "node:fs";
6
+ import { join, basename } from "node:path";
7
+ import { mkdirSync } from "node:fs";
8
+ import { runScan } from "./scanner.js";
9
+ import type { OnUnsafeAction, PluginLogger } from "./types.js";
10
+
11
+ // Debounce delay in milliseconds
12
+ const DEBOUNCE_DELAY = 1000; // Increased from 500ms to 1000ms for better stability
13
+
14
+ export async function handleNewSkill(
15
+ skillPath: string,
16
+ onUnsafe: OnUnsafeAction,
17
+ behavioral: boolean,
18
+ apiUrl: string,
19
+ useLLM: boolean,
20
+ policy: string,
21
+ notifyFn: (msg: string) => void,
22
+ logger: PluginLogger,
23
+ quarantineDir: string
24
+ ): Promise<void> {
25
+ if (!existsSync(join(skillPath, "SKILL.md"))) return;
26
+
27
+ const name = basename(skillPath);
28
+ logger.info(`[skills-scanner] 🔍 检测到新 Skill,开始安装前扫描:${name}`);
29
+ notifyFn(`🔍 检测到新 Skill \`${name}\`,正在安全扫描...`);
30
+
31
+ try {
32
+ const res = await runScan("scan", skillPath, {
33
+ behavioral,
34
+ detailed: true,
35
+ apiUrl,
36
+ useLLM,
37
+ policy,
38
+ });
39
+
40
+ if (res.exitCode === 0) {
41
+ notifyFn(`✅ \`${name}\` 安全检查通过,可以正常使用。`);
42
+ return;
43
+ }
44
+
45
+ let action = "";
46
+ try {
47
+ if (onUnsafe === "quarantine") {
48
+ mkdirSync(quarantineDir, { recursive: true });
49
+ const dest = join(quarantineDir, `${name}-${Date.now()}`);
50
+ renameSync(skillPath, dest);
51
+ action = `已移入隔离目录:\`${dest}\``;
52
+ } else if (onUnsafe === "delete") {
53
+ rmSync(skillPath, { recursive: true, force: true });
54
+ action = "已自动删除";
55
+ } else {
56
+ action = "仅警告,Skill 已保留(请谨慎使用)";
57
+ }
58
+ } catch (e: any) {
59
+ action = `处置失败:${e.message}`;
60
+ logger.error("[skills-scanner] Failed to handle unsafe skill", {
61
+ skill: name,
62
+ error: e.message,
63
+ });
64
+ }
65
+
66
+ notifyFn(
67
+ [
68
+ `❌ *安全警告:\`${name}\` 未通过扫描*`,
69
+ `处置:${action}`,
70
+ "```",
71
+ res.output.slice(0, 600),
72
+ "```",
73
+ ].join("\n")
74
+ );
75
+ } catch (err: any) {
76
+ logger.error("[skills-scanner] Scan failed for new skill", {
77
+ skill: name,
78
+ error: err.message,
79
+ stack: err.stack,
80
+ });
81
+ notifyFn(`⚠️ \`${name}\` 扫描失败:${err.message}`);
82
+ }
83
+ }
84
+
85
+ export function startWatcher(
86
+ dirs: string[],
87
+ onUnsafe: OnUnsafeAction,
88
+ behavioral: boolean,
89
+ apiUrl: string,
90
+ useLLM: boolean,
91
+ policy: string,
92
+ notifyFn: (msg: string) => void,
93
+ logger: PluginLogger,
94
+ quarantineDir: string
95
+ ): () => void {
96
+ const timers = new Map<string, NodeJS.Timeout>();
97
+ const processing = new Set<string>(); // Track files being processed
98
+
99
+ const watchers = dirs.map((dir) => {
100
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
101
+ logger.info(`[skills-scanner] 👁 监听目录:${dir}`);
102
+
103
+ const watcher = fsWatch(dir, { persistent: false }, (_evt, filename) => {
104
+ if (!filename) return;
105
+ const skillPath = join(dir, filename);
106
+
107
+ // Skip if file doesn't exist or is already being processed
108
+ if (!existsSync(skillPath) || processing.has(skillPath)) return;
109
+
110
+ // Clear existing timer for this path (debounce)
111
+ const prev = timers.get(skillPath);
112
+ if (prev) clearTimeout(prev);
113
+
114
+ // Set new timer with debounce delay
115
+ timers.set(
116
+ skillPath,
117
+ setTimeout(async () => {
118
+ timers.delete(skillPath);
119
+ processing.add(skillPath);
120
+
121
+ try {
122
+ await handleNewSkill(
123
+ skillPath,
124
+ onUnsafe,
125
+ behavioral,
126
+ apiUrl,
127
+ useLLM,
128
+ policy,
129
+ notifyFn,
130
+ logger,
131
+ quarantineDir
132
+ );
133
+ } catch (err: any) {
134
+ logger.error("[skills-scanner] Watcher handler failed", {
135
+ path: skillPath,
136
+ error: err.message,
137
+ });
138
+ } finally {
139
+ processing.delete(skillPath);
140
+ }
141
+ }, DEBOUNCE_DELAY)
142
+ );
143
+ });
144
+
145
+ // Add error handler for watcher
146
+ watcher.on("error", (error: Error) => {
147
+ logger.error("[skills-scanner] Watcher error", {
148
+ directory: dir,
149
+ error: error.message,
150
+ stack: error.stack,
151
+ });
152
+ });
153
+
154
+ return watcher;
155
+ });
156
+
157
+ return () => {
158
+ try {
159
+ watchers.forEach((w) => {
160
+ try {
161
+ w.close();
162
+ } catch (err: any) {
163
+ logger.warn("[skills-scanner] Failed to close watcher", {
164
+ error: err.message,
165
+ });
166
+ }
167
+ });
168
+ timers.forEach((t) => clearTimeout(t));
169
+ timers.clear();
170
+ processing.clear();
171
+ logger.info("[skills-scanner] 目录监听已停止");
172
+ } catch (err: any) {
173
+ logger.error("[skills-scanner] Error stopping watcher", {
174
+ error: err.message,
175
+ });
176
+ }
177
+ };
178
+ }