loadout-ai 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/LICENSE +21 -0
- package/MASTER_PLAN.md +1711 -0
- package/README.md +453 -0
- package/SECURITY.md +18 -0
- package/SIMPLE_PLAN.md +44 -0
- package/catalog/discovered.json +10469 -0
- package/catalog/packages.json +1150 -0
- package/dashboard/app.js +607 -0
- package/dashboard/index.html +249 -0
- package/dashboard/styles.css +384 -0
- package/dist/src/cli.js +2735 -0
- package/dist/src/core/active-policy.js +233 -0
- package/dist/src/core/active-set.js +382 -0
- package/dist/src/core/adapters.js +254 -0
- package/dist/src/core/adopt.js +92 -0
- package/dist/src/core/agent-health-score.js +475 -0
- package/dist/src/core/agent-inspection.js +152 -0
- package/dist/src/core/agent-versions.js +131 -0
- package/dist/src/core/api.js +90 -0
- package/dist/src/core/atomic-file.js +22 -0
- package/dist/src/core/audit.js +174 -0
- package/dist/src/core/benchmark-campaign.js +496 -0
- package/dist/src/core/benchmark-evidence.js +458 -0
- package/dist/src/core/benchmark-fixtures.js +1157 -0
- package/dist/src/core/benchmark-runner.js +301 -0
- package/dist/src/core/benchmark-trust.js +795 -0
- package/dist/src/core/canary.js +77 -0
- package/dist/src/core/candidate-intelligence.js +661 -0
- package/dist/src/core/catalog-coverage.js +85 -0
- package/dist/src/core/catalog-install.js +186 -0
- package/dist/src/core/catalog-release.js +286 -0
- package/dist/src/core/catalog.js +327 -0
- package/dist/src/core/codex-mcp.js +122 -0
- package/dist/src/core/community.js +86 -0
- package/dist/src/core/compatibility-intelligence.js +465 -0
- package/dist/src/core/completion.js +160 -0
- package/dist/src/core/components.js +138 -0
- package/dist/src/core/conversion.js +56 -0
- package/dist/src/core/credentials.js +377 -0
- package/dist/src/core/demo.js +136 -0
- package/dist/src/core/diff.js +83 -0
- package/dist/src/core/discovery-connector.js +165 -0
- package/dist/src/core/doctor.js +95 -0
- package/dist/src/core/ecosystem-import.js +1072 -0
- package/dist/src/core/evaluate.js +72 -0
- package/dist/src/core/file-lock.js +140 -0
- package/dist/src/core/freshness-alerts.js +230 -0
- package/dist/src/core/github-discovery.js +97 -0
- package/dist/src/core/github.js +148 -0
- package/dist/src/core/head-to-head.js +437 -0
- package/dist/src/core/health-score-evidence.js +169 -0
- package/dist/src/core/health.js +129 -0
- package/dist/src/core/improve.js +223 -0
- package/dist/src/core/install.js +233 -0
- package/dist/src/core/intelligence-feed-build.js +59 -0
- package/dist/src/core/intelligence-feed.js +353 -0
- package/dist/src/core/loadout-badge.js +55 -0
- package/dist/src/core/loadout-card.js +87 -0
- package/dist/src/core/manifest.js +307 -0
- package/dist/src/core/mcp-recipes.js +419 -0
- package/dist/src/core/mcp-registry-discovery.js +455 -0
- package/dist/src/core/mcp.js +287 -0
- package/dist/src/core/model-config.js +102 -0
- package/dist/src/core/observations.js +87 -0
- package/dist/src/core/outcomes.js +164 -0
- package/dist/src/core/package.js +225 -0
- package/dist/src/core/paths.js +214 -0
- package/dist/src/core/portable.js +130 -0
- package/dist/src/core/private-discovery.js +45 -0
- package/dist/src/core/profiles.js +255 -0
- package/dist/src/core/provenance.js +278 -0
- package/dist/src/core/ranking.js +104 -0
- package/dist/src/core/recommend.js +181 -0
- package/dist/src/core/registry-api.js +97 -0
- package/dist/src/core/registry.js +316 -0
- package/dist/src/core/release-claims.js +175 -0
- package/dist/src/core/remove.js +122 -0
- package/dist/src/core/review-queue.js +200 -0
- package/dist/src/core/runtime-tool-recipe.js +315 -0
- package/dist/src/core/runtime-tools.js +453 -0
- package/dist/src/core/safety.js +234 -0
- package/dist/src/core/sandbox.js +78 -0
- package/dist/src/core/scheduler.js +264 -0
- package/dist/src/core/share-report.js +85 -0
- package/dist/src/core/signing.js +115 -0
- package/dist/src/core/skill-compare.js +239 -0
- package/dist/src/core/skill-inventory.js +193 -0
- package/dist/src/core/skill-security.js +340 -0
- package/dist/src/core/skills-sh-discovery.js +336 -0
- package/dist/src/core/skills.js +208 -0
- package/dist/src/core/snapshot.js +210 -0
- package/dist/src/core/source.js +305 -0
- package/dist/src/core/state.js +300 -0
- package/dist/src/core/sync.js +348 -0
- package/dist/src/core/terminal.js +53 -0
- package/dist/src/core/transaction.js +211 -0
- package/dist/src/core/update-watch.js +39 -0
- package/dist/src/core/update.js +216 -0
- package/dist/src/core/upgrade.js +207 -0
- package/dist/src/dashboard.js +418 -0
- package/dist/src/shared/schemas.js +336 -0
- package/dist/src/shared/types.js +1 -0
- package/docs/ACTIVE_SET.md +53 -0
- package/docs/ACTIVE_SET_POLICY.md +45 -0
- package/docs/CANDIDATE_INTELLIGENCE.md +139 -0
- package/docs/CATALOG.md +78 -0
- package/docs/CATALOG_POLICY.md +41 -0
- package/docs/COMMUNITY_DISCOVERY.md +14 -0
- package/docs/COMPATIBILITY_POLICY.md +22 -0
- package/docs/CONVERSION_AND_SANDBOX.md +27 -0
- package/docs/CREDENTIAL_AND_UPDATE_POLICY.md +31 -0
- package/docs/DISCOVERED.md +280 -0
- package/docs/EVALUATION_PROTOCOL.md +40 -0
- package/docs/EVALUATION_PROTOCOL_V1.md +300 -0
- package/docs/FEATURE_TEST_MATRIX.md +802 -0
- package/docs/GITHUB_AUTHORIZATION.md +38 -0
- package/docs/HEAD_TO_HEAD_EVALUATION.md +79 -0
- package/docs/PROVENANCE_AND_COMPARISON.md +72 -0
- package/docs/PROVIDER_CONFIGURATION.md +45 -0
- package/docs/RELEASE_REVIEW.md +117 -0
- package/docs/SAFE_UPDATE_DEMO.md +25 -0
- package/docs/SCHEMA_DECISIONS.md +25 -0
- package/docs/TEAM_POLICY.md +18 -0
- package/docs/TESTING.md +190 -0
- package/package.json +82 -0
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { lstat, readFile, readdir } from "node:fs/promises";
|
|
3
|
+
import { basename, extname, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
const DEFAULT_MAX_FILES = 500;
|
|
5
|
+
const DEFAULT_MAX_TOTAL_BYTES = 10_000_000;
|
|
6
|
+
const DEFAULT_MAX_FILE_BYTES = 2_000_000;
|
|
7
|
+
const SCRIPT_EXTENSIONS = new Set([
|
|
8
|
+
".bat",
|
|
9
|
+
".cmd",
|
|
10
|
+
".fish",
|
|
11
|
+
".js",
|
|
12
|
+
".mjs",
|
|
13
|
+
".cjs",
|
|
14
|
+
".pl",
|
|
15
|
+
".ps1",
|
|
16
|
+
".py",
|
|
17
|
+
".rb",
|
|
18
|
+
".sh",
|
|
19
|
+
".ts",
|
|
20
|
+
".zsh",
|
|
21
|
+
]);
|
|
22
|
+
const DEPENDENCY_MANIFESTS = new Set([
|
|
23
|
+
"cargo.toml",
|
|
24
|
+
"composer.json",
|
|
25
|
+
"gemfile",
|
|
26
|
+
"go.mod",
|
|
27
|
+
"package.json",
|
|
28
|
+
"pipfile",
|
|
29
|
+
"pyproject.toml",
|
|
30
|
+
"requirements.txt",
|
|
31
|
+
]);
|
|
32
|
+
const UNICODE_CONTROL_PATTERN = /[\u061c\u200b-\u200f\u202a-\u202e\u2060-\u2069\ufeff]/u;
|
|
33
|
+
const DOMAIN_PATTERN = /\bhttps?:\/\/([^\s/:'"`<>)}\]]+)/giu;
|
|
34
|
+
const ENV_PATTERNS = [
|
|
35
|
+
/process\.env\.([A-Z_][A-Z0-9_]*)/g,
|
|
36
|
+
/process\.env\[['"]([A-Z_][A-Z0-9_]*)['"]\]/g,
|
|
37
|
+
/\$\{([A-Z_][A-Z0-9_]*)\}|\$([A-Z_][A-Z0-9_]*)/g,
|
|
38
|
+
/\b(?:environment|env(?:ironment)? variable)\s+([A-Z_][A-Z0-9_]*)/gi,
|
|
39
|
+
];
|
|
40
|
+
const REMOTE_INSTRUCTION_PATTERN = /(?:curl|wget|invoke-webrequest)\s+[^\n]*(?:\||>|-o\b)|(?:read|fetch|load|follow)\s+(?:the\s+)?instructions?\s+(?:at|from)\s+https?:\/\//i;
|
|
41
|
+
const INJECTION_PATTERN = /\b(?:ignore|disregard|override)\b.{0,60}\b(?:previous|prior|system|developer|security|safety)\b.{0,30}\binstructions?\b/gi;
|
|
42
|
+
const EXFILTRATION_PATTERN = /\b(?:send|post|upload|exfiltrate|transmit)\b.{0,100}\b(?:credentials?|secrets?|tokens?|api[_ -]?keys?|private keys?|\.ssh|\.aws|environment variables?)\b/gi;
|
|
43
|
+
function digest(content) {
|
|
44
|
+
return createHash("sha256").update(content).digest("hex");
|
|
45
|
+
}
|
|
46
|
+
function normalizedPath(root, path) {
|
|
47
|
+
return relative(root, path).split(sep).join("/") || ".";
|
|
48
|
+
}
|
|
49
|
+
function finding(id, severity, category, message, paths, names) {
|
|
50
|
+
return {
|
|
51
|
+
id,
|
|
52
|
+
scanner: "deterministic",
|
|
53
|
+
severity,
|
|
54
|
+
category,
|
|
55
|
+
message,
|
|
56
|
+
paths: [...new Set(paths)].sort(),
|
|
57
|
+
...(names?.length ? { names: [...new Set(names)].sort() } : {}),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
function parseFrontmatter(content) {
|
|
61
|
+
const normalized = content.replace(/\r\n/g, "\n");
|
|
62
|
+
if (!normalized.startsWith("---\n"))
|
|
63
|
+
return { values: new Map(), body: normalized, valid: false };
|
|
64
|
+
const close = normalized.indexOf("\n---\n", 4);
|
|
65
|
+
if (close < 0)
|
|
66
|
+
return { values: new Map(), body: "", valid: false };
|
|
67
|
+
const values = new Map();
|
|
68
|
+
for (const line of normalized.slice(4, close).split("\n")) {
|
|
69
|
+
const match = line.match(/^([a-zA-Z][a-zA-Z0-9_-]*):\s*(.*?)\s*$/);
|
|
70
|
+
if (match)
|
|
71
|
+
values.set(match[1].toLowerCase(), match[2].replace(/^['"]|['"]$/g, ""));
|
|
72
|
+
}
|
|
73
|
+
return { values, body: normalized.slice(close + 5), valid: true };
|
|
74
|
+
}
|
|
75
|
+
function dependencyNames(path, content) {
|
|
76
|
+
const name = basename(path).toLowerCase();
|
|
77
|
+
if (name === "package.json" || name === "composer.json") {
|
|
78
|
+
try {
|
|
79
|
+
const parsed = JSON.parse(content);
|
|
80
|
+
const sections = [
|
|
81
|
+
"dependencies",
|
|
82
|
+
"devDependencies",
|
|
83
|
+
"optionalDependencies",
|
|
84
|
+
"require",
|
|
85
|
+
];
|
|
86
|
+
return sections.flatMap((section) => {
|
|
87
|
+
const value = parsed[section];
|
|
88
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
89
|
+
? Object.keys(value)
|
|
90
|
+
: [];
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (name === "requirements.txt")
|
|
98
|
+
return content
|
|
99
|
+
.split(/\r?\n/)
|
|
100
|
+
.map((line) => line.trim().match(/^([A-Za-z0-9_.-]+)/)?.[1])
|
|
101
|
+
.filter((value) => Boolean(value));
|
|
102
|
+
return [];
|
|
103
|
+
}
|
|
104
|
+
function disagreementKey(item) {
|
|
105
|
+
return `${item.category}:${[...item.paths].sort().join(",")}`;
|
|
106
|
+
}
|
|
107
|
+
function compareFindings(deterministic, assisted) {
|
|
108
|
+
const left = new Map(deterministic.map((item) => [disagreementKey(item), item]));
|
|
109
|
+
const right = new Map(assisted.map((item) => [disagreementKey(item), item]));
|
|
110
|
+
const result = [];
|
|
111
|
+
for (const [key, item] of left) {
|
|
112
|
+
const other = right.get(key);
|
|
113
|
+
if (!other)
|
|
114
|
+
result.push({
|
|
115
|
+
key,
|
|
116
|
+
kind: "deterministic-only",
|
|
117
|
+
deterministicSeverity: item.severity,
|
|
118
|
+
});
|
|
119
|
+
else if (item.severity !== other.severity)
|
|
120
|
+
result.push({
|
|
121
|
+
key,
|
|
122
|
+
kind: "severity-mismatch",
|
|
123
|
+
deterministicSeverity: item.severity,
|
|
124
|
+
assistedSeverity: other.severity,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
for (const [key, item] of right)
|
|
128
|
+
if (!left.has(key))
|
|
129
|
+
result.push({
|
|
130
|
+
key,
|
|
131
|
+
kind: "assisted-only",
|
|
132
|
+
assistedSeverity: item.severity,
|
|
133
|
+
});
|
|
134
|
+
return result.sort((a, b) => a.key.localeCompare(b.key));
|
|
135
|
+
}
|
|
136
|
+
function hasUnnegatedMatch(content, pattern) {
|
|
137
|
+
pattern.lastIndex = 0;
|
|
138
|
+
for (const match of content.matchAll(pattern)) {
|
|
139
|
+
const before = content.slice(Math.max(0, (match.index ?? 0) - 32), match.index);
|
|
140
|
+
if (!/(?:\bnever|\bdo not|\bdon't|\bmust not|\bshould not)\s*$/i.test(before))
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Inspect an Agent Skill without executing it. Output contains hashes, paths and
|
|
147
|
+
* capability names only; instruction text and credential values never leave the scanner.
|
|
148
|
+
*/
|
|
149
|
+
export async function scanSkillSecurity(skillRoot, options = {}) {
|
|
150
|
+
const root = resolve(skillRoot);
|
|
151
|
+
const rootStat = await lstat(root);
|
|
152
|
+
if (!rootStat.isDirectory() || rootStat.isSymbolicLink())
|
|
153
|
+
throw new Error(`Skill path must be a real directory: ${skillRoot}`);
|
|
154
|
+
const maxFiles = options.maxFiles ?? DEFAULT_MAX_FILES;
|
|
155
|
+
const maxTotalBytes = options.maxTotalBytes ?? DEFAULT_MAX_TOTAL_BYTES;
|
|
156
|
+
const maxFileBytes = options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES;
|
|
157
|
+
const inventory = [];
|
|
158
|
+
const contents = new Map();
|
|
159
|
+
const findings = [];
|
|
160
|
+
let totalBytes = 0;
|
|
161
|
+
async function visit(directory, depth) {
|
|
162
|
+
if (depth > 12) {
|
|
163
|
+
findings.push(finding("tree-depth", "high", "size", "Skill tree exceeds the bounded inspection depth.", [normalizedPath(root, directory)]));
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
167
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
168
|
+
const absolute = join(directory, entry.name);
|
|
169
|
+
const path = normalizedPath(root, absolute);
|
|
170
|
+
const info = await lstat(absolute);
|
|
171
|
+
if (info.isSymbolicLink()) {
|
|
172
|
+
findings.push(finding("symlink", "critical", "symlink", "Symlinks are not permitted inside a reviewed skill.", [path]));
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (info.isDirectory()) {
|
|
176
|
+
await visit(absolute, depth + 1);
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (!info.isFile())
|
|
180
|
+
continue;
|
|
181
|
+
if (inventory.length >= maxFiles)
|
|
182
|
+
throw new Error(`Skill exceeds inspection file limit (${maxFiles})`);
|
|
183
|
+
if (info.size > maxFileBytes)
|
|
184
|
+
throw new Error(`Skill file exceeds inspection byte limit: ${path}`);
|
|
185
|
+
totalBytes += info.size;
|
|
186
|
+
if (totalBytes > maxTotalBytes)
|
|
187
|
+
throw new Error(`Skill exceeds inspection byte limit (${maxTotalBytes})`);
|
|
188
|
+
const content = await readFile(absolute);
|
|
189
|
+
const text = content.toString("utf8");
|
|
190
|
+
const lowerName = entry.name.toLowerCase();
|
|
191
|
+
const manifest = DEPENDENCY_MANIFESTS.has(lowerName);
|
|
192
|
+
const script = SCRIPT_EXTENSIONS.has(extname(entry.name).toLowerCase()) ||
|
|
193
|
+
path.startsWith("scripts/") ||
|
|
194
|
+
text.startsWith("#!");
|
|
195
|
+
const executable = (info.mode & 0o111) !== 0 || text.startsWith("#!");
|
|
196
|
+
inventory.push({
|
|
197
|
+
path,
|
|
198
|
+
bytes: info.size,
|
|
199
|
+
sha256: digest(content),
|
|
200
|
+
kind: path === "SKILL.md"
|
|
201
|
+
? "instruction"
|
|
202
|
+
: manifest
|
|
203
|
+
? "dependency-manifest"
|
|
204
|
+
: script
|
|
205
|
+
? "script"
|
|
206
|
+
: "asset",
|
|
207
|
+
executable,
|
|
208
|
+
});
|
|
209
|
+
contents.set(path, text);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
await visit(root, 0);
|
|
213
|
+
const skillText = contents.get("SKILL.md");
|
|
214
|
+
if (skillText === undefined)
|
|
215
|
+
findings.push(finding("skill-file", "critical", "specification", "SKILL.md is required.", ["SKILL.md"]));
|
|
216
|
+
const parsed = parseFrontmatter(skillText ?? "");
|
|
217
|
+
const name = parsed.values.get("name");
|
|
218
|
+
const description = parsed.values.get("description") ?? "";
|
|
219
|
+
const compatibility = parsed.values.get("compatibility") ?? "";
|
|
220
|
+
if (!parsed.valid)
|
|
221
|
+
findings.push(finding("frontmatter", "critical", "specification", "SKILL.md must contain closed YAML frontmatter.", ["SKILL.md"]));
|
|
222
|
+
if (!name || name.length > 64 || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name))
|
|
223
|
+
findings.push(finding("name", "critical", "specification", "Skill name must be 1-64 lowercase letters, numbers, or single hyphens.", ["SKILL.md"]));
|
|
224
|
+
if (name && name !== basename(root))
|
|
225
|
+
findings.push(finding("directory-name", "medium", "specification", "Skill name does not match its parent directory.", ["SKILL.md"]));
|
|
226
|
+
if (!description || description.length > 1024)
|
|
227
|
+
findings.push(finding("description", "critical", "specification", "Skill description must contain 1-1024 characters.", ["SKILL.md"]));
|
|
228
|
+
if (compatibility.length > 500)
|
|
229
|
+
findings.push(finding("compatibility", "high", "specification", "Compatibility metadata exceeds 500 characters.", ["SKILL.md"]));
|
|
230
|
+
const lineCount = (skillText ?? "").split(/\r?\n/).length;
|
|
231
|
+
if (lineCount > 500)
|
|
232
|
+
findings.push(finding("progressive-disclosure", "medium", "size", "SKILL.md exceeds the recommended 500-line progressive-disclosure boundary.", ["SKILL.md"]));
|
|
233
|
+
const domains = new Set();
|
|
234
|
+
const envNames = new Set();
|
|
235
|
+
const executableFiles = [];
|
|
236
|
+
const dependencyManifests = [];
|
|
237
|
+
const dependencies = new Set();
|
|
238
|
+
for (const item of inventory) {
|
|
239
|
+
const text = contents.get(item.path) ?? "";
|
|
240
|
+
DOMAIN_PATTERN.lastIndex = 0;
|
|
241
|
+
for (const match of text.matchAll(DOMAIN_PATTERN))
|
|
242
|
+
if (match[1])
|
|
243
|
+
domains.add(match[1].toLowerCase());
|
|
244
|
+
for (const pattern of ENV_PATTERNS) {
|
|
245
|
+
pattern.lastIndex = 0;
|
|
246
|
+
for (const match of text.matchAll(pattern)) {
|
|
247
|
+
const envName = (match[1] ?? match[2] ?? "").toUpperCase();
|
|
248
|
+
if (envName)
|
|
249
|
+
envNames.add(envName);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (item.kind === "script" || item.executable)
|
|
253
|
+
executableFiles.push(item.path);
|
|
254
|
+
if (item.kind === "dependency-manifest") {
|
|
255
|
+
dependencyManifests.push(item.path);
|
|
256
|
+
for (const dependency of dependencyNames(item.path, text))
|
|
257
|
+
dependencies.add(dependency);
|
|
258
|
+
}
|
|
259
|
+
if (UNICODE_CONTROL_PATTERN.test(text))
|
|
260
|
+
findings.push(finding(`unicode:${item.path}`, "critical", "unicode-control", "File contains invisible or bidirectional Unicode controls.", [item.path]));
|
|
261
|
+
if (REMOTE_INSTRUCTION_PATTERN.test(text))
|
|
262
|
+
findings.push(finding(`remote:${item.path}`, "high", "remote-instruction", "Instructions load or execute mutable remote content.", [item.path]));
|
|
263
|
+
if (hasUnnegatedMatch(text, INJECTION_PATTERN))
|
|
264
|
+
findings.push(finding(`injection:${item.path}`, "critical", "prompt-injection", "Instructions attempt to override higher-priority or safety instructions.", [item.path]));
|
|
265
|
+
if (hasUnnegatedMatch(text, EXFILTRATION_PATTERN))
|
|
266
|
+
findings.push(finding(`exfiltration:${item.path}`, "critical", "exfiltration", "Instructions appear to transmit credential or secret material.", [item.path]));
|
|
267
|
+
}
|
|
268
|
+
if (executableFiles.length)
|
|
269
|
+
findings.push(finding("executables", "high", "executable", "Skill contains executable or script content that requires explicit review.", executableFiles));
|
|
270
|
+
if (dependencyManifests.length)
|
|
271
|
+
findings.push(finding("dependencies", "high", "dependency", "Skill declares external dependencies that require provenance review.", dependencyManifests, [...dependencies]));
|
|
272
|
+
if (domains.size)
|
|
273
|
+
findings.push(finding("domains", "high", "domain", "Skill references network domains that require explicit approval.", inventory
|
|
274
|
+
.filter((item) => [...domains].some((domain) => (contents.get(item.path) ?? "").toLowerCase().includes(domain)))
|
|
275
|
+
.map((item) => item.path), [...domains]));
|
|
276
|
+
if (envNames.size)
|
|
277
|
+
findings.push(finding("environment", "high", "environment", "Skill references environment-variable names; values were not read.", inventory
|
|
278
|
+
.filter((item) => [...envNames].some((envName) => (contents.get(item.path) ?? "").includes(envName)))
|
|
279
|
+
.map((item) => item.path), [...envNames]));
|
|
280
|
+
const declaredTools = (parsed.values.get("allowed-tools") ?? "")
|
|
281
|
+
.split(/\s+/)
|
|
282
|
+
.filter(Boolean);
|
|
283
|
+
if ((executableFiles.length || domains.size || envNames.size) &&
|
|
284
|
+
declaredTools.length === 0 &&
|
|
285
|
+
!compatibility)
|
|
286
|
+
findings.push(finding("capability-declaration", "medium", "capability", "Executable, network, or environment capabilities are not declared in frontmatter.", ["SKILL.md"]));
|
|
287
|
+
const deterministicFindings = findings.sort((a, b) => a.id.localeCompare(b.id));
|
|
288
|
+
const assistedFindings = (options.assistedFindings ?? []).map((item) => ({
|
|
289
|
+
...item,
|
|
290
|
+
scanner: "assisted",
|
|
291
|
+
}));
|
|
292
|
+
const filesSorted = inventory.sort((a, b) => a.path.localeCompare(b.path));
|
|
293
|
+
const treeHash = digest(filesSorted
|
|
294
|
+
.map((item) => `${item.path}\0${item.bytes}\0${item.sha256}`)
|
|
295
|
+
.join("\n"));
|
|
296
|
+
const verdict = deterministicFindings.some((item) => item.severity === "critical")
|
|
297
|
+
? "blocked"
|
|
298
|
+
: deterministicFindings.some((item) => item.severity === "high" || item.severity === "medium") || assistedFindings.length
|
|
299
|
+
? "review-required"
|
|
300
|
+
: "pass";
|
|
301
|
+
return {
|
|
302
|
+
schemaVersion: 1,
|
|
303
|
+
policy: "agent-skills-security-v1",
|
|
304
|
+
rootName: basename(root),
|
|
305
|
+
specification: {
|
|
306
|
+
...(name ? { name } : {}),
|
|
307
|
+
descriptionPresent: Boolean(description),
|
|
308
|
+
lineCount,
|
|
309
|
+
progressiveDisclosureRecommended: lineCount <= 500,
|
|
310
|
+
},
|
|
311
|
+
inventory: {
|
|
312
|
+
files: filesSorted,
|
|
313
|
+
totalFiles: filesSorted.length,
|
|
314
|
+
totalBytes,
|
|
315
|
+
treeHash,
|
|
316
|
+
},
|
|
317
|
+
capabilities: {
|
|
318
|
+
declaredTools,
|
|
319
|
+
executableFiles: executableFiles.sort(),
|
|
320
|
+
dependencyManifests: dependencyManifests.sort(),
|
|
321
|
+
dependencyNames: [...dependencies].sort(),
|
|
322
|
+
domains: [...domains].sort(),
|
|
323
|
+
environmentNames: [...envNames].sort(),
|
|
324
|
+
},
|
|
325
|
+
deterministicFindings,
|
|
326
|
+
assistedFindings,
|
|
327
|
+
disagreements: compareFindings(deterministicFindings, assistedFindings),
|
|
328
|
+
verdict,
|
|
329
|
+
limitations: [
|
|
330
|
+
"Static inspection cannot prove runtime safety or task quality.",
|
|
331
|
+
"Assisted findings are annotations and cannot clear a deterministic critical finding.",
|
|
332
|
+
"Dependency names are inventory evidence, not license or vulnerability approval.",
|
|
333
|
+
],
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
export function assertSkillSecurity(report) {
|
|
337
|
+
const critical = report.deterministicFindings.filter((item) => item.severity === "critical");
|
|
338
|
+
if (critical.length)
|
|
339
|
+
throw new Error(`Skill security validation failed: ${critical.map((item) => item.id).join(", ")}`);
|
|
340
|
+
}
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { ConnectorRequestError, fetchBoundedJson, rateLimitFromHeaders, readDiscoveryConnectorCache, writeDiscoveryConnectorCache, } from "./discovery-connector.js";
|
|
3
|
+
import { loadoutHome } from "./paths.js";
|
|
4
|
+
const SOURCE = "skills-sh";
|
|
5
|
+
const API = "https://skills.sh/api/v1/skills";
|
|
6
|
+
const ATTRIBUTION = "https://skills.sh/docs/api";
|
|
7
|
+
const REPOSITORY_LIMITATION = "skills.sh leaderboard metadata identifies a mutable GitHub repository but supplies no Git commit; Loadout must resolve and inspect an immutable commit before review or installation.";
|
|
8
|
+
function isRecord(value) {
|
|
9
|
+
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
10
|
+
}
|
|
11
|
+
function safeInteger(value, options = {}) {
|
|
12
|
+
return (typeof value === "number" &&
|
|
13
|
+
Number.isSafeInteger(value) &&
|
|
14
|
+
(options.signed || value >= 0));
|
|
15
|
+
}
|
|
16
|
+
function boundedString(value, maximum, options = {}) {
|
|
17
|
+
return (typeof value === "string" &&
|
|
18
|
+
value.length <= maximum &&
|
|
19
|
+
(options.empty || value.length > 0) &&
|
|
20
|
+
!/[\0\r\n]/.test(value));
|
|
21
|
+
}
|
|
22
|
+
function githubRepository(value) {
|
|
23
|
+
const normalized = value.replace(/\.git$/i, "").replace(/\/$/, "");
|
|
24
|
+
return /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(normalized)
|
|
25
|
+
? normalized
|
|
26
|
+
: undefined;
|
|
27
|
+
}
|
|
28
|
+
function validHttpsUrl(value, origin) {
|
|
29
|
+
try {
|
|
30
|
+
const url = new URL(value);
|
|
31
|
+
return (url.protocol === "https:" &&
|
|
32
|
+
!url.username &&
|
|
33
|
+
!url.password &&
|
|
34
|
+
(!origin || url.origin === origin));
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function rankingMeaning(view) {
|
|
41
|
+
if (view === "all-time")
|
|
42
|
+
return "skills.sh all-time leaderboard position and total deduplicated installs";
|
|
43
|
+
if (view === "trending")
|
|
44
|
+
return "skills.sh recent-growth leaderboard position; installs remains a total install count, not a growth rate";
|
|
45
|
+
return "skills.sh hot leaderboard position; change compares the current hour with the same hour yesterday";
|
|
46
|
+
}
|
|
47
|
+
function normalizeSkill(value, view, position, observedAt) {
|
|
48
|
+
if (!isRecord(value))
|
|
49
|
+
return undefined;
|
|
50
|
+
const { id, slug, name, source, installs, sourceType, installUrl, url, isDuplicate, installsYesterday, change, } = value;
|
|
51
|
+
if (!boundedString(id, 512) ||
|
|
52
|
+
!boundedString(slug, 200) ||
|
|
53
|
+
!boundedString(name, 300) ||
|
|
54
|
+
!boundedString(source, 300) ||
|
|
55
|
+
id !== `${source}/${slug}` ||
|
|
56
|
+
!safeInteger(installs) ||
|
|
57
|
+
(sourceType !== "github" && sourceType !== "well-known") ||
|
|
58
|
+
(installUrl !== null && !boundedString(installUrl, 2_048)) ||
|
|
59
|
+
!boundedString(url, 2_048) ||
|
|
60
|
+
!validHttpsUrl(url, "https://skills.sh") ||
|
|
61
|
+
(isDuplicate !== undefined && typeof isDuplicate !== "boolean") ||
|
|
62
|
+
(installsYesterday !== undefined && !safeInteger(installsYesterday)) ||
|
|
63
|
+
(change !== undefined && !safeInteger(change, { signed: true })))
|
|
64
|
+
return undefined;
|
|
65
|
+
let repository;
|
|
66
|
+
let repositoryKey;
|
|
67
|
+
if (sourceType === "github") {
|
|
68
|
+
const normalized = githubRepository(source);
|
|
69
|
+
if (!normalized ||
|
|
70
|
+
typeof installUrl !== "string" ||
|
|
71
|
+
!validHttpsUrl(installUrl, "https://github.com"))
|
|
72
|
+
return undefined;
|
|
73
|
+
const installPath = new URL(installUrl).pathname
|
|
74
|
+
.replace(/^\//, "")
|
|
75
|
+
.replace(/\.git$/i, "")
|
|
76
|
+
.replace(/\/$/, "");
|
|
77
|
+
if (installPath.toLowerCase() !== normalized.toLowerCase())
|
|
78
|
+
return undefined;
|
|
79
|
+
repository = {
|
|
80
|
+
repository: normalized,
|
|
81
|
+
url: `https://github.com/${normalized}`,
|
|
82
|
+
immutable: false,
|
|
83
|
+
limitation: REPOSITORY_LIMITATION,
|
|
84
|
+
};
|
|
85
|
+
repositoryKey = `github:${normalized.toLowerCase()}`;
|
|
86
|
+
}
|
|
87
|
+
else if (installUrl !== null && !validHttpsUrl(installUrl))
|
|
88
|
+
return undefined;
|
|
89
|
+
return {
|
|
90
|
+
source: SOURCE,
|
|
91
|
+
kind: "skill",
|
|
92
|
+
identityKey: `skills-sh:${id.toLowerCase()}`,
|
|
93
|
+
...(repositoryKey ? { repositoryKey } : {}),
|
|
94
|
+
externalId: id,
|
|
95
|
+
slug,
|
|
96
|
+
name,
|
|
97
|
+
sourceName: source,
|
|
98
|
+
sourceType,
|
|
99
|
+
installUrl,
|
|
100
|
+
sourceUrl: url,
|
|
101
|
+
installs,
|
|
102
|
+
isDuplicate: isDuplicate ?? false,
|
|
103
|
+
...(repository ? { repository } : {}),
|
|
104
|
+
ranking: {
|
|
105
|
+
provider: "skills.sh",
|
|
106
|
+
view,
|
|
107
|
+
position,
|
|
108
|
+
installs,
|
|
109
|
+
...(installsYesterday !== undefined ? { installsYesterday } : {}),
|
|
110
|
+
...(change !== undefined ? { change } : {}),
|
|
111
|
+
meaning: rankingMeaning(view),
|
|
112
|
+
uncertainty: "Anonymous install telemetry is an adoption signal only; it is not safety, quality, compatibility, or performance evidence.",
|
|
113
|
+
},
|
|
114
|
+
attribution: {
|
|
115
|
+
source: SOURCE,
|
|
116
|
+
sourceUrl: ATTRIBUTION,
|
|
117
|
+
observedAt,
|
|
118
|
+
meaning: "Metadata and leaderboard order reported by skills.sh; Loadout did not install or execute the skill.",
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
function parsePage(value, expectedPage, pageSize) {
|
|
123
|
+
if (!isRecord(value) ||
|
|
124
|
+
!Array.isArray(value.data) ||
|
|
125
|
+
!isRecord(value.pagination))
|
|
126
|
+
throw new Error("skills.sh response envelope is invalid");
|
|
127
|
+
const page = value.pagination.page;
|
|
128
|
+
const perPage = value.pagination.perPage;
|
|
129
|
+
const total = value.pagination.total;
|
|
130
|
+
const hasMore = value.pagination.hasMore;
|
|
131
|
+
if (page !== expectedPage ||
|
|
132
|
+
!safeInteger(perPage) ||
|
|
133
|
+
perPage < 1 ||
|
|
134
|
+
perPage > 500 ||
|
|
135
|
+
!safeInteger(total) ||
|
|
136
|
+
typeof hasMore !== "boolean" ||
|
|
137
|
+
value.data.length > pageSize)
|
|
138
|
+
throw new Error("skills.sh pagination metadata is invalid");
|
|
139
|
+
return { data: value.data, hasMore, total };
|
|
140
|
+
}
|
|
141
|
+
export function isSkillsShDiscoveryRecord(value) {
|
|
142
|
+
if (!isRecord(value))
|
|
143
|
+
return false;
|
|
144
|
+
const record = value;
|
|
145
|
+
return (record.source === SOURCE &&
|
|
146
|
+
record.kind === "skill" &&
|
|
147
|
+
boundedString(record.identityKey, 1_024) &&
|
|
148
|
+
boundedString(record.externalId, 512) &&
|
|
149
|
+
boundedString(record.slug, 200) &&
|
|
150
|
+
boundedString(record.name, 300) &&
|
|
151
|
+
boundedString(record.sourceName, 300) &&
|
|
152
|
+
(record.sourceType === "github" || record.sourceType === "well-known") &&
|
|
153
|
+
safeInteger(record.installs) &&
|
|
154
|
+
typeof record.isDuplicate === "boolean" &&
|
|
155
|
+
boundedString(record.sourceUrl, 2_048) &&
|
|
156
|
+
isRecord(record.ranking) &&
|
|
157
|
+
isRecord(record.attribution) &&
|
|
158
|
+
record.attribution.source === SOURCE &&
|
|
159
|
+
boundedString(record.attribution.observedAt, 64) &&
|
|
160
|
+
Number.isFinite(Date.parse(record.attribution.observedAt)));
|
|
161
|
+
}
|
|
162
|
+
function issueFromError(error, page) {
|
|
163
|
+
if (error instanceof ConnectorRequestError) {
|
|
164
|
+
return {
|
|
165
|
+
code: error.status === 401
|
|
166
|
+
? "authentication-required"
|
|
167
|
+
: error.status === 429
|
|
168
|
+
? "rate-limited"
|
|
169
|
+
: "request-failed",
|
|
170
|
+
message: `skills.sh page ${page} failed: ${error.message}`,
|
|
171
|
+
page,
|
|
172
|
+
...(error.retryAfterSeconds !== undefined
|
|
173
|
+
? { retryAfterSeconds: error.retryAfterSeconds }
|
|
174
|
+
: {}),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
return {
|
|
178
|
+
code: "invalid-response",
|
|
179
|
+
message: `skills.sh page ${page} was invalid: ${error instanceof Error ? error.message : String(error)}`,
|
|
180
|
+
page,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
async function cachedOrUnavailable(cachePath, fetchedAt, issues) {
|
|
184
|
+
const cachedPath = cachePath === false ? undefined : cachePath;
|
|
185
|
+
const cache = cachedPath === undefined
|
|
186
|
+
? undefined
|
|
187
|
+
: await readDiscoveryConnectorCache(cachedPath, SOURCE, isSkillsShDiscoveryRecord);
|
|
188
|
+
return cache
|
|
189
|
+
? {
|
|
190
|
+
source: SOURCE,
|
|
191
|
+
status: "cached",
|
|
192
|
+
fetchedAt,
|
|
193
|
+
records: cache.records,
|
|
194
|
+
pagesFetched: 0,
|
|
195
|
+
issues,
|
|
196
|
+
cache: { path: cachedPath, cachedAt: cache.cachedAt },
|
|
197
|
+
}
|
|
198
|
+
: {
|
|
199
|
+
source: SOURCE,
|
|
200
|
+
status: "unavailable",
|
|
201
|
+
fetchedAt,
|
|
202
|
+
records: [],
|
|
203
|
+
pagesFetched: 0,
|
|
204
|
+
issues,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Read the authenticated skills.sh leaderboard. Candidate files are never
|
|
209
|
+
* downloaded; output remains metadata-only and requires later immutable review.
|
|
210
|
+
*/
|
|
211
|
+
export async function discoverSkillsSh(options = {}) {
|
|
212
|
+
const now = options.now ?? new Date();
|
|
213
|
+
const fetchedAt = now.toISOString();
|
|
214
|
+
const view = options.view ?? "trending";
|
|
215
|
+
const pageSize = Math.max(1, Math.min(options.pageSize ?? 100, 100));
|
|
216
|
+
const maxPages = Math.max(1, Math.min(options.maxPages ?? 5, 10));
|
|
217
|
+
const maxRecords = Math.max(1, Math.min(options.maxRecords ?? 500, 1_000));
|
|
218
|
+
const cachePath = options.cachePath === false
|
|
219
|
+
? false
|
|
220
|
+
: (options.cachePath ??
|
|
221
|
+
join(loadoutHome(), "discovery-cache", `skills-sh-${view}.json`));
|
|
222
|
+
const token = (options.token ?? process.env.VERCEL_OIDC_TOKEN)?.trim();
|
|
223
|
+
if (!token || /[\0\r\n]/.test(token))
|
|
224
|
+
return cachedOrUnavailable(cachePath, fetchedAt, [
|
|
225
|
+
{
|
|
226
|
+
code: "authentication-required",
|
|
227
|
+
message: "skills.sh requires a request-scoped Vercel OIDC token; set VERCEL_OIDC_TOKEN or pass token without persisting it",
|
|
228
|
+
},
|
|
229
|
+
]);
|
|
230
|
+
const records = [];
|
|
231
|
+
const identities = new Set();
|
|
232
|
+
const issues = [];
|
|
233
|
+
let pagesFetched = 0;
|
|
234
|
+
let hasMore = true;
|
|
235
|
+
let nextPage;
|
|
236
|
+
let stoppedByFailure = false;
|
|
237
|
+
let stoppedByRecordLimit = false;
|
|
238
|
+
let truncatedWithinPage = false;
|
|
239
|
+
let rateLimit;
|
|
240
|
+
for (let page = 0; page < maxPages && hasMore && records.length < maxRecords; page++) {
|
|
241
|
+
const url = new URL(API);
|
|
242
|
+
url.searchParams.set("view", view);
|
|
243
|
+
url.searchParams.set("page", String(page));
|
|
244
|
+
url.searchParams.set("per_page", String(pageSize));
|
|
245
|
+
try {
|
|
246
|
+
const response = await fetchBoundedJson(url.toString(), {
|
|
247
|
+
fetcher: options.fetcher,
|
|
248
|
+
timeoutMs: options.timeoutMs,
|
|
249
|
+
maximumBytes: 5 * 1024 * 1024,
|
|
250
|
+
headers: {
|
|
251
|
+
accept: "application/json",
|
|
252
|
+
authorization: `Bearer ${token}`,
|
|
253
|
+
"user-agent": "loadout-ai-discovery",
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
pagesFetched++;
|
|
257
|
+
rateLimit = rateLimitFromHeaders(response.headers) ?? rateLimit;
|
|
258
|
+
const parsed = parsePage(response.value, page, pageSize);
|
|
259
|
+
hasMore = parsed.hasMore;
|
|
260
|
+
if (hasMore && parsed.data.length === 0)
|
|
261
|
+
throw new Error("skills.sh returned an empty page with hasMore=true");
|
|
262
|
+
for (const [index, value] of parsed.data.entries()) {
|
|
263
|
+
const normalized = normalizeSkill(value, view, page * pageSize + index + 1, fetchedAt);
|
|
264
|
+
if (!normalized) {
|
|
265
|
+
issues.push({
|
|
266
|
+
code: "invalid-record",
|
|
267
|
+
message: `skills.sh page ${page} record ${index + 1} was skipped because it failed the bounded metadata schema`,
|
|
268
|
+
page,
|
|
269
|
+
});
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
if (identities.has(normalized.identityKey)) {
|
|
273
|
+
issues.push({
|
|
274
|
+
code: "duplicate-record",
|
|
275
|
+
message: `skills.sh repeated ${normalized.externalId}; the earliest leaderboard position was retained`,
|
|
276
|
+
page,
|
|
277
|
+
});
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
identities.add(normalized.identityKey);
|
|
281
|
+
records.push(normalized);
|
|
282
|
+
if (records.length >= maxRecords) {
|
|
283
|
+
truncatedWithinPage = index < parsed.data.length - 1;
|
|
284
|
+
stoppedByRecordLimit = truncatedWithinPage || hasMore;
|
|
285
|
+
break;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
nextPage = hasMore && !truncatedWithinPage ? page + 1 : undefined;
|
|
289
|
+
}
|
|
290
|
+
catch (error) {
|
|
291
|
+
issues.push(issueFromError(error, page));
|
|
292
|
+
stoppedByFailure = true;
|
|
293
|
+
break;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
if (!stoppedByFailure && stoppedByRecordLimit)
|
|
297
|
+
issues.push({
|
|
298
|
+
code: "pagination-limit",
|
|
299
|
+
message: `skills.sh pagination stopped at the configured ${maxRecords}-record limit${truncatedWithinPage ? "; no continuation is exposed because the limit was reached within a page" : ""}`,
|
|
300
|
+
...(nextPage !== undefined ? { page: nextPage } : {}),
|
|
301
|
+
});
|
|
302
|
+
else if (!stoppedByFailure &&
|
|
303
|
+
hasMore &&
|
|
304
|
+
nextPage !== undefined &&
|
|
305
|
+
pagesFetched >= maxPages)
|
|
306
|
+
issues.push({
|
|
307
|
+
code: "pagination-limit",
|
|
308
|
+
message: `skills.sh pagination stopped at the configured ${maxPages}-page limit`,
|
|
309
|
+
page: nextPage,
|
|
310
|
+
});
|
|
311
|
+
if (!records.length && issues.length)
|
|
312
|
+
return cachedOrUnavailable(cachePath, fetchedAt, issues);
|
|
313
|
+
let status = issues.length ? "partial" : "complete";
|
|
314
|
+
if (status === "complete" && cachePath !== false) {
|
|
315
|
+
try {
|
|
316
|
+
await writeDiscoveryConnectorCache(cachePath, SOURCE, records, now);
|
|
317
|
+
}
|
|
318
|
+
catch (error) {
|
|
319
|
+
status = "partial";
|
|
320
|
+
issues.push({
|
|
321
|
+
code: "cache-write-failed",
|
|
322
|
+
message: `skills.sh metadata was fetched but its offline cache could not be written: ${error instanceof Error ? error.message : String(error)}`,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return {
|
|
327
|
+
source: SOURCE,
|
|
328
|
+
status,
|
|
329
|
+
fetchedAt,
|
|
330
|
+
records,
|
|
331
|
+
pagesFetched,
|
|
332
|
+
issues,
|
|
333
|
+
...(nextPage !== undefined && hasMore ? { next: String(nextPage) } : {}),
|
|
334
|
+
...(rateLimit ? { rateLimit } : {}),
|
|
335
|
+
};
|
|
336
|
+
}
|