@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.
@@ -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 filePath = resolve(process.cwd(), opts.file);
112
- let raw;
113
- try {
114
- raw = await readFile(filePath, "utf8");
115
- }
116
- catch (e) {
117
- const code = e.code;
118
- if (code === "ENOENT") {
119
- throw new FloomError(`File not found: ${opts.file}`);
120
- }
121
- if (code === "EISDIR") {
122
- throw new FloomError(`That's a directory, not a file: ${opts.file}`);
123
- }
124
- throw new FloomError(`Couldn't read ${opts.file}: ${e.message}`);
125
- }
126
- if (!raw.trim()) {
127
- throw new FloomError(`File is empty: ${opts.file}`);
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
- process.stdout.write(` ${c.bold("They run:")}\n`);
200
- process.stdout.write(` ${c.cyan(`npx -y @floomhq/floom add ${humanUrl} --setup`)}\n\n`);
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 link to clipboard.")}\n\n`);
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("Agent prompt:")}\n`);
222
- process.stdout.write(` ${c.cyan("npx -y @floomhq/floom agent-prompt")}\n\n`);
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
- export async function scanSkill(file) {
7
- const path = resolve(process.cwd(), file);
8
- let raw;
9
- try {
10
- raw = await readFile(path, "utf8");
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
- catch (err) {
13
- const code = err.code;
14
- if (code === "ENOENT")
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|sample|mock|placeholder|replace|changeme|todo|xxx|demo|dummy|fake|redacted)(?:$|[_./+=-])/i;
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 || isPlaceholderValue(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 || isPlaceholderValue(value))
69
+ if (!value)
96
70
  continue;
97
71
  pushFinding(findings, seen, "Provider-like secret assignment", lineNumberAt(input, match.index ?? 0), value);
98
72
  }