@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,516 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { isClawHubMetadataFile } from "./clawhub.js";
|
|
3
|
+
import { isDependencyFile } from "./dependencies.js";
|
|
4
|
+
|
|
5
|
+
const requiredSkillFields = ["name", "description", "version", "author", "category"];
|
|
6
|
+
const sensitiveEnvNames = new Set(["HOME", "PATH", "PWD", "SHELL", "USER", "USERNAME", "NODE_ENV"]);
|
|
7
|
+
const binaryNames = [
|
|
8
|
+
"brew",
|
|
9
|
+
"curl",
|
|
10
|
+
"docker",
|
|
11
|
+
"gh",
|
|
12
|
+
"git",
|
|
13
|
+
"go",
|
|
14
|
+
"node",
|
|
15
|
+
"npm",
|
|
16
|
+
"npx",
|
|
17
|
+
"openssl",
|
|
18
|
+
"pip",
|
|
19
|
+
"pip3",
|
|
20
|
+
"pnpm",
|
|
21
|
+
"powershell",
|
|
22
|
+
"python",
|
|
23
|
+
"python3",
|
|
24
|
+
"rsync",
|
|
25
|
+
"scp",
|
|
26
|
+
"ssh",
|
|
27
|
+
"uv",
|
|
28
|
+
"wget",
|
|
29
|
+
"yarn"
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
const configPathPatterns = [
|
|
33
|
+
/\.env\b/i,
|
|
34
|
+
/\.npmrc\b/i,
|
|
35
|
+
/\.pypirc\b/i,
|
|
36
|
+
/\.aws\/credentials\b/i,
|
|
37
|
+
/\.ssh\/config\b/i,
|
|
38
|
+
/\.cursor\/mcp\.json\b/i,
|
|
39
|
+
/\.openclaw\/[a-z0-9_.\/-]+/i,
|
|
40
|
+
/\bconfig\.json\b/i
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
export function analyzeSkillMetadata(fileRecords, basePath = process.cwd()) {
|
|
44
|
+
const findings = [];
|
|
45
|
+
const skillFiles = fileRecords.filter((record) => isSkillFile(record.file));
|
|
46
|
+
|
|
47
|
+
for (const skillFile of skillFiles) {
|
|
48
|
+
const parsed = parseSkillFrontmatter(skillFile.text);
|
|
49
|
+
|
|
50
|
+
if (!parsed.frontmatter) {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
findings.push(...findMissingMetadataFields(parsed, skillFile, basePath));
|
|
55
|
+
|
|
56
|
+
const skillDir = path.dirname(skillFile.file);
|
|
57
|
+
const relatedRecords = fileRecords.filter((record) => {
|
|
58
|
+
return isInsideDir(record.file, skillDir) && !isClawHubMetadataFile(record.file, basePath) && !isDependencyFile(record.file);
|
|
59
|
+
});
|
|
60
|
+
const observed = collectObservedBehavior(relatedRecords);
|
|
61
|
+
|
|
62
|
+
findings.push(...findUndeclaredEnv(parsed, observed.envVars, basePath));
|
|
63
|
+
findings.push(...findUndeclaredBinaries(parsed, observed.binaries, basePath));
|
|
64
|
+
findings.push(...findUndeclaredConfig(parsed, observed.configPaths, basePath));
|
|
65
|
+
findings.push(...findUndeclaredNetwork(parsed, observed.network, basePath));
|
|
66
|
+
findings.push(...findUndeclaredInstall(parsed, observed.install, basePath));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return findings;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function parseSkillFrontmatter(text) {
|
|
73
|
+
const normalizedText = text.replace(/^\uFEFF/, "");
|
|
74
|
+
const lines = normalizedText.split(/\r?\n/);
|
|
75
|
+
|
|
76
|
+
if (lines[0]?.trim() !== "---") {
|
|
77
|
+
return {
|
|
78
|
+
frontmatter: null,
|
|
79
|
+
body: normalizedText,
|
|
80
|
+
declarations: emptyDeclarations()
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const endIndex = lines.findIndex((line, index) => index > 0 && ["---", "..."].includes(line.trim()));
|
|
85
|
+
|
|
86
|
+
if (endIndex === -1) {
|
|
87
|
+
return {
|
|
88
|
+
frontmatter: null,
|
|
89
|
+
body: normalizedText,
|
|
90
|
+
declarations: emptyDeclarations(),
|
|
91
|
+
error: "unterminated-frontmatter"
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const frontmatter = lines.slice(1, endIndex).join("\n");
|
|
96
|
+
const body = lines.slice(endIndex + 1).join("\n");
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
frontmatter,
|
|
100
|
+
body,
|
|
101
|
+
declarations: normalizeFrontmatter(frontmatter)
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function normalizeFrontmatter(frontmatter) {
|
|
106
|
+
const declarations = emptyDeclarations();
|
|
107
|
+
const contexts = [];
|
|
108
|
+
const lines = frontmatter.split(/\r?\n/);
|
|
109
|
+
|
|
110
|
+
for (const rawLine of lines) {
|
|
111
|
+
const line = stripInlineComment(rawLine);
|
|
112
|
+
|
|
113
|
+
if (!line.trim()) {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const indent = countIndent(line);
|
|
118
|
+
const trimmed = line.trim();
|
|
119
|
+
|
|
120
|
+
while (contexts.length > 0 && indent <= contexts.at(-1).indent) {
|
|
121
|
+
contexts.pop();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (trimmed.startsWith("- ")) {
|
|
125
|
+
const value = trimmed.slice(2).trim();
|
|
126
|
+
const key = contexts.at(-1)?.key ?? "";
|
|
127
|
+
collectDeclaredValue(declarations, key, value);
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const keyValue = /^([A-Za-z0-9_.-]+)\s*:\s*(.*)$/.exec(trimmed);
|
|
132
|
+
if (!keyValue) {
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const key = keyValue[1];
|
|
137
|
+
const value = keyValue[2].trim();
|
|
138
|
+
const keyLower = key.toLowerCase();
|
|
139
|
+
const parentKey = contexts.at(-1)?.key ?? "";
|
|
140
|
+
|
|
141
|
+
declarations.fields.add(keyLower);
|
|
142
|
+
collectContextDeclaredValue(declarations, parentKey, key, value);
|
|
143
|
+
collectDeclaredValue(declarations, keyLower, value);
|
|
144
|
+
|
|
145
|
+
if (value === "") {
|
|
146
|
+
contexts.push({ indent, key: keyLower });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return freezeDeclarations(declarations);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function findMissingMetadataFields(parsed, skillFile, basePath) {
|
|
154
|
+
const missing = requiredSkillFields.filter((field) => !parsed.declarations.fields.has(field));
|
|
155
|
+
|
|
156
|
+
if (missing.length === 0) {
|
|
157
|
+
return [];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return [
|
|
161
|
+
{
|
|
162
|
+
ruleId: "missing-skill-metadata",
|
|
163
|
+
title: "Missing recommended OpenClaw skill metadata",
|
|
164
|
+
severity: "low",
|
|
165
|
+
recommendation: "Add complete SKILL.md frontmatter so users and registries can understand requirements.",
|
|
166
|
+
file: relativePath(basePath, skillFile.file),
|
|
167
|
+
line: 1,
|
|
168
|
+
evidence: `Missing fields: ${missing.join(", ")}`
|
|
169
|
+
}
|
|
170
|
+
];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function findUndeclaredEnv(parsed, envVars, basePath) {
|
|
174
|
+
return firstUndeclared(envVars, (item) => {
|
|
175
|
+
return !parsed.declarations.env.has(item.value);
|
|
176
|
+
}).map((item) => ({
|
|
177
|
+
ruleId: "undeclared-env-access",
|
|
178
|
+
title: "Uses environment secrets not declared in skill metadata",
|
|
179
|
+
severity: "high",
|
|
180
|
+
recommendation: "Declare required environment variables under metadata.openclaw before users install the skill.",
|
|
181
|
+
file: relativePath(basePath, item.file),
|
|
182
|
+
line: item.line,
|
|
183
|
+
evidence: item.value
|
|
184
|
+
}));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function findUndeclaredBinaries(parsed, binaries, basePath) {
|
|
188
|
+
return firstUndeclared(binaries, (item) => {
|
|
189
|
+
return !parsed.declarations.bins.has(item.value.toLowerCase());
|
|
190
|
+
}).map((item) => ({
|
|
191
|
+
ruleId: "undeclared-binary-requirement",
|
|
192
|
+
title: "Uses a command-line tool not declared in skill metadata",
|
|
193
|
+
severity: "medium",
|
|
194
|
+
recommendation: "Declare required binaries under metadata.openclaw.requires.bins or anyBins.",
|
|
195
|
+
file: relativePath(basePath, item.file),
|
|
196
|
+
line: item.line,
|
|
197
|
+
evidence: item.value
|
|
198
|
+
}));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function findUndeclaredConfig(parsed, configPaths, basePath) {
|
|
202
|
+
return firstUndeclared(configPaths, (item) => {
|
|
203
|
+
return !hasDeclaredConfig(parsed.declarations.config, item.value);
|
|
204
|
+
}).map((item) => ({
|
|
205
|
+
ruleId: "undeclared-config-access",
|
|
206
|
+
title: "Reads config paths not declared in skill metadata",
|
|
207
|
+
severity: "medium",
|
|
208
|
+
recommendation: "Declare required config paths under metadata.openclaw.requires.config.",
|
|
209
|
+
file: relativePath(basePath, item.file),
|
|
210
|
+
line: item.line,
|
|
211
|
+
evidence: item.value
|
|
212
|
+
}));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function findUndeclaredNetwork(parsed, networkAccess, basePath) {
|
|
216
|
+
if (parsed.declarations.network || networkAccess.length === 0) {
|
|
217
|
+
return [];
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return firstUndeclared(networkAccess, () => true).map((item) => ({
|
|
221
|
+
ruleId: "undeclared-network-access",
|
|
222
|
+
title: "Uses network access not declared in skill metadata",
|
|
223
|
+
severity: "medium",
|
|
224
|
+
recommendation: "Declare network requirements or permissions so users can make an informed trust decision.",
|
|
225
|
+
file: relativePath(basePath, item.file),
|
|
226
|
+
line: item.line,
|
|
227
|
+
evidence: item.value
|
|
228
|
+
}));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function findUndeclaredInstall(parsed, installBehavior, basePath) {
|
|
232
|
+
if (parsed.declarations.install || installBehavior.length === 0) {
|
|
233
|
+
return [];
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return firstUndeclared(installBehavior, () => true).slice(0, 1).map((item) => ({
|
|
237
|
+
ruleId: "undeclared-install-requirement",
|
|
238
|
+
title: "Mentions install behavior not declared in skill metadata",
|
|
239
|
+
severity: "high",
|
|
240
|
+
recommendation: "Declare install requirements explicitly and avoid hidden setup steps.",
|
|
241
|
+
file: relativePath(basePath, item.file),
|
|
242
|
+
line: item.line,
|
|
243
|
+
evidence: item.value
|
|
244
|
+
}));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function collectObservedBehavior(records) {
|
|
248
|
+
const observed = {
|
|
249
|
+
envVars: [],
|
|
250
|
+
binaries: [],
|
|
251
|
+
configPaths: [],
|
|
252
|
+
network: [],
|
|
253
|
+
install: []
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
for (const record of records) {
|
|
257
|
+
collectMatches(record, observed.envVars, [
|
|
258
|
+
/\bprocess\.env\.([A-Z][A-Z0-9_]{2,})\b/g,
|
|
259
|
+
/\$([A-Z][A-Z0-9_]{2,})\b/g,
|
|
260
|
+
/\b([A-Z][A-Z0-9_]*(?:API_KEY|TOKEN|SECRET|PASSWORD|PASS|PRIVATE_KEY|ACCESS_KEY|CREDENTIALS)[A-Z0-9_]*)\b/g
|
|
261
|
+
], (match) => normalizeEnvName(match[1]));
|
|
262
|
+
|
|
263
|
+
collectMatches(record, observed.binaries, [
|
|
264
|
+
new RegExp(`(?:^|\\n|\\bRun\\s+|\\bUse\\s+|\\brequires?\\s+|\\bneeds?\\s+|[\\\`'"])(${binaryNames.join("|")})\\b`, "gi")
|
|
265
|
+
], (match) => match[1].toLowerCase());
|
|
266
|
+
|
|
267
|
+
collectMatches(record, observed.network, [
|
|
268
|
+
/https?:\/\/[^\s)]+/gi,
|
|
269
|
+
/\b(?:fetch|axios|request)\s*\(/gi,
|
|
270
|
+
/\b(?:webhook|api endpoint|callback url)\b/gi
|
|
271
|
+
]);
|
|
272
|
+
|
|
273
|
+
collectMatches(record, observed.install, [
|
|
274
|
+
/\b(?:npm|pnpm|yarn|uv|pip|pip3|brew|go)\s+(?:install|add|get)\b/gi,
|
|
275
|
+
/\b(?:setup command|install command)\b/gi,
|
|
276
|
+
/\b(?:preinstall|postinstall|prepare)\b/gi
|
|
277
|
+
]);
|
|
278
|
+
|
|
279
|
+
for (const pattern of configPathPatterns) {
|
|
280
|
+
collectMatches(record, observed.configPaths, [pattern]);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
observed.envVars = uniqueObservations(observed.envVars.filter((item) => item.value));
|
|
285
|
+
observed.binaries = uniqueObservations(observed.binaries);
|
|
286
|
+
observed.configPaths = uniqueObservations(observed.configPaths);
|
|
287
|
+
observed.network = uniqueObservations(observed.network);
|
|
288
|
+
observed.install = uniqueObservations(observed.install);
|
|
289
|
+
|
|
290
|
+
return observed;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function collectMatches(record, bucket, patterns, valueFromMatch = (match) => match[0]) {
|
|
294
|
+
for (const pattern of patterns) {
|
|
295
|
+
const matcher = toGlobalRegex(pattern);
|
|
296
|
+
let match;
|
|
297
|
+
|
|
298
|
+
while ((match = matcher.exec(record.text))) {
|
|
299
|
+
if (match[0] === "") {
|
|
300
|
+
matcher.lastIndex += 1;
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const value = cleanScalar(valueFromMatch(match));
|
|
305
|
+
if (!value) {
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
bucket.push({
|
|
310
|
+
value,
|
|
311
|
+
file: record.file,
|
|
312
|
+
line: lineNumberForIndex(record.text, match.index ?? 0)
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function collectDeclaredValue(declarations, key, rawValue) {
|
|
319
|
+
const values = parseYamlScalarList(rawValue);
|
|
320
|
+
|
|
321
|
+
if (["env", "envvars", "primaryenv", "requiredenv", "environment_variables"].includes(key)) {
|
|
322
|
+
addValues(declarations.env, values.map(normalizeEnvName));
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (["bins", "anybins", "bin", "commands"].includes(key)) {
|
|
327
|
+
addValues(declarations.bins, values.map((value) => value.toLowerCase()));
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (["config", "configs"].includes(key)) {
|
|
332
|
+
addValues(declarations.config, values.map((value) => value.toLowerCase()));
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (key === "install") {
|
|
337
|
+
declarations.install = true;
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (["permissions", "permission"].includes(key)) {
|
|
342
|
+
addValues(declarations.permissions, values.map((value) => value.toLowerCase()));
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (["network", "network_access"].includes(key) && isTruthy(rawValue)) {
|
|
346
|
+
declarations.network = true;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (key === "safety_level" && rawValue.toLowerCase().includes("network")) {
|
|
350
|
+
declarations.network = true;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (values.some((value) => value.toLowerCase().includes("network_access"))) {
|
|
354
|
+
declarations.network = true;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function collectContextDeclaredValue(declarations, parentKey, key, rawValue) {
|
|
359
|
+
if (!["envvars", "environment_variables"].includes(parentKey)) {
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const envName = normalizeEnvName(key);
|
|
364
|
+
if (envName) {
|
|
365
|
+
declarations.env.add(envName);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
addValues(declarations.env, parseYamlScalarList(rawValue).map(normalizeEnvName));
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function parseYamlScalarList(rawValue) {
|
|
372
|
+
const value = cleanScalar(rawValue);
|
|
373
|
+
|
|
374
|
+
if (!value || value === "{}" || value === "[]") {
|
|
375
|
+
return [];
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (value.startsWith("[") && value.endsWith("]")) {
|
|
379
|
+
return value
|
|
380
|
+
.slice(1, -1)
|
|
381
|
+
.split(",")
|
|
382
|
+
.map(cleanScalar)
|
|
383
|
+
.filter(Boolean);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const inlineKeyValue = /^[A-Za-z0-9_.-]+\s*:\s*(.+)$/.exec(value);
|
|
387
|
+
if (inlineKeyValue) {
|
|
388
|
+
return parseYamlScalarList(inlineKeyValue[1]);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
return [value];
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function firstUndeclared(items, predicate) {
|
|
395
|
+
const findings = [];
|
|
396
|
+
const seen = new Set();
|
|
397
|
+
|
|
398
|
+
for (const item of items) {
|
|
399
|
+
const key = item.value.toLowerCase();
|
|
400
|
+
if (seen.has(key) || !predicate(item)) {
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
seen.add(key);
|
|
404
|
+
findings.push(item);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
return findings.slice(0, 5);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function hasDeclaredConfig(declaredConfig, observedValue) {
|
|
411
|
+
const observed = observedValue.toLowerCase();
|
|
412
|
+
|
|
413
|
+
for (const declared of declaredConfig) {
|
|
414
|
+
if (observed.includes(declared) || declared.includes(observed)) {
|
|
415
|
+
return true;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
return false;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function isSkillFile(file) {
|
|
423
|
+
return ["skill.md", "SKILL.md"].includes(path.basename(file));
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function isInsideDir(file, dir) {
|
|
427
|
+
const relative = path.relative(dir, file);
|
|
428
|
+
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function emptyDeclarations() {
|
|
432
|
+
return {
|
|
433
|
+
env: new Set(),
|
|
434
|
+
bins: new Set(),
|
|
435
|
+
config: new Set(),
|
|
436
|
+
permissions: new Set(),
|
|
437
|
+
fields: new Set(),
|
|
438
|
+
install: false,
|
|
439
|
+
network: false
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function freezeDeclarations(declarations) {
|
|
444
|
+
return {
|
|
445
|
+
env: declarations.env,
|
|
446
|
+
bins: declarations.bins,
|
|
447
|
+
config: declarations.config,
|
|
448
|
+
permissions: declarations.permissions,
|
|
449
|
+
fields: declarations.fields,
|
|
450
|
+
install: declarations.install,
|
|
451
|
+
network: declarations.network
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function addValues(target, values) {
|
|
456
|
+
for (const value of values) {
|
|
457
|
+
if (value) {
|
|
458
|
+
target.add(value);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function stripInlineComment(line) {
|
|
464
|
+
return line.replace(/\s+#.*$/, "");
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function countIndent(line) {
|
|
468
|
+
return /^ */.exec(line)?.[0].length ?? 0;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function cleanScalar(value) {
|
|
472
|
+
return String(value ?? "")
|
|
473
|
+
.trim()
|
|
474
|
+
.replace(/^["'`]+|["'`]+$/g, "")
|
|
475
|
+
.replace(/[),.;:]+$/g, "")
|
|
476
|
+
.trim();
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function normalizeEnvName(value) {
|
|
480
|
+
const envName = cleanScalar(value).toUpperCase();
|
|
481
|
+
return sensitiveEnvNames.has(envName) ? "" : envName;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function uniqueObservations(items) {
|
|
485
|
+
const seen = new Set();
|
|
486
|
+
const unique = [];
|
|
487
|
+
|
|
488
|
+
for (const item of items) {
|
|
489
|
+
const key = `${item.file}:${item.line}:${item.value.toLowerCase()}`;
|
|
490
|
+
if (!seen.has(key)) {
|
|
491
|
+
seen.add(key);
|
|
492
|
+
unique.push(item);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
return unique;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function toGlobalRegex(pattern) {
|
|
500
|
+
const flags = new Set(pattern.flags.split(""));
|
|
501
|
+
flags.add("g");
|
|
502
|
+
return new RegExp(pattern.source, [...flags].join(""));
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function lineNumberForIndex(text, index) {
|
|
506
|
+
return text.slice(0, index).split("\n").length;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function relativePath(basePath, filePath) {
|
|
510
|
+
const relative = path.relative(basePath, filePath);
|
|
511
|
+
return relative || path.basename(filePath);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function isTruthy(value) {
|
|
515
|
+
return ["true", "yes", "on", "1"].includes(cleanScalar(value).toLowerCase());
|
|
516
|
+
}
|