@floomhq/floom 1.0.14 → 1.0.17
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 +36 -30
- package/dist/cli.js +127 -233
- package/dist/doctor.js +119 -38
- package/dist/errors.js +1 -1
- package/dist/info.js +1 -1
- package/dist/init.js +87 -92
- package/dist/install.js +140 -67
- package/dist/library.js +4 -8
- package/dist/list.js +7 -8
- package/dist/login.js +81 -46
- package/dist/mcp.js +4 -7
- package/dist/package.js +318 -0
- package/dist/publish.js +51 -51
- package/dist/scan.js +18 -23
- package/dist/secrets.js +3 -29
- package/dist/setup.js +12 -14
- package/dist/sync-manifest.js +65 -16
- package/dist/sync.js +216 -172
- package/package.json +3 -2
- package/dist/targets.js +0 -16
package/dist/package.js
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { lstat, open, readdir, readFile } from "node:fs/promises";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { execFile as execFileCb } from "node:child_process";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
7
|
+
import { FloomError } from "./errors.js";
|
|
8
|
+
const ALLOWED_PACKAGE_DIRS = new Set(["references", "examples", "scripts", "assets"]);
|
|
9
|
+
const ALLOWED_PACKAGE_ROOT_FILES = new Set(["SKILL.md", ".gitignore"]);
|
|
10
|
+
const PACKAGE_FILE_LIMIT = 100;
|
|
11
|
+
const PACKAGE_TOTAL_BYTES_LIMIT = 1_000_000;
|
|
12
|
+
const PACKAGE_FILE_BYTES_LIMIT = 500_000;
|
|
13
|
+
const PACKAGE_SEGMENT_RE = /^[A-Za-z0-9._-]{1,128}$/;
|
|
14
|
+
const BASE64_RE = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
|
|
15
|
+
const execFile = promisify(execFileCb);
|
|
16
|
+
export function sha256Bytes(input) {
|
|
17
|
+
return createHash("sha256").update(input).digest("hex");
|
|
18
|
+
}
|
|
19
|
+
export async function readSkillPackage(inputPath) {
|
|
20
|
+
const resolved = resolve(process.cwd(), inputPath);
|
|
21
|
+
let stat;
|
|
22
|
+
try {
|
|
23
|
+
stat = await lstat(resolved);
|
|
24
|
+
}
|
|
25
|
+
catch (err) {
|
|
26
|
+
if (err.code === "ENOENT") {
|
|
27
|
+
throw new FloomError(`File not found: ${inputPath}`);
|
|
28
|
+
}
|
|
29
|
+
throw new FloomError(`Couldn't read ${inputPath}: ${err.message}`);
|
|
30
|
+
}
|
|
31
|
+
if (stat.isSymbolicLink()) {
|
|
32
|
+
throw new FloomError(`Package path is a symbolic link: ${inputPath}`);
|
|
33
|
+
}
|
|
34
|
+
if (stat.isFile()) {
|
|
35
|
+
const body = await readSkillFile(resolved, inputPath);
|
|
36
|
+
return {
|
|
37
|
+
rootPath: dirname(resolved),
|
|
38
|
+
skillPath: resolved,
|
|
39
|
+
skillBody: body,
|
|
40
|
+
packageFiles: [],
|
|
41
|
+
originalFilename: basename(resolved),
|
|
42
|
+
isFolder: false,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
if (!stat.isDirectory()) {
|
|
46
|
+
throw new FloomError(`That's not a file or skill folder: ${inputPath}`);
|
|
47
|
+
}
|
|
48
|
+
const skillPath = join(resolved, "SKILL.md");
|
|
49
|
+
const skillBody = await readSkillFile(skillPath, join(inputPath, "SKILL.md"));
|
|
50
|
+
const packageFiles = await collectPackageFiles(resolved);
|
|
51
|
+
return {
|
|
52
|
+
rootPath: resolved,
|
|
53
|
+
skillPath,
|
|
54
|
+
skillBody,
|
|
55
|
+
packageFiles,
|
|
56
|
+
originalFilename: `${basename(resolved)}/SKILL.md`,
|
|
57
|
+
isFolder: true,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
async function readSkillFile(path, label) {
|
|
61
|
+
let body;
|
|
62
|
+
try {
|
|
63
|
+
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
64
|
+
try {
|
|
65
|
+
const stat = await handle.stat();
|
|
66
|
+
if (!stat.isFile())
|
|
67
|
+
throw new FloomError(`That's a directory, not a file: ${label}`);
|
|
68
|
+
if (stat.size > PACKAGE_FILE_BYTES_LIMIT) {
|
|
69
|
+
throw new FloomError(`File too large: ${label}`, "Each package file is capped at 500KB.");
|
|
70
|
+
}
|
|
71
|
+
body = await handle.readFile("utf8");
|
|
72
|
+
}
|
|
73
|
+
finally {
|
|
74
|
+
await handle.close();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
const code = err.code;
|
|
79
|
+
if (code === "ENOENT")
|
|
80
|
+
throw new FloomError(`File not found: ${label}`);
|
|
81
|
+
if (code === "ELOOP")
|
|
82
|
+
throw new FloomError(`Package file is a symbolic link: ${label}`);
|
|
83
|
+
if (err instanceof FloomError)
|
|
84
|
+
throw err;
|
|
85
|
+
throw new FloomError(`Couldn't read ${label}: ${err.message}`);
|
|
86
|
+
}
|
|
87
|
+
if (!body.trim()) {
|
|
88
|
+
throw new FloomError(`File is empty: ${label}`, "Add skill instructions before scanning or publishing.");
|
|
89
|
+
}
|
|
90
|
+
return body;
|
|
91
|
+
}
|
|
92
|
+
async function collectPackageFiles(root) {
|
|
93
|
+
const files = [];
|
|
94
|
+
let totalBytes = Buffer.byteLength(await readFile(join(root, "SKILL.md")));
|
|
95
|
+
const isIgnored = gitIgnoreChecker(root);
|
|
96
|
+
const rootEntries = await readdir(root, { withFileTypes: true });
|
|
97
|
+
for (const entry of rootEntries) {
|
|
98
|
+
if (ALLOWED_PACKAGE_ROOT_FILES.has(entry.name) || ALLOWED_PACKAGE_DIRS.has(entry.name))
|
|
99
|
+
continue;
|
|
100
|
+
if (await isIgnored(entry.name))
|
|
101
|
+
continue;
|
|
102
|
+
const hint = "Move supporting files under references/, examples/, scripts/, or assets/.";
|
|
103
|
+
throw new FloomError(`Unsupported root package entry: ${entry.name}`, hint);
|
|
104
|
+
}
|
|
105
|
+
for (const dirName of ALLOWED_PACKAGE_DIRS) {
|
|
106
|
+
const dirPath = join(root, dirName);
|
|
107
|
+
let stat;
|
|
108
|
+
try {
|
|
109
|
+
stat = await lstat(dirPath);
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
if (err.code === "ENOENT")
|
|
113
|
+
continue;
|
|
114
|
+
throw err;
|
|
115
|
+
}
|
|
116
|
+
if (await isIgnored(dirName))
|
|
117
|
+
continue;
|
|
118
|
+
if (stat.isSymbolicLink())
|
|
119
|
+
throw new FloomError(`Package directory is a symbolic link: ${dirName}`);
|
|
120
|
+
if (!stat.isDirectory())
|
|
121
|
+
throw new FloomError(`Package entry must be a directory: ${dirName}`);
|
|
122
|
+
await collectDir(root, dirPath, files, isIgnored, (size) => {
|
|
123
|
+
totalBytes += size;
|
|
124
|
+
if (totalBytes > PACKAGE_TOTAL_BYTES_LIMIT) {
|
|
125
|
+
throw new FloomError("Skill package is too large.", "A skill package is capped at 1MB total.");
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
return files.sort((a, b) => a.path.localeCompare(b.path));
|
|
130
|
+
}
|
|
131
|
+
async function collectDir(root, dir, files, isIgnored, addBytes) {
|
|
132
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
133
|
+
for (const entry of entries) {
|
|
134
|
+
if (!PACKAGE_SEGMENT_RE.test(entry.name)) {
|
|
135
|
+
throw new FloomError(`Invalid package path segment: ${entry.name}`);
|
|
136
|
+
}
|
|
137
|
+
const fullPath = join(dir, entry.name);
|
|
138
|
+
const rel = packageRelativePath(root, fullPath);
|
|
139
|
+
if (await isIgnored(rel))
|
|
140
|
+
continue;
|
|
141
|
+
if (entry.isSymbolicLink())
|
|
142
|
+
throw new FloomError(`Package path is a symbolic link: ${rel}`);
|
|
143
|
+
if (entry.isDirectory()) {
|
|
144
|
+
await collectDir(root, fullPath, files, isIgnored, addBytes);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (!entry.isFile())
|
|
148
|
+
throw new FloomError(`Package path is not a regular file: ${rel}`);
|
|
149
|
+
if (files.length >= PACKAGE_FILE_LIMIT) {
|
|
150
|
+
throw new FloomError("Skill package has too many files.", "A skill package is capped at 100 supporting files.");
|
|
151
|
+
}
|
|
152
|
+
const stat = await lstat(fullPath);
|
|
153
|
+
if (stat.isSymbolicLink())
|
|
154
|
+
throw new FloomError(`Package path is a symbolic link: ${rel}`);
|
|
155
|
+
if (!stat.isFile())
|
|
156
|
+
throw new FloomError(`Package path is not a regular file: ${rel}`);
|
|
157
|
+
if (stat.size > PACKAGE_FILE_BYTES_LIMIT) {
|
|
158
|
+
throw new FloomError(`File too large: ${rel}`, "Each package file is capped at 500KB.");
|
|
159
|
+
}
|
|
160
|
+
addBytes(stat.size);
|
|
161
|
+
const bytes = await readFile(fullPath);
|
|
162
|
+
files.push({
|
|
163
|
+
path: rel,
|
|
164
|
+
content_base64: bytes.toString("base64"),
|
|
165
|
+
encoding: "base64",
|
|
166
|
+
size_bytes: bytes.length,
|
|
167
|
+
sha256: sha256Bytes(bytes),
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
function gitIgnoreChecker(root) {
|
|
172
|
+
const cache = new Map();
|
|
173
|
+
let localRules = null;
|
|
174
|
+
return async (rel) => {
|
|
175
|
+
const cached = cache.get(rel);
|
|
176
|
+
if (cached !== undefined)
|
|
177
|
+
return cached;
|
|
178
|
+
let ignored = false;
|
|
179
|
+
try {
|
|
180
|
+
await execFile("git", ["-C", root, "check-ignore", "--quiet", "--", rel]);
|
|
181
|
+
ignored = true;
|
|
182
|
+
}
|
|
183
|
+
catch (err) {
|
|
184
|
+
const code = err.code;
|
|
185
|
+
if (code !== 1 && code !== 128 && code !== "ENOENT") {
|
|
186
|
+
ignored = false;
|
|
187
|
+
}
|
|
188
|
+
if (code === 128 || code === "ENOENT") {
|
|
189
|
+
localRules ??= readLocalGitignore(root);
|
|
190
|
+
ignored = matchesLocalGitignore(rel, await localRules);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
cache.set(rel, ignored);
|
|
194
|
+
return ignored;
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
async function readLocalGitignore(root) {
|
|
198
|
+
try {
|
|
199
|
+
const body = await readFile(join(root, ".gitignore"), "utf8");
|
|
200
|
+
return body
|
|
201
|
+
.split(/\r?\n/)
|
|
202
|
+
.map((line) => line.trim())
|
|
203
|
+
.filter((line) => line.length > 0 && !line.startsWith("#") && !line.startsWith("!"));
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
return [];
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
function matchesLocalGitignore(rel, rules) {
|
|
210
|
+
const normalized = rel.replace(/\\/g, "/").replace(/^\/+/, "");
|
|
211
|
+
for (const rawRule of rules) {
|
|
212
|
+
const rule = rawRule.replace(/\\/g, "/").replace(/^\/+/, "");
|
|
213
|
+
if (!rule)
|
|
214
|
+
continue;
|
|
215
|
+
if (rule.endsWith("/")) {
|
|
216
|
+
const prefix = rule.replace(/\/+$/, "");
|
|
217
|
+
if (normalized === prefix || normalized.startsWith(`${prefix}/`))
|
|
218
|
+
return true;
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
if (normalized === rule || normalized.endsWith(`/${rule}`))
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
function packageRelativePath(root, path) {
|
|
227
|
+
const rel = relative(resolve(root), resolve(path));
|
|
228
|
+
if (!rel || rel === "." || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
|
|
229
|
+
throw new FloomError("Invalid package path.");
|
|
230
|
+
}
|
|
231
|
+
const normalized = rel.split(sep).join("/");
|
|
232
|
+
validatePackageRelativePath(normalized);
|
|
233
|
+
return normalized;
|
|
234
|
+
}
|
|
235
|
+
export function validatePackageRelativePath(path) {
|
|
236
|
+
if (!path || isAbsolute(path) || path.includes("\\") || path.length > 512) {
|
|
237
|
+
throw new FloomError(`Invalid package file path: ${path}`);
|
|
238
|
+
}
|
|
239
|
+
const segments = path.split("/");
|
|
240
|
+
if (segments.length < 2 || !ALLOWED_PACKAGE_DIRS.has(segments[0] ?? "")) {
|
|
241
|
+
throw new FloomError(`Invalid package file path: ${path}`, "Package files must be under references/, examples/, scripts/, or assets/.");
|
|
242
|
+
}
|
|
243
|
+
if (segments.some((segment) => segment === "." || segment === ".." || !PACKAGE_SEGMENT_RE.test(segment))) {
|
|
244
|
+
throw new FloomError(`Invalid package file path: ${path}`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
export function normalizeRemotePackageFiles(input) {
|
|
248
|
+
if (input === undefined || input === null)
|
|
249
|
+
return [];
|
|
250
|
+
if (!Array.isArray(input))
|
|
251
|
+
throw new FloomError("Invalid skill package response.");
|
|
252
|
+
if (input.length > PACKAGE_FILE_LIMIT) {
|
|
253
|
+
throw new FloomError("Skill package response has too many files.");
|
|
254
|
+
}
|
|
255
|
+
let totalBytes = 0;
|
|
256
|
+
const seenPaths = new Set();
|
|
257
|
+
return input.map((raw) => {
|
|
258
|
+
if (!raw || typeof raw !== "object")
|
|
259
|
+
throw new FloomError("Invalid skill package response.");
|
|
260
|
+
const file = raw;
|
|
261
|
+
if (typeof file.path !== "string")
|
|
262
|
+
throw new FloomError("Invalid skill package response.");
|
|
263
|
+
validatePackageRelativePath(file.path);
|
|
264
|
+
const pathKey = file.path.toLowerCase();
|
|
265
|
+
if (seenPaths.has(pathKey))
|
|
266
|
+
throw new FloomError(`Duplicate package file: ${file.path}`);
|
|
267
|
+
seenPaths.add(pathKey);
|
|
268
|
+
let bytes;
|
|
269
|
+
if (typeof file.content_base64 === "string") {
|
|
270
|
+
if (file.encoding !== undefined && file.encoding !== "base64") {
|
|
271
|
+
throw new FloomError(`Invalid package file encoding: ${file.path}`);
|
|
272
|
+
}
|
|
273
|
+
if (file.content_base64.length % 4 !== 0 || !BASE64_RE.test(file.content_base64)) {
|
|
274
|
+
throw new FloomError(`Invalid package file base64: ${file.path}`);
|
|
275
|
+
}
|
|
276
|
+
bytes = Buffer.from(file.content_base64, "base64");
|
|
277
|
+
}
|
|
278
|
+
else if (typeof file.content === "string") {
|
|
279
|
+
bytes = Buffer.from(file.content, "utf8");
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
throw new FloomError("Invalid skill package response.");
|
|
283
|
+
}
|
|
284
|
+
if (bytes.length > PACKAGE_FILE_BYTES_LIMIT) {
|
|
285
|
+
throw new FloomError(`Package file is too large: ${file.path}`);
|
|
286
|
+
}
|
|
287
|
+
if (typeof file.size_bytes === "number" && file.size_bytes !== bytes.length) {
|
|
288
|
+
throw new FloomError(`Package file size mismatch: ${file.path}`);
|
|
289
|
+
}
|
|
290
|
+
totalBytes += bytes.length;
|
|
291
|
+
if (totalBytes > PACKAGE_TOTAL_BYTES_LIMIT) {
|
|
292
|
+
throw new FloomError("Skill package response is too large.");
|
|
293
|
+
}
|
|
294
|
+
const hash = sha256Bytes(bytes);
|
|
295
|
+
if (typeof file.content_base64 === "string" && typeof file.sha256 !== "string") {
|
|
296
|
+
throw new FloomError(`Package file missing sha256: ${file.path}`);
|
|
297
|
+
}
|
|
298
|
+
if (typeof file.sha256 === "string" && file.sha256 !== hash) {
|
|
299
|
+
throw new FloomError(`Package file hash mismatch: ${file.path}`);
|
|
300
|
+
}
|
|
301
|
+
return { path: file.path, bytes, sha256: hash };
|
|
302
|
+
}).sort((a, b) => a.path.localeCompare(b.path));
|
|
303
|
+
}
|
|
304
|
+
export function packageHash(skillBody, files) {
|
|
305
|
+
const hash = createHash("sha256");
|
|
306
|
+
hash.update("SKILL.md\0");
|
|
307
|
+
hash.update(skillBody);
|
|
308
|
+
for (const file of files) {
|
|
309
|
+
hash.update("\0");
|
|
310
|
+
hash.update(file.path);
|
|
311
|
+
hash.update("\0");
|
|
312
|
+
if ("bytes" in file)
|
|
313
|
+
hash.update(file.bytes);
|
|
314
|
+
else
|
|
315
|
+
hash.update(Buffer.from(file.content_base64, "base64"));
|
|
316
|
+
}
|
|
317
|
+
return hash.digest("hex");
|
|
318
|
+
}
|
package/dist/publish.js
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { readFile } from "node:fs/promises";
|
|
2
|
-
import { basename, resolve } from "node:path";
|
|
3
1
|
import { parse as parseYaml } from "yaml";
|
|
4
2
|
import ora from "ora";
|
|
5
3
|
import clipboard from "clipboardy";
|
|
@@ -9,6 +7,7 @@ import { c, symbols } from "./ui.js";
|
|
|
9
7
|
import { FloomError, friendlyHttp } from "./errors.js";
|
|
10
8
|
import { formatVersionLabel } from "./version.js";
|
|
11
9
|
import { detectSkillSecurityFindings, formatSecurityFindings } from "./secrets.js";
|
|
10
|
+
import { readSkillPackage } from "./package.js";
|
|
12
11
|
const ASSET_TYPES = new Set(["knowledge", "instruction", "workflow", "skill"]);
|
|
13
12
|
const INSTALL_TARGETS = new Set([
|
|
14
13
|
"claude_skill",
|
|
@@ -108,33 +107,28 @@ function parseVersion(value, source) {
|
|
|
108
107
|
throw new FloomError(`Invalid ${source}: ${value}`, "Use 1-64 characters: letters, numbers, dots, underscores, plus, or hyphen.");
|
|
109
108
|
}
|
|
110
109
|
export async function publish(opts) {
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
}
|
|
129
|
-
const securityFindings = detectSkillSecurityFindings(raw);
|
|
130
|
-
if (securityFindings.length > 0) {
|
|
131
|
-
throw new FloomError("Security scan failed. Publish stopped.", `${formatSecurityFindings(securityFindings)}\nRemove secrets, prompt-injection text, or data-exfiltration instructions before publishing.`);
|
|
132
|
-
}
|
|
133
|
-
process.stdout.write(`\n${symbols.ok} Pre-publish security scan passed (3 checks).\n`);
|
|
134
|
-
const { meta, error: fmError } = parseFrontmatter(raw);
|
|
110
|
+
const skillPackage = await readSkillPackage(opts.file);
|
|
111
|
+
const securityFailures = [
|
|
112
|
+
{ path: "SKILL.md", body: skillPackage.skillBody },
|
|
113
|
+
...skillPackage.packageFiles.map((file) => ({
|
|
114
|
+
path: file.path,
|
|
115
|
+
body: Buffer.from(file.content_base64, "base64").toString("utf8"),
|
|
116
|
+
})),
|
|
117
|
+
].flatMap((file) => {
|
|
118
|
+
const findings = detectSkillSecurityFindings(file.body);
|
|
119
|
+
return findings.length > 0 ? [`${file.path}\n${formatSecurityFindings(findings)}`] : [];
|
|
120
|
+
});
|
|
121
|
+
if (securityFailures.length > 0) {
|
|
122
|
+
throw new FloomError("Security scan failed. Publish stopped.", `${securityFailures.join("\n")}\nRemove secrets, prompt-injection text, or data-exfiltration instructions before publishing.`);
|
|
123
|
+
}
|
|
124
|
+
const fileCount = 1 + skillPackage.packageFiles.length;
|
|
125
|
+
process.stdout.write(`\n${symbols.ok} Pre-publish security scan passed (${fileCount} file${fileCount === 1 ? "" : "s"}, 3 checks).\n`);
|
|
126
|
+
const { meta, error: fmError } = parseFrontmatter(skillPackage.skillBody);
|
|
135
127
|
if (fmError) {
|
|
136
128
|
throw new FloomError(`Couldn't parse frontmatter — check your YAML.`, `Line ${fmError.line}: ${fmError.message}`);
|
|
137
129
|
}
|
|
130
|
+
validateTextMeta(meta.title, "frontmatter title", 200);
|
|
131
|
+
validateTextMeta(meta.description, "frontmatter description", 1000);
|
|
138
132
|
const assetType = opts.assetType ?? parseAssetType(meta.asset_type ?? meta.type, "frontmatter type") ?? "skill";
|
|
139
133
|
const installsAs = opts.installsAs ?? parseInstallsAs(meta.installs_as, "frontmatter installs_as") ?? null;
|
|
140
134
|
const version = opts.version ?? parseVersion(meta.version, "frontmatter version") ?? null;
|
|
@@ -150,25 +144,27 @@ export async function publish(opts) {
|
|
|
150
144
|
color: "yellow",
|
|
151
145
|
}).start();
|
|
152
146
|
const apiUrl = resolveApiUrl(cfg);
|
|
147
|
+
const body = {
|
|
148
|
+
title: meta.title ?? null,
|
|
149
|
+
description: meta.description ?? null,
|
|
150
|
+
body_md: skillPackage.skillBody,
|
|
151
|
+
visibility: opts.visibility,
|
|
152
|
+
asset_type: assetType,
|
|
153
|
+
source: "markdown",
|
|
154
|
+
installs_as: installsAs,
|
|
155
|
+
version,
|
|
156
|
+
original_filename: skillPackage.originalFilename,
|
|
157
|
+
published_via: "cli",
|
|
158
|
+
...(opts.sharedWithEmails ? { shared_with_emails: opts.sharedWithEmails } : {}),
|
|
159
|
+
...(skillPackage.packageFiles.length > 0 ? { package_files: skillPackage.packageFiles } : {}),
|
|
160
|
+
};
|
|
153
161
|
let res;
|
|
154
162
|
try {
|
|
155
163
|
res = await floomFetch(`${apiUrl}/api/skills`, "publish your skill", {
|
|
156
164
|
method: "POST",
|
|
157
165
|
token: cfg.accessToken,
|
|
158
166
|
checkOk: false,
|
|
159
|
-
body
|
|
160
|
-
title: meta.title ?? null,
|
|
161
|
-
description: meta.description ?? null,
|
|
162
|
-
body_md: raw,
|
|
163
|
-
visibility: opts.visibility,
|
|
164
|
-
asset_type: assetType,
|
|
165
|
-
source: "markdown",
|
|
166
|
-
installs_as: installsAs,
|
|
167
|
-
version,
|
|
168
|
-
original_filename: basename(filePath),
|
|
169
|
-
published_via: "cli",
|
|
170
|
-
shared_with_emails: opts.sharedWithEmails,
|
|
171
|
-
},
|
|
167
|
+
body,
|
|
172
168
|
});
|
|
173
169
|
}
|
|
174
170
|
catch (err) {
|
|
@@ -192,17 +188,10 @@ export async function publish(opts) {
|
|
|
192
188
|
spinner.stop();
|
|
193
189
|
const versionTag = version ? c.dim(` (${formatVersionLabel(version)})`) : "";
|
|
194
190
|
const titleLabel = data.title ? `"${data.title}"` : opts.file;
|
|
195
|
-
const invitedEmails = opts.sharedWithEmails ?? [];
|
|
196
191
|
process.stdout.write(`\n${symbols.ok} Published ${c.bold(titleLabel)}${versionTag}\n\n`);
|
|
197
|
-
process.stdout.write(` ${c.bold("Send this to someone:")}\n`);
|
|
198
192
|
process.stdout.write(` ${c.cyan(humanUrl)}\n\n`);
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
process.stdout.write(` ${c.bold("Your library:")}\n`);
|
|
202
|
-
process.stdout.write(` ${c.dim("Your account owns this published skill. Run `npx -y @floomhq/floom sync` or connect MCP to keep your saved, published, and followed library skills local.")}\n\n`);
|
|
203
|
-
if (invitedEmails.length) {
|
|
204
|
-
process.stdout.write(` ${c.bold("Email invite:")}\n`);
|
|
205
|
-
process.stdout.write(` ${c.dim(invitedEmails.join(", "))}\n\n`);
|
|
193
|
+
if (data.visibility === "shared" && data.shared_with_emails?.length) {
|
|
194
|
+
process.stdout.write(` ${c.dim(`Shared with ${data.shared_with_emails.join(", ")}`)}\n\n`);
|
|
206
195
|
}
|
|
207
196
|
let copied = false;
|
|
208
197
|
try {
|
|
@@ -213,11 +202,22 @@ export async function publish(opts) {
|
|
|
213
202
|
copied = false;
|
|
214
203
|
}
|
|
215
204
|
if (copied) {
|
|
216
|
-
process.stdout.write(` ${c.dim("Copied
|
|
205
|
+
process.stdout.write(` ${c.dim("Copied to clipboard. Share it anywhere.")}\n\n`);
|
|
217
206
|
}
|
|
218
207
|
else {
|
|
219
208
|
process.stdout.write(` ${c.dim("Share it anywhere.")}\n\n`);
|
|
220
209
|
}
|
|
221
|
-
process.stdout.write(` ${c.bold("
|
|
222
|
-
process.stdout.write(` ${c.cyan(
|
|
210
|
+
process.stdout.write(` ${c.bold("Next")}\n`);
|
|
211
|
+
process.stdout.write(` ${c.dim("1.")} Test locally: ${c.cyan(`npx -y @floomhq/floom add ${humanUrl} --setup`)}\n`);
|
|
212
|
+
process.stdout.write(` ${c.dim("2.")} Send the link.\n`);
|
|
213
|
+
process.stdout.write(` ${c.dim("3.")} Receiver runs ${c.cyan(`npx -y @floomhq/floom add ${humanUrl} --setup`)}\n`);
|
|
214
|
+
process.stdout.write(` ${c.dim("4.")} Agent reads the installed Markdown from the local skills folder.\n\n`);
|
|
215
|
+
}
|
|
216
|
+
function validateTextMeta(value, label, maxLength) {
|
|
217
|
+
if (value === undefined)
|
|
218
|
+
return;
|
|
219
|
+
if (!value.trim())
|
|
220
|
+
throw new FloomError(`Invalid ${label}.`, "Metadata values cannot be blank.");
|
|
221
|
+
if (value.length > maxLength)
|
|
222
|
+
throw new FloomError(`Invalid ${label}.`, `Use ${maxLength} characters or fewer.`);
|
|
223
223
|
}
|
package/dist/scan.js
CHANGED
|
@@ -1,29 +1,24 @@
|
|
|
1
|
-
import { readFile } from "node:fs/promises";
|
|
2
|
-
import { resolve } from "node:path";
|
|
3
1
|
import { detectSkillSecurityFindings, formatSecurityFindings } from "./secrets.js";
|
|
4
2
|
import { FloomError } from "./errors.js";
|
|
5
3
|
import { c, symbols } from "./ui.js";
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
4
|
+
import { readSkillPackage } from "./package.js";
|
|
5
|
+
export async function scanSkill(inputPath) {
|
|
6
|
+
const skillPackage = await readSkillPackage(inputPath);
|
|
7
|
+
const failures = [
|
|
8
|
+
{ path: "SKILL.md", body: skillPackage.skillBody },
|
|
9
|
+
...skillPackage.packageFiles.map((file) => ({
|
|
10
|
+
path: file.path,
|
|
11
|
+
body: Buffer.from(file.content_base64, "base64").toString("utf8"),
|
|
12
|
+
})),
|
|
13
|
+
].flatMap((file) => {
|
|
14
|
+
const findings = detectSkillSecurityFindings(file.body);
|
|
15
|
+
return findings.length > 0 ? [`${file.path}\n${formatSecurityFindings(findings)}`] : [];
|
|
16
|
+
});
|
|
17
|
+
if (failures.length > 0) {
|
|
18
|
+
throw new FloomError("Security scan failed.", `${failures.join("\n")}\nRemove secrets, prompt-injection text, or data-exfiltration instructions before publishing.`);
|
|
11
19
|
}
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
throw new FloomError(`File not found: ${file}`);
|
|
16
|
-
if (code === "EISDIR")
|
|
17
|
-
throw new FloomError(`That's a directory, not a file: ${file}`);
|
|
18
|
-
throw new FloomError(`Couldn't read ${file}: ${err.message}`);
|
|
19
|
-
}
|
|
20
|
-
if (!raw.trim()) {
|
|
21
|
-
throw new FloomError(`File is empty: ${file}`, "Add skill instructions before scanning or publishing.");
|
|
22
|
-
}
|
|
23
|
-
const findings = detectSkillSecurityFindings(raw);
|
|
24
|
-
if (findings.length > 0) {
|
|
25
|
-
throw new FloomError("Security scan failed.", `${formatSecurityFindings(findings)}\nRemove secrets, prompt-injection text, or data-exfiltration instructions before publishing.`);
|
|
26
|
-
}
|
|
27
|
-
process.stdout.write(`\n${symbols.ok} Security scan passed for ${c.bold(file)}\n`);
|
|
20
|
+
const fileCount = 1 + skillPackage.packageFiles.length;
|
|
21
|
+
process.stdout.write(`\n${symbols.ok} Security scan passed for ${c.bold(inputPath)}\n`);
|
|
22
|
+
process.stdout.write(` ${c.dim(`Checked ${fileCount} package file${fileCount === 1 ? "" : "s"}.`)}\n`);
|
|
28
23
|
process.stdout.write(` ${c.dim("No high-confidence secrets, prompt-injection text, or exfiltration instructions found.")}\n\n`);
|
|
29
24
|
}
|
package/dist/secrets.js
CHANGED
|
@@ -5,29 +5,15 @@ const SECRET_PATTERNS = [
|
|
|
5
5
|
{ label: "Google API key", regex: /\bAIza[0-9A-Za-z_-]{25,}\b/g },
|
|
6
6
|
{ label: "GitHub token", regex: /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{30,}\b/g },
|
|
7
7
|
{ label: "GitHub token", regex: /\bgithub_pat_[A-Za-z0-9_]{40,}\b/g },
|
|
8
|
-
{ label: "npm token", regex: /\bnpm_[A-Za-z0-9]{30,}\b/g },
|
|
9
8
|
{ label: "Supabase access token", regex: /\bsbp_[A-Za-z0-9]{30,}\b/g },
|
|
10
9
|
{ label: "Stripe secret key", regex: /\bsk_(?:live|test)_[A-Za-z0-9]{20,}\b/g },
|
|
11
10
|
{ label: "Slack token", regex: /\bxox[baprs]-[A-Za-z0-9-]{20,}\b/g },
|
|
12
11
|
{ label: "AWS access key", regex: /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g },
|
|
13
|
-
{ label: "Database URL with password", regex: /\b(?:postgres|postgresql|mysql|mongodb(?:\+srv)?|redis):\/\/[^:\s/@]+:[^@\s]{8,}@[^\s]+/gi },
|
|
14
12
|
{ label: "Private key", regex: /-----BEGIN (?:RSA |EC |OPENSSH |)PRIVATE KEY-----/g },
|
|
15
13
|
];
|
|
16
14
|
const GENERIC_ASSIGNMENT_RE = /\b(?:api[_-]?key|secret|access[_-]?token|auth[_-]?token|bearer[_-]?token)\b\s*[:=]\s*["']?([A-Za-z0-9_./+=-]{24,})["']?/gi;
|
|
17
15
|
const PROVIDER_LIKE_ASSIGNMENT_RE = /\b(?:api[_-]?key|secret|access[_-]?token|auth[_-]?token|bearer[_-]?token)\b\s*[:=]\s*["']?((?:sk|pk|rk)-[A-Za-z0-9_-]{8,}|sbp_[A-Za-z0-9]{12,}|xox[baprs]-[A-Za-z0-9-]{12,})["']?/gi;
|
|
18
|
-
const PLACEHOLDER_RE = /(?:^|[_./+=-])(?:your|example|
|
|
19
|
-
const PLACEHOLDER_PHRASE_WORDS = new Set([
|
|
20
|
-
"and",
|
|
21
|
-
"fake",
|
|
22
|
-
"is",
|
|
23
|
-
"key",
|
|
24
|
-
"long",
|
|
25
|
-
"looks",
|
|
26
|
-
"real",
|
|
27
|
-
"secret",
|
|
28
|
-
"that",
|
|
29
|
-
"very",
|
|
30
|
-
]);
|
|
16
|
+
const PLACEHOLDER_RE = /(?:^|[_./+=-])(?:your|example|placeholder|replace|changeme|todo|xxx|demo|dummy|fake|redacted)(?:$|[_./+=-])/i;
|
|
31
17
|
const PROMPT_INJECTION_PATTERNS = [
|
|
32
18
|
{ label: "Prompt injection instruction", regex: /\bignore (?:all )?(?:previous|prior|above|earlier) instructions\b/gi },
|
|
33
19
|
{ label: "Prompt injection instruction", regex: /\bdisregard (?:all )?(?:previous|prior|above|earlier) instructions\b/gi },
|
|
@@ -37,14 +23,8 @@ const PROMPT_INJECTION_PATTERNS = [
|
|
|
37
23
|
];
|
|
38
24
|
const DATA_EXFILTRATION_PATTERNS = [
|
|
39
25
|
{ label: "Data exfiltration instruction", regex: /\b(?:send|post|upload|exfiltrate|copy) (?:[^.\n]{0,80})\b(?:api keys?|tokens?|secrets?|environment variables|\.env|credentials)\b(?:[^.\n]{0,120})\b(?:to|into) https?:\/\//gi },
|
|
40
|
-
{ label: "Data exfiltration instruction", regex: /\b(?:send|post|upload|exfiltrate|copy)\b[^.\n]{0,120}(?:~\/\.ssh\/[A-Za-z0-9_.-]+|id_rsa|id_ed25519|ssh keys?|private keys?|secret files?)\b[^.\n]{0,120}\b(?:to|into) https?:\/\//gi },
|
|
41
|
-
{ label: "Data exfiltration instruction", regex: /\b(?:send|email|mail|forward|share|transmit|post|upload|exfiltrate|copy)\b[^.\n]{0,160}\b(?:api keys?|tokens?|secrets?|environment variables|\.env|credentials)\b[^.\n]{0,160}\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi },
|
|
42
|
-
{ label: "Data exfiltration instruction", regex: /\b(?:send|email|mail|forward|share|transmit|post|upload|exfiltrate|copy)\b[^.\n]{0,160}(?:~\/\.ssh\/[A-Za-z0-9_.-]+|id_rsa|id_ed25519|ssh keys?|private keys?|secret files?)\b[^.\n]{0,160}\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi },
|
|
43
|
-
{ label: "Data exfiltration instruction", regex: /(?:~\/\.ssh\/[A-Za-z0-9_.-]+|id_rsa|id_ed25519|ssh keys?|private keys?|secret files?|\bapi keys?|\btokens?|\bsecrets?|\benvironment variables|\.env|\bcredentials)\b[^.\n]{0,160}\b(?:send|email|mail|forward|share|transmit|post|upload|exfiltrate|copy)\b[^.\n]{0,120}\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi },
|
|
44
26
|
{ label: "Data exfiltration instruction", regex: /\b(?:curl|wget|fetch)\b[^\n]{0,160}\b(?:api keys?|tokens?|secrets?|environment variables|\.env|credentials)\b/gi },
|
|
45
|
-
{ label: "Data exfiltration instruction", regex: /\b(?:curl|wget|fetch)\b[^\n]{0,160}(?:~\/\.ssh\/[A-Za-z0-9_.-]+|id_rsa|id_ed25519|ssh keys?|private keys?|secret files?)\b/gi },
|
|
46
27
|
{ label: "Credential harvesting instruction", regex: /\b(?:collect|harvest|steal|extract) (?:[^.\n]{0,80})\b(?:api keys?|tokens?|secrets?|environment variables|\.env|credentials)\b/gi },
|
|
47
|
-
{ label: "Credential harvesting instruction", regex: /\b(?:collect|harvest|steal|extract)\b[^.\n]{0,120}(?:~\/\.ssh\/[A-Za-z0-9_.-]+|id_rsa|id_ed25519|ssh keys?|private keys?|secret files?)\b/gi },
|
|
48
28
|
];
|
|
49
29
|
function redact(value) {
|
|
50
30
|
if (value.length <= 12)
|
|
@@ -66,12 +46,6 @@ function pushFinding(findings, seen, label, line, value) {
|
|
|
66
46
|
seen.add(key);
|
|
67
47
|
findings.push({ label, line, preview: redact(value) });
|
|
68
48
|
}
|
|
69
|
-
function isPlaceholderValue(value) {
|
|
70
|
-
if (PLACEHOLDER_RE.test(value))
|
|
71
|
-
return true;
|
|
72
|
-
const words = value.toLowerCase().split(/[^a-z]+/).filter(Boolean);
|
|
73
|
-
return words.length >= 6 && words.every((word) => PLACEHOLDER_PHRASE_WORDS.has(word));
|
|
74
|
-
}
|
|
75
49
|
export function detectSecrets(input) {
|
|
76
50
|
const findings = [];
|
|
77
51
|
const seen = new Set();
|
|
@@ -85,14 +59,14 @@ export function detectSecrets(input) {
|
|
|
85
59
|
GENERIC_ASSIGNMENT_RE.lastIndex = 0;
|
|
86
60
|
for (const match of input.matchAll(GENERIC_ASSIGNMENT_RE)) {
|
|
87
61
|
const value = match[1] ?? "";
|
|
88
|
-
if (!value ||
|
|
62
|
+
if (!value || PLACEHOLDER_RE.test(value))
|
|
89
63
|
continue;
|
|
90
64
|
pushFinding(findings, seen, "Possible secret assignment", lineNumberAt(input, match.index ?? 0), value);
|
|
91
65
|
}
|
|
92
66
|
PROVIDER_LIKE_ASSIGNMENT_RE.lastIndex = 0;
|
|
93
67
|
for (const match of input.matchAll(PROVIDER_LIKE_ASSIGNMENT_RE)) {
|
|
94
68
|
const value = match[1] ?? "";
|
|
95
|
-
if (!value
|
|
69
|
+
if (!value)
|
|
96
70
|
continue;
|
|
97
71
|
pushFinding(findings, seen, "Provider-like secret assignment", lineNumberAt(input, match.index ?? 0), value);
|
|
98
72
|
}
|