@denial-web/clawguard 0.1.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/.clawguard.example.json +16 -0
- package/LICENSE +21 -0
- package/README.md +241 -0
- package/SECURITY.md +33 -0
- package/action.yml +72 -0
- package/docs/ARCHITECTURE.md +312 -0
- package/docs/ARCHITECTURE_ROADMAP.md +267 -0
- package/docs/CLAWHUB_METADATA.md +57 -0
- package/docs/DEMO_CAPTURE.md +25 -0
- package/docs/DEMO_SCRIPT.md +87 -0
- package/docs/DEPENDENCY_SCANNING.md +61 -0
- package/docs/GITHUB_ACTION.md +56 -0
- package/docs/GITHUB_REPO_SETUP.md +76 -0
- package/docs/HTML_REPORTS.md +27 -0
- package/docs/INTEGRATION_SPEC.md +253 -0
- package/docs/LAUNCH_CHECKLIST.md +64 -0
- package/docs/LAUNCH_PLAN.md +40 -0
- package/docs/LOCAL_PROJECT_ASSETS.md +250 -0
- package/docs/MCP_PLUGIN_SCANNING.md +53 -0
- package/docs/NEXT_SESSION.md +110 -0
- package/docs/NPM_PUBLISHING.md +66 -0
- package/docs/OPENCLAW_CLAWHUB_RESEARCH.md +128 -0
- package/docs/POLICY_MODEL.md +198 -0
- package/docs/PROJECT_REVIEW.md +108 -0
- package/docs/REAL_WORLD_VALIDATION.md +57 -0
- package/docs/RELEASE_NOTES_v0.1.0.md +52 -0
- package/docs/REPORT_SCHEMA.md +81 -0
- package/docs/RULES.md +92 -0
- package/docs/THREAT_MODEL.md +50 -0
- package/docs/WEB_DEMO.md +39 -0
- package/docs/WORKSPACE_SCANNING.md +41 -0
- package/examples/clawhub-origin-without-lock/skills/orphan-helper/.clawhub/origin.json +6 -0
- package/examples/clawhub-origin-without-lock/skills/orphan-helper/SKILL.md +11 -0
- package/examples/clawhub-workspace/.clawhub/lock.json +22 -0
- package/examples/clawhub-workspace/skills/drift-helper/.clawhub/origin.json +6 -0
- package/examples/clawhub-workspace/skills/drift-helper/SKILL.md +11 -0
- package/examples/clawhub-workspace/skills/missing-origin/SKILL.md +11 -0
- package/examples/clawhub-workspace/skills/weather-helper/.clawhub/origin.json +6 -0
- package/examples/clawhub-workspace/skills/weather-helper/SKILL.md +15 -0
- package/examples/declared-api-skill/SKILL.md +27 -0
- package/examples/dependency-python-skill/SKILL.md +16 -0
- package/examples/dependency-python-skill/pyproject.toml +5 -0
- package/examples/dependency-python-skill/requirements.txt +3 -0
- package/examples/dependency-risky-skill/SKILL.md +16 -0
- package/examples/dependency-risky-skill/package.json +12 -0
- package/examples/dependency-safe-skill/SKILL.md +16 -0
- package/examples/dependency-safe-skill/package-lock.json +19 -0
- package/examples/dependency-safe-skill/package.json +7 -0
- package/examples/metadata-mismatch-skill/SKILL.md +22 -0
- package/examples/openclaw-plugin-config/.openclaw/plugins.json +18 -0
- package/examples/openclaw-workspace/.agents/skills/research-helper/SKILL.md +11 -0
- package/examples/openclaw-workspace/skills/notes/SKILL.md +3 -0
- package/examples/openclaw-workspace/skills/research-helper/SKILL.md +17 -0
- package/examples/risky-mcp-config/.cursor/mcp.json +29 -0
- package/examples/risky-openclaw-plugin/openclaw.plugin.json +6 -0
- package/examples/risky-openclaw-plugin/package.json +7 -0
- package/examples/risky-openclaw-plugin/src/index.ts +1 -0
- package/examples/risky-skill/SKILL.md +17 -0
- package/examples/safe-mcp-config/.cursor/mcp.json +15 -0
- package/examples/safe-openclaw-plugin/dist/index.js +1 -0
- package/examples/safe-openclaw-plugin/openclaw.plugin.json +5 -0
- package/examples/safe-openclaw-plugin/package.json +14 -0
- package/examples/safe-skill/SKILL.md +12 -0
- package/package.json +49 -0
- package/schemas/clawguard-report.schema.json +266 -0
- package/scripts/capture-demo.js +206 -0
- package/src/clawhub.js +383 -0
- package/src/cli.js +296 -0
- package/src/config.js +205 -0
- package/src/dependencies.js +417 -0
- package/src/mcp-config.js +592 -0
- package/src/policy.js +165 -0
- package/src/reporters/html.js +482 -0
- package/src/reporters/sarif.js +121 -0
- package/src/rule-catalog.js +400 -0
- package/src/rules.js +121 -0
- package/src/scanner.js +387 -0
- package/src/skill-metadata.js +516 -0
- package/src/web-server.js +395 -0
- package/src/workspace.js +233 -0
- package/web/app.js +374 -0
- package/web/index.html +119 -0
- package/web/styles.css +453 -0
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { createServer } from "node:http";
|
|
4
|
+
import { promises as fs } from "node:fs";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
8
|
+
import { createHtmlReport } from "./reporters/html.js";
|
|
9
|
+
import { scanTarget } from "./scanner.js";
|
|
10
|
+
|
|
11
|
+
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
12
|
+
const publicDir = path.join(rootDir, "web");
|
|
13
|
+
const defaultPort = 4173;
|
|
14
|
+
|
|
15
|
+
const examples = [
|
|
16
|
+
{
|
|
17
|
+
id: "safe-skill",
|
|
18
|
+
label: "Safe Skill",
|
|
19
|
+
description: "Clean baseline with no risky patterns.",
|
|
20
|
+
path: "examples/safe-skill"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
id: "risky-skill",
|
|
24
|
+
label: "Risky Skill",
|
|
25
|
+
description: "Remote code and credential risk signals.",
|
|
26
|
+
path: "examples/risky-skill"
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
id: "metadata-mismatch-skill",
|
|
30
|
+
label: "Metadata Mismatch",
|
|
31
|
+
description: "Observed behavior differs from SKILL.md declarations.",
|
|
32
|
+
path: "examples/metadata-mismatch-skill"
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
id: "openclaw-workspace",
|
|
36
|
+
label: "Workspace Override",
|
|
37
|
+
description: "Duplicate OpenClaw skill names and precedence.",
|
|
38
|
+
path: "examples/openclaw-workspace"
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
id: "clawhub-workspace",
|
|
42
|
+
label: "ClawHub Drift",
|
|
43
|
+
description: "Lockfile, origin, source, and version drift.",
|
|
44
|
+
path: "examples/clawhub-workspace"
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
id: "dependency-risky-skill",
|
|
48
|
+
label: "Dependency Risk",
|
|
49
|
+
description: "Install scripts, direct sources, and loose specs.",
|
|
50
|
+
path: "examples/dependency-risky-skill"
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
id: "dependency-safe-skill",
|
|
54
|
+
label: "Dependency Safe",
|
|
55
|
+
description: "Pinned npm dependency with a lockfile.",
|
|
56
|
+
path: "examples/dependency-safe-skill"
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
id: "dependency-python-skill",
|
|
60
|
+
label: "Python Dependencies",
|
|
61
|
+
description: "Python range and direct-source dependency signals.",
|
|
62
|
+
path: "examples/dependency-python-skill"
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
id: "risky-mcp-config",
|
|
66
|
+
label: "MCP Config",
|
|
67
|
+
description: "Risky MCP/tool configuration.",
|
|
68
|
+
path: "examples/risky-mcp-config"
|
|
69
|
+
}
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
const exampleById = new Map(examples.map((example) => [example.id, example]));
|
|
73
|
+
|
|
74
|
+
export const webExamples = examples;
|
|
75
|
+
|
|
76
|
+
export function createWebServer(options = {}) {
|
|
77
|
+
const appRoot = options.rootDir ?? rootDir;
|
|
78
|
+
const appPublic = options.publicDir ?? publicDir;
|
|
79
|
+
|
|
80
|
+
return createServer(async (request, response) => {
|
|
81
|
+
try {
|
|
82
|
+
if (request.method === "GET" && request.url === "/api/examples") {
|
|
83
|
+
await sendJson(response, { examples });
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (request.method === "POST" && request.url === "/api/scan") {
|
|
88
|
+
const body = await readJsonBody(request);
|
|
89
|
+
const result = await scanPastedSkill(body, appRoot);
|
|
90
|
+
await sendJson(response, result);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (request.method === "POST" && request.url === "/api/scan-files") {
|
|
95
|
+
const body = await readJsonBody(request);
|
|
96
|
+
const result = await scanUploadedFiles(body);
|
|
97
|
+
await sendJson(response, result);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (request.method === "POST" && request.url === "/api/scan-example") {
|
|
102
|
+
const body = await readJsonBody(request);
|
|
103
|
+
const result = await scanExampleTarget(body, appRoot);
|
|
104
|
+
await sendJson(response, result);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (request.method === "POST" && request.url === "/api/html-report") {
|
|
109
|
+
const body = await readJsonBody(request);
|
|
110
|
+
await sendHtml(response, createWebHtmlReport(body));
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (request.method === "GET") {
|
|
115
|
+
await serveStatic(request, response, appPublic);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
await sendJson(response, { error: "Method not allowed" }, 405);
|
|
120
|
+
} catch (error) {
|
|
121
|
+
await sendJson(response, { error: error.message }, error.statusCode ?? 500);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function startWebServer(options = {}) {
|
|
127
|
+
const port = Number(options.port ?? process.env.PORT ?? defaultPort);
|
|
128
|
+
const host = options.host ?? process.env.HOST ?? "127.0.0.1";
|
|
129
|
+
const server = createWebServer(options);
|
|
130
|
+
|
|
131
|
+
server.listen(port, host, () => {
|
|
132
|
+
console.log(`ClawGuard web demo: http://${host}:${port}`);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
return server;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function scanPastedSkill(body, appRoot = rootDir) {
|
|
139
|
+
const text = String(body?.text ?? "").trimEnd();
|
|
140
|
+
const policy = normalizePolicy(body?.policy);
|
|
141
|
+
|
|
142
|
+
if (!text.trim()) {
|
|
143
|
+
throw httpError("Paste SKILL.md content before scanning.", 400);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (Buffer.byteLength(text, "utf8") > 512 * 1024) {
|
|
147
|
+
throw httpError("Pasted content is too large for the demo scanner.", 413);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawguard-paste-"));
|
|
151
|
+
const filename = sanitizeFilename(body?.filename ?? "SKILL.md");
|
|
152
|
+
const filePath = path.join(tempDir, filename);
|
|
153
|
+
|
|
154
|
+
await fs.writeFile(filePath, text, "utf8");
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
const scan = await scanTarget(tempDir, { policy });
|
|
158
|
+
return {
|
|
159
|
+
displayTarget: filename,
|
|
160
|
+
source: "paste",
|
|
161
|
+
scan
|
|
162
|
+
};
|
|
163
|
+
} finally {
|
|
164
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export async function scanUploadedFiles(body) {
|
|
169
|
+
const files = Array.isArray(body?.files) ? body.files : [];
|
|
170
|
+
const policy = normalizePolicy(body?.policy);
|
|
171
|
+
const label = String(body?.label ?? "Uploaded folder").slice(0, 120);
|
|
172
|
+
|
|
173
|
+
if (files.length === 0) {
|
|
174
|
+
throw httpError("Choose a folder with at least one readable file.", 400);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (files.length > 200) {
|
|
178
|
+
throw httpError("Folder has too many files for the demo scanner.", 413);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const totalBytes = files.reduce((sum, file) => {
|
|
182
|
+
return sum + Buffer.byteLength(String(file?.text ?? ""), "utf8");
|
|
183
|
+
}, 0);
|
|
184
|
+
|
|
185
|
+
if (totalBytes > 1024 * 1024) {
|
|
186
|
+
throw httpError("Folder content is too large for the demo scanner.", 413);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawguard-upload-"));
|
|
190
|
+
|
|
191
|
+
try {
|
|
192
|
+
for (const file of files) {
|
|
193
|
+
const relative = safeRelativeUploadPath(file?.path);
|
|
194
|
+
const text = String(file?.text ?? "");
|
|
195
|
+
|
|
196
|
+
if (Buffer.byteLength(text, "utf8") > 512 * 1024) {
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const destination = path.join(tempDir, relative);
|
|
201
|
+
await fs.mkdir(path.dirname(destination), { recursive: true });
|
|
202
|
+
await fs.writeFile(destination, text, "utf8");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const scan = await scanTarget(tempDir, { policy });
|
|
206
|
+
return {
|
|
207
|
+
displayTarget: label || "Uploaded folder",
|
|
208
|
+
source: "folder",
|
|
209
|
+
scan
|
|
210
|
+
};
|
|
211
|
+
} finally {
|
|
212
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export async function scanExampleTarget(body, appRoot = rootDir) {
|
|
217
|
+
const id = String(body?.example ?? "");
|
|
218
|
+
const policy = normalizePolicy(body?.policy);
|
|
219
|
+
const example = exampleById.get(id);
|
|
220
|
+
|
|
221
|
+
if (!example) {
|
|
222
|
+
throw httpError("Unknown example.", 404);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const target = path.resolve(appRoot, example.path);
|
|
226
|
+
const examplesRoot = path.resolve(appRoot, "examples");
|
|
227
|
+
|
|
228
|
+
if (!isInsidePath(examplesRoot, target)) {
|
|
229
|
+
throw httpError("Example path is outside the examples directory.", 400);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
displayTarget: example.label,
|
|
234
|
+
source: "example",
|
|
235
|
+
example,
|
|
236
|
+
scan: await scanTarget(target, { policy })
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function createWebHtmlReport(body) {
|
|
241
|
+
if (!body?.scan || typeof body.scan !== "object") {
|
|
242
|
+
throw httpError("Scan result is required to create an HTML report.", 400);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return createHtmlReport(body.scan);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function serveStatic(request, response, appPublic) {
|
|
249
|
+
const url = new URL(request.url, "http://localhost");
|
|
250
|
+
const pathname = decodeURIComponent(url.pathname);
|
|
251
|
+
const safePath = pathname === "/" ? "/index.html" : pathname;
|
|
252
|
+
const filePath = path.resolve(appPublic, `.${safePath}`);
|
|
253
|
+
|
|
254
|
+
if (!isInsidePath(path.resolve(appPublic), filePath)) {
|
|
255
|
+
await sendJson(response, { error: "Not found" }, 404);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
try {
|
|
260
|
+
const stat = await fs.stat(filePath);
|
|
261
|
+
if (!stat.isFile()) {
|
|
262
|
+
throw httpError("Not found", 404);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
response.writeHead(200, {
|
|
266
|
+
"content-type": contentTypeFor(filePath),
|
|
267
|
+
"cache-control": "no-store"
|
|
268
|
+
});
|
|
269
|
+
response.end(await fs.readFile(filePath));
|
|
270
|
+
} catch (error) {
|
|
271
|
+
if (error.code === "ENOENT" || error.statusCode === 404) {
|
|
272
|
+
response.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
273
|
+
response.end("Not found");
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
throw error;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function readJsonBody(request) {
|
|
282
|
+
return new Promise((resolve, reject) => {
|
|
283
|
+
let data = "";
|
|
284
|
+
|
|
285
|
+
request.setEncoding("utf8");
|
|
286
|
+
request.on("data", (chunk) => {
|
|
287
|
+
data += chunk;
|
|
288
|
+
if (data.length > 700 * 1024) {
|
|
289
|
+
reject(httpError("Request body is too large.", 413));
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
request.on("end", () => {
|
|
293
|
+
try {
|
|
294
|
+
resolve(data ? JSON.parse(data) : {});
|
|
295
|
+
} catch {
|
|
296
|
+
reject(httpError("Request body must be valid JSON.", 400));
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
request.on("error", reject);
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function sendJson(response, payload, statusCode = 200) {
|
|
304
|
+
response.writeHead(statusCode, {
|
|
305
|
+
"content-type": "application/json; charset=utf-8",
|
|
306
|
+
"cache-control": "no-store"
|
|
307
|
+
});
|
|
308
|
+
response.end(JSON.stringify(payload, null, 2));
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function sendHtml(response, html, statusCode = 200) {
|
|
312
|
+
response.writeHead(statusCode, {
|
|
313
|
+
"content-type": "text/html; charset=utf-8",
|
|
314
|
+
"cache-control": "no-store"
|
|
315
|
+
});
|
|
316
|
+
response.end(html);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function contentTypeFor(filePath) {
|
|
320
|
+
const ext = path.extname(filePath);
|
|
321
|
+
if (ext === ".html") return "text/html; charset=utf-8";
|
|
322
|
+
if (ext === ".css") return "text/css; charset=utf-8";
|
|
323
|
+
if (ext === ".js") return "text/javascript; charset=utf-8";
|
|
324
|
+
if (ext === ".json") return "application/json; charset=utf-8";
|
|
325
|
+
return "application/octet-stream";
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function sanitizeFilename(value) {
|
|
329
|
+
const filename = path.basename(String(value || "SKILL.md")).replace(/[^A-Za-z0-9_.-]/g, "-");
|
|
330
|
+
return filename || "SKILL.md";
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function safeRelativeUploadPath(value) {
|
|
334
|
+
const normalized = String(value ?? "")
|
|
335
|
+
.replaceAll("\\", "/")
|
|
336
|
+
.split("/")
|
|
337
|
+
.filter((part) => part && part !== "." && part !== "..")
|
|
338
|
+
.map((part) => part.replace(/[^A-Za-z0-9_. -]/g, "-"))
|
|
339
|
+
.join("/");
|
|
340
|
+
|
|
341
|
+
if (!normalized) {
|
|
342
|
+
throw httpError("Uploaded file has an invalid path.", 400);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return normalized;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function normalizePolicy(value) {
|
|
349
|
+
return ["personal", "governed", "enterprise"].includes(value) ? value : "personal";
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function httpError(message, statusCode) {
|
|
353
|
+
const error = new Error(message);
|
|
354
|
+
error.statusCode = statusCode;
|
|
355
|
+
return error;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function isInsidePath(parent, child) {
|
|
359
|
+
const relative = path.relative(parent, child);
|
|
360
|
+
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
364
|
+
const server = startWebServer(parseCliArgs(process.argv.slice(2)));
|
|
365
|
+
server.on("error", (error) => {
|
|
366
|
+
if (error.code === "EADDRINUSE") {
|
|
367
|
+
console.error(`Port is already in use. Try: npm run web -- --port ${defaultPort + 1}`);
|
|
368
|
+
process.exit(1);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
throw error;
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function parseCliArgs(args) {
|
|
376
|
+
const options = {};
|
|
377
|
+
|
|
378
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
379
|
+
const arg = args[index];
|
|
380
|
+
|
|
381
|
+
if (arg === "--port") {
|
|
382
|
+
options.port = Number(args[index + 1]);
|
|
383
|
+
index += 1;
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (arg === "--host") {
|
|
388
|
+
options.host = args[index + 1];
|
|
389
|
+
index += 1;
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return options;
|
|
395
|
+
}
|
package/src/workspace.js
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { severityWeights } from "./rules.js";
|
|
3
|
+
|
|
4
|
+
const locations = {
|
|
5
|
+
"skills": {
|
|
6
|
+
kind: "workspace-skills",
|
|
7
|
+
label: "workspace skills",
|
|
8
|
+
precedence: 20
|
|
9
|
+
},
|
|
10
|
+
".agents/skills": {
|
|
11
|
+
kind: "project-agent-skills",
|
|
12
|
+
label: "project agent skills",
|
|
13
|
+
precedence: 10
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function analyzeWorkspaceSkills(fileRecords, existingFindings, basePath = process.cwd()) {
|
|
18
|
+
const skills = discoverWorkspaceSkills(fileRecords, existingFindings, basePath);
|
|
19
|
+
const findings = [];
|
|
20
|
+
const duplicates = [];
|
|
21
|
+
|
|
22
|
+
for (const [name, groupedSkills] of groupByName(skills)) {
|
|
23
|
+
if (groupedSkills.length < 2) {
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const sorted = groupedSkills.sort((a, b) => b.precedence - a.precedence || a.skillFile.localeCompare(b.skillFile));
|
|
28
|
+
const winner = sorted[0];
|
|
29
|
+
const overridden = sorted.slice(1);
|
|
30
|
+
const evidence = `${name}: ${sorted.map((skill) => skill.skillFile).join(", ")}`;
|
|
31
|
+
|
|
32
|
+
duplicates.push({
|
|
33
|
+
name,
|
|
34
|
+
winner: winner.skillFile,
|
|
35
|
+
overridden: overridden.map((skill) => skill.skillFile)
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
findings.push(createFinding({
|
|
39
|
+
ruleId: "workspace-duplicate-skill-name",
|
|
40
|
+
title: "Workspace contains duplicate skill names",
|
|
41
|
+
severity: "medium",
|
|
42
|
+
recommendation: "Review duplicate skill names so users know which skill OpenClaw will load.",
|
|
43
|
+
file: winner.skillFile,
|
|
44
|
+
line: winner.nameLine,
|
|
45
|
+
evidence
|
|
46
|
+
}));
|
|
47
|
+
|
|
48
|
+
findings.push(createFinding({
|
|
49
|
+
ruleId: "workspace-skill-override",
|
|
50
|
+
title: "Higher-precedence workspace skill overrides another skill",
|
|
51
|
+
severity: "medium",
|
|
52
|
+
recommendation: "Confirm the effective skill is the intended one before trusting this workspace.",
|
|
53
|
+
file: winner.skillFile,
|
|
54
|
+
line: winner.nameLine,
|
|
55
|
+
evidence: `${winner.skillFile} overrides ${overridden.map((skill) => skill.skillFile).join(", ")}`
|
|
56
|
+
}));
|
|
57
|
+
|
|
58
|
+
const riskiestOverridden = overridden.reduce((riskiest, skill) => {
|
|
59
|
+
return skill.score > riskiest.score ? skill : riskiest;
|
|
60
|
+
}, { score: 0 });
|
|
61
|
+
|
|
62
|
+
if (winner.score > riskiestOverridden.score && winner.score >= severityWeights.medium) {
|
|
63
|
+
findings.push(createFinding({
|
|
64
|
+
ruleId: "workspace-risky-skill-override",
|
|
65
|
+
title: "Winning workspace skill is riskier than the skill it overrides",
|
|
66
|
+
severity: "high",
|
|
67
|
+
recommendation: "Review the higher-precedence skill carefully because it changes the effective trusted behavior.",
|
|
68
|
+
file: winner.skillFile,
|
|
69
|
+
line: winner.nameLine,
|
|
70
|
+
evidence: `${winner.skillFile} score ${winner.score} overrides lower-precedence score ${riskiestOverridden.score}`
|
|
71
|
+
}));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
findings,
|
|
77
|
+
workspace: {
|
|
78
|
+
skills: skills.map(publicSkillInfo),
|
|
79
|
+
duplicates
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function discoverWorkspaceSkills(fileRecords, existingFindings = [], basePath = process.cwd()) {
|
|
85
|
+
return fileRecords
|
|
86
|
+
.filter((record) => isSkillFile(record.file))
|
|
87
|
+
.map((record) => toWorkspaceSkill(record, existingFindings, basePath))
|
|
88
|
+
.filter(Boolean);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function toWorkspaceSkill(record, existingFindings, basePath) {
|
|
92
|
+
const relative = toPosixPath(relativePath(basePath, record.file));
|
|
93
|
+
const location = locationFor(relative);
|
|
94
|
+
|
|
95
|
+
if (!location) {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const identity = parseSkillIdentity(record.text, path.basename(path.dirname(record.file)));
|
|
100
|
+
const skillDir = path.dirname(relative);
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
name: identity.name,
|
|
104
|
+
nameLine: identity.line,
|
|
105
|
+
locationKind: location.kind,
|
|
106
|
+
locationLabel: location.label,
|
|
107
|
+
precedence: location.precedence,
|
|
108
|
+
skillDir,
|
|
109
|
+
skillFile: relative,
|
|
110
|
+
score: scoreForSkill(skillDir, existingFindings)
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function parseSkillIdentity(text, fallbackName) {
|
|
115
|
+
const lines = text.replace(/^\uFEFF/, "").split(/\r?\n/);
|
|
116
|
+
|
|
117
|
+
if (lines[0]?.trim() === "---") {
|
|
118
|
+
const endIndex = lines.findIndex((line, index) => index > 0 && ["---", "..."].includes(line.trim()));
|
|
119
|
+
const frontmatterEnd = endIndex === -1 ? lines.length : endIndex;
|
|
120
|
+
|
|
121
|
+
for (let index = 1; index < frontmatterEnd; index += 1) {
|
|
122
|
+
const match = /^name\s*:\s*(.+)\s*$/i.exec(lines[index].trim());
|
|
123
|
+
if (match) {
|
|
124
|
+
return {
|
|
125
|
+
name: cleanName(match[1]),
|
|
126
|
+
line: index + 1
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
133
|
+
const match = /^#\s+(.+)\s*$/.exec(lines[index]);
|
|
134
|
+
if (match) {
|
|
135
|
+
return {
|
|
136
|
+
name: slugifyName(match[1]),
|
|
137
|
+
line: index + 1
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
name: slugifyName(fallbackName),
|
|
144
|
+
line: 1
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function locationFor(relativePathValue) {
|
|
149
|
+
if (relativePathValue.startsWith("skills/")) {
|
|
150
|
+
return locations.skills;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (relativePathValue.startsWith(".agents/skills/")) {
|
|
154
|
+
return locations[".agents/skills"];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function scoreForSkill(skillDir, findings) {
|
|
161
|
+
const rawScore = findings.reduce((sum, finding) => {
|
|
162
|
+
if (finding.file === skillDir || finding.file.startsWith(`${skillDir}/`)) {
|
|
163
|
+
return sum + severityWeights[finding.severity];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return sum;
|
|
167
|
+
}, 0);
|
|
168
|
+
|
|
169
|
+
return Math.min(100, rawScore);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function groupByName(skills) {
|
|
173
|
+
const groups = new Map();
|
|
174
|
+
|
|
175
|
+
for (const skill of skills) {
|
|
176
|
+
const key = skill.name.toLowerCase();
|
|
177
|
+
const current = groups.get(key) ?? [];
|
|
178
|
+
current.push(skill);
|
|
179
|
+
groups.set(key, current);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return groups;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function publicSkillInfo(skill) {
|
|
186
|
+
return {
|
|
187
|
+
name: skill.name,
|
|
188
|
+
locationKind: skill.locationKind,
|
|
189
|
+
precedence: skill.precedence,
|
|
190
|
+
skillDir: skill.skillDir,
|
|
191
|
+
skillFile: skill.skillFile,
|
|
192
|
+
score: skill.score
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function createFinding({ ruleId, title, severity, recommendation, file, line, evidence }) {
|
|
197
|
+
return {
|
|
198
|
+
ruleId,
|
|
199
|
+
title,
|
|
200
|
+
severity,
|
|
201
|
+
recommendation,
|
|
202
|
+
file,
|
|
203
|
+
line,
|
|
204
|
+
evidence
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function isSkillFile(file) {
|
|
209
|
+
return ["skill.md", "SKILL.md"].includes(path.basename(file));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function cleanName(value) {
|
|
213
|
+
return String(value ?? "")
|
|
214
|
+
.trim()
|
|
215
|
+
.replace(/^["']+|["']+$/g, "")
|
|
216
|
+
.trim();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function slugifyName(value) {
|
|
220
|
+
return cleanName(value)
|
|
221
|
+
.toLowerCase()
|
|
222
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
223
|
+
.replace(/^-+|-+$/g, "") || "unnamed-skill";
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function toPosixPath(value) {
|
|
227
|
+
return value.split(path.sep).join("/");
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function relativePath(basePath, filePath) {
|
|
231
|
+
const relative = path.relative(basePath, filePath);
|
|
232
|
+
return relative || path.basename(filePath);
|
|
233
|
+
}
|