@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/README.md +255 -0
- package/index.ts +647 -0
- package/openclaw.plugin.json +122 -0
- package/package.json +64 -0
- package/skills/skills-scanner/SKILL.md +281 -0
- package/src/api-client.ts +245 -0
- package/src/before-install-hook.ts +241 -0
- package/src/cache.ts +138 -0
- package/src/commands.ts +289 -0
- package/src/config-validator.ts +110 -0
- package/src/config.ts +230 -0
- package/src/cron-manager.ts +210 -0
- package/src/debug.ts +40 -0
- package/src/error-handler.ts +103 -0
- package/src/high-risk-operation-guard.ts +62 -0
- package/src/metrics.ts +140 -0
- package/src/prompt-guidance.ts +80 -0
- package/src/prompt-injection-guard.ts +56 -0
- package/src/rate-limiter.ts +102 -0
- package/src/report.ts +128 -0
- package/src/scanner.ts +230 -0
- package/src/state.ts +136 -0
- package/src/structured-logger.ts +97 -0
- package/src/types.ts +76 -0
- package/src/watcher.ts +178 -0
|
@@ -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
|
+
}
|