@pwddd/skills-scanner 1.0.3

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/deps.ts ADDED
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Dependency management module
3
+ */
4
+
5
+ import { execSync } from "node:child_process";
6
+ import { existsSync, rmSync } from "node:fs";
7
+ import { promisify } from "node:util";
8
+ import { exec } from "node:child_process";
9
+ import { join } from "node:path";
10
+
11
+ const execAsync = promisify(exec);
12
+
13
+ export function hasUv(): boolean {
14
+ try {
15
+ execSync("uv --version", { stdio: "ignore" });
16
+ return true;
17
+ } catch {
18
+ return false;
19
+ }
20
+ }
21
+
22
+ export function isVenvReady(venvPython: string): boolean {
23
+ if (!existsSync(venvPython)) return false;
24
+
25
+ try {
26
+ execSync(`"${venvPython}" -c "import requests"`, { stdio: "ignore" });
27
+ return true;
28
+ } catch {
29
+ return false;
30
+ }
31
+ }
32
+
33
+ export async function ensureDeps(
34
+ skillDir: string,
35
+ venvPython: string,
36
+ logger: any
37
+ ): Promise<boolean> {
38
+ if (isVenvReady(venvPython)) {
39
+ logger.info("[skills-scanner] Python dependencies ready (requests installed)");
40
+ return true;
41
+ }
42
+
43
+ if (!hasUv()) {
44
+ logger.warn(
45
+ "[skills-scanner] uv not installed: brew install uv or curl -LsSf https://astral.sh/uv/install.sh | sh"
46
+ );
47
+ return false;
48
+ }
49
+
50
+ logger.info("[skills-scanner] Installing Python dependencies...");
51
+
52
+ try {
53
+ const venvDir = join(skillDir, ".venv");
54
+
55
+ if (existsSync(venvDir)) {
56
+ logger.info("[skills-scanner] Cleaning old virtual environment...");
57
+ rmSync(venvDir, { recursive: true, force: true });
58
+ }
59
+
60
+ await execAsync(`uv venv "${venvDir}" --python 3.10`);
61
+ logger.info("[skills-scanner] Virtual environment created");
62
+
63
+ logger.info("[skills-scanner] Installing requests...");
64
+ await execAsync(`uv pip install --python "${venvPython}" requests>=2.31.0`);
65
+
66
+ execSync(`"${venvPython}" -c "import requests"`, { stdio: "ignore" });
67
+ logger.info("[skills-scanner] ✅ Dependencies installed successfully");
68
+ return true;
69
+ } catch (err: any) {
70
+ logger.error(`[skills-scanner] ⚠️ Dependency installation failed: ${err.message}`);
71
+ return false;
72
+ }
73
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * System prompt guidance for Skills security scanning
3
+ */
4
+
5
+ export const SKILLS_SECURITY_GUIDANCE = [
6
+ "## Skills 安全规则",
7
+ "",
8
+ "当用户请求安装、下载或使用新的 Skill 时,必须先运行安全扫描:",
9
+ "",
10
+ "1. 使用 `/skills-scanner scan <skill路径>` 进行扫描",
11
+ "2. 等待扫描结果",
12
+ "3. 根据结果决定:",
13
+ " - ✅ 安全(is_safe=true):可以继续安装,告知用户扫描通过",
14
+ " - ❌ 不安全(is_safe=false):",
15
+ " * 立即告知用户发现的安全问题",
16
+ " * 显示威胁详情(严重级别、发现数量)",
17
+ " * 不得继续安装,除非用户明确要求忽略风险",
18
+ "",
19
+ "对于来自不可信来源的 Skill,使用 `--detailed --behavioral` 进行深度扫描。",
20
+ "",
21
+ "用户明确绕过风险时,必须:",
22
+ "1. 再次明确告知风险",
23
+ "2. 要求用户确认(\"我了解风险并仍要继续\")",
24
+ "3. 建议用户监控该 Skill 的行为",
25
+ ].join("\n");
package/src/report.ts ADDED
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Report generation module
3
+ */
4
+
5
+ import { readFileSync, writeFileSync, mkdirSync, rmSync, existsSync } 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 } 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: any,
18
+ venvPython: string,
19
+ scanScript: string
20
+ ): Promise<string> {
21
+ const now = new Date();
22
+ const dateStr = now.toLocaleDateString("en-US", {
23
+ year: "numeric",
24
+ month: "2-digit",
25
+ day: "2-digit",
26
+ });
27
+ const timeStr = now.toLocaleTimeString("en-US", {
28
+ hour: "2-digit",
29
+ minute: "2-digit",
30
+ });
31
+ const jsonOut = join(STATE_DIR, `report-${now.toISOString().slice(0, 10)}.json`);
32
+ mkdirSync(STATE_DIR, { recursive: true });
33
+
34
+ let total = 0;
35
+ let safe = 0;
36
+ let unsafe = 0;
37
+ let errors = 0;
38
+ const unsafeList: string[] = [];
39
+ const allResults: ScanRecord[] = [];
40
+
41
+ for (const dir of dirs) {
42
+ if (!existsSync(dir)) continue;
43
+ const tmpJson = join(STATE_DIR, `tmp-${Date.now()}.json`);
44
+ await runScan(venvPython, scanScript, "batch", dir, {
45
+ behavioral,
46
+ recursive: true,
47
+ jsonOut: tmpJson,
48
+ apiUrl,
49
+ useLLM,
50
+ policy,
51
+ });
52
+ try {
53
+ const rows: ScanRecord[] = JSON.parse(readFileSync(tmpJson, "utf-8"));
54
+ try {
55
+ rmSync(tmpJson);
56
+ } catch {}
57
+ for (const r of rows) {
58
+ allResults.push(r);
59
+ total++;
60
+ if (r.error) errors++;
61
+ else if (r.is_safe) safe++;
62
+ else {
63
+ unsafe++;
64
+ unsafeList.push(r.name || basename(r.path ?? ""));
65
+ }
66
+ }
67
+ } catch {
68
+ logger.warn(`[skills-scanner] Cannot parse ${tmpJson}`);
69
+ }
70
+ }
71
+
72
+ writeFileSync(jsonOut, JSON.stringify(allResults, null, 2));
73
+ saveState({
74
+ ...loadState(),
75
+ lastScanAt: now.toISOString(),
76
+ lastUnsafeSkills: unsafeList,
77
+ });
78
+
79
+ const lines = [`🔍 *Skills 安全日报* — ${dateStr} ${timeStr}`, "─".repeat(36)];
80
+ if (total === 0) {
81
+ lines.push("📭 未找到任何 Skill,请检查扫描目录。");
82
+ } else {
83
+ lines.push(`📊 扫描总计:${total} 个 Skill`);
84
+ lines.push(`✅ 安全:${safe} 个`);
85
+ lines.push(`❌ 问题:${unsafe} 个`);
86
+ if (errors) lines.push(`⚠️ 错误:${errors} 个`);
87
+ if (unsafe > 0) {
88
+ lines.push("", "🚨 *需要关注的 Skills:*");
89
+ for (const name of unsafeList) {
90
+ const r = allResults.find((x) => (x.name || basename(x.path ?? "")) === name);
91
+ lines.push(` • ${name} [${r?.max_severity ?? "?"}] — ${r?.findings ?? "?"} 条发现`);
92
+ }
93
+ lines.push("", "💡 运行 `/skills-scanner scan <路径> --detailed` 查看详情");
94
+ } else {
95
+ lines.push("", "🎉 所有 Skills 安全,未发现威胁。");
96
+ }
97
+ }
98
+ lines.push("", `📁 完整报告:${jsonOut}`);
99
+ return lines.join("\n");
100
+ }
package/src/scanner.ts ADDED
@@ -0,0 +1,50 @@
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
+ venvPython: string,
13
+ scanScript: string,
14
+ mode: "scan" | "batch" | "clawhub",
15
+ target: string,
16
+ opts: ScanOptions = {}
17
+ ): Promise<ScanResult> {
18
+ const args = [mode, target];
19
+ if (opts.detailed) args.push("--detailed");
20
+ if (opts.behavioral) args.push("--behavioral");
21
+ if (opts.recursive) args.push("--recursive");
22
+ if (opts.useLLM) args.push("--llm");
23
+ if (opts.policy) args.push("--policy", opts.policy);
24
+ if (opts.jsonOut) args.push("--json", opts.jsonOut);
25
+ if (opts.apiUrl) args.unshift("--api-url", opts.apiUrl);
26
+
27
+ const cmd = `"${venvPython}" "${scanScript}" ${args.map((a) => `"${a}"`).join(" ")}`;
28
+
29
+ try {
30
+ const env = { ...process.env };
31
+ // Remove proxy env vars to avoid connection issues
32
+ delete env.http_proxy;
33
+ delete env.https_proxy;
34
+ delete env.HTTP_PROXY;
35
+ delete env.HTTPS_PROXY;
36
+ delete env.all_proxy;
37
+ delete env.ALL_PROXY;
38
+
39
+ const { stdout, stderr } = await execAsync(cmd, {
40
+ timeout: 180_000,
41
+ env,
42
+ });
43
+ return { exitCode: 0, output: (stdout + stderr).trim() };
44
+ } catch (err: any) {
45
+ return {
46
+ exitCode: err.code ?? 1,
47
+ output: (err.stdout + err.stderr || "").trim() || err.message,
48
+ };
49
+ }
50
+ }
package/src/state.ts ADDED
@@ -0,0 +1,71 @@
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 };
package/src/types.ts ADDED
@@ -0,0 +1,48 @@
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
+ }
15
+
16
+ export interface ScanState {
17
+ lastScanAt?: string;
18
+ lastUnsafeSkills?: string[];
19
+ configReviewed?: boolean;
20
+ cronJobId?: string;
21
+ pendingAlerts?: string[];
22
+ }
23
+
24
+ export interface ScanOptions {
25
+ detailed?: boolean;
26
+ behavioral?: boolean;
27
+ recursive?: boolean;
28
+ jsonOut?: string;
29
+ apiUrl?: string;
30
+ useLLM?: boolean;
31
+ policy?: string;
32
+ }
33
+
34
+ export interface ScanResult {
35
+ exitCode: number;
36
+ output: string;
37
+ }
38
+
39
+ export interface ScanRecord {
40
+ name?: string;
41
+ path?: string;
42
+ is_safe?: boolean;
43
+ error?: string;
44
+ max_severity?: string;
45
+ findings?: number;
46
+ }
47
+
48
+ export type OnUnsafeAction = "quarantine" | "delete" | "warn";
package/src/watcher.ts ADDED
@@ -0,0 +1,125 @@
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 } from "./types.js";
10
+
11
+ export async function handleNewSkill(
12
+ skillPath: string,
13
+ onUnsafe: OnUnsafeAction,
14
+ behavioral: boolean,
15
+ apiUrl: string,
16
+ useLLM: boolean,
17
+ policy: string,
18
+ notifyFn: (msg: string) => void,
19
+ logger: any,
20
+ venvPython: string,
21
+ scanScript: string,
22
+ quarantineDir: string
23
+ ): Promise<void> {
24
+ if (!existsSync(join(skillPath, "SKILL.md"))) return;
25
+
26
+ const name = basename(skillPath);
27
+ logger.info(`[skills-scanner] 🔍 检测到新 Skill,开始安装前扫描: ${name}`);
28
+ notifyFn(`🔍 检测到新 Skill \`${name}\`,正在安全扫描...`);
29
+
30
+ const res = await runScan(venvPython, scanScript, "scan", skillPath, {
31
+ behavioral,
32
+ detailed: true,
33
+ apiUrl,
34
+ useLLM,
35
+ policy,
36
+ });
37
+
38
+ if (res.exitCode === 0) {
39
+ notifyFn(`✅ \`${name}\` 安全检查通过,可以正常使用。`);
40
+ return;
41
+ }
42
+
43
+ let action = "";
44
+ try {
45
+ if (onUnsafe === "quarantine") {
46
+ mkdirSync(quarantineDir, { recursive: true });
47
+ const dest = join(quarantineDir, `${name}-${Date.now()}`);
48
+ renameSync(skillPath, dest);
49
+ action = `已移入隔离目录:\`${dest}\``;
50
+ } else if (onUnsafe === "delete") {
51
+ rmSync(skillPath, { recursive: true, force: true });
52
+ action = "已自动删除";
53
+ } else {
54
+ action = "仅警告,Skill 已保留(请谨慎使用)";
55
+ }
56
+ } catch (e: any) {
57
+ action = `处置失败:${e.message}`;
58
+ }
59
+
60
+ notifyFn(
61
+ [
62
+ `❌ *安全警告:\`${name}\` 未通过扫描*`,
63
+ `处置:${action}`,
64
+ "```",
65
+ res.output.slice(0, 600),
66
+ "```",
67
+ ].join("\n")
68
+ );
69
+ }
70
+
71
+ export function startWatcher(
72
+ dirs: string[],
73
+ onUnsafe: OnUnsafeAction,
74
+ behavioral: boolean,
75
+ apiUrl: string,
76
+ useLLM: boolean,
77
+ policy: string,
78
+ notifyFn: (msg: string) => void,
79
+ logger: any,
80
+ venvPython: string,
81
+ scanScript: string,
82
+ quarantineDir: string
83
+ ): () => void {
84
+ const timers = new Map<string, NodeJS.Timeout>();
85
+
86
+ const watchers = dirs.map((dir) => {
87
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
88
+ logger.info(`[skills-scanner] 👁 监听目录:${dir}`);
89
+
90
+ return fsWatch(dir, { persistent: false }, (_evt, filename) => {
91
+ if (!filename) return;
92
+ const skillPath = join(dir, filename);
93
+ if (!existsSync(skillPath)) return;
94
+
95
+ const prev = timers.get(skillPath);
96
+ if (prev) clearTimeout(prev);
97
+ timers.set(
98
+ skillPath,
99
+ setTimeout(() => {
100
+ timers.delete(skillPath);
101
+ handleNewSkill(
102
+ skillPath,
103
+ onUnsafe,
104
+ behavioral,
105
+ apiUrl,
106
+ useLLM,
107
+ policy,
108
+ notifyFn,
109
+ logger,
110
+ venvPython,
111
+ scanScript,
112
+ quarantineDir
113
+ );
114
+ }, 500)
115
+ );
116
+ });
117
+ });
118
+
119
+ return () => {
120
+ watchers.forEach((w) => w.close());
121
+ timers.forEach((t) => clearTimeout(t));
122
+ timers.clear();
123
+ logger.info("[skills-scanner] 目录监听已停止");
124
+ };
125
+ }