@klhapp/skillmux 1.11.2 → 1.13.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/CHANGELOG.md +15 -0
- package/README.md +20 -10
- package/bin/skillmux.js +89 -0
- package/docs/README.md +4 -4
- package/docs/cli.md +15 -5
- package/docs/concepts.md +4 -3
- package/docs/configuration.md +22 -5
- package/docs/deployment.md +3 -3
- package/docs/getting-started.md +32 -7
- package/docs/releasing.md +17 -2
- package/docs/sdd/cli-remote-target-parity/schema-bucket-b.yaml +297 -0
- package/docs/sdd/cli-remote-target-parity/spec-bucket-b.md +95 -0
- package/docs/sdd/cli-remote-target-parity/spec.md +111 -0
- package/docs/sdd/cli-remote-target-parity/think.md +183 -0
- package/docs/sdd/cli-surface-consistency/think.md +149 -0
- package/docs/sdd/config-authority-rerank-resilience/plan.md +338 -0
- package/docs/sdd/config-authority-rerank-resilience/review.md +124 -0
- package/docs/sdd/config-authority-rerank-resilience/spec.md +78 -0
- package/docs/sdd/feat-packaging-deployment/review.md +95 -0
- package/docs/sdd/fetch-outcome-flywheel/review.md +103 -0
- package/docs/sdd/fetch-outcome-flywheel/schema.json +347 -0
- package/docs/sdd/fetch-outcome-flywheel/spec.md +185 -0
- package/docs/sdd/npm-native-binaries/review.md +103 -0
- package/docs/sdd/npm-native-binaries/spec.md +148 -0
- package/docs/sdd/ranked-shortlist-2/pr3-calibration-removal.md +56 -0
- package/docs/sdd/refactor-builtin-target-paths/spec.md +41 -0
- package/docs/sdd/runtime-resource-hardening/review.md +86 -0
- package/docs/sdd/runtime-resource-hardening/spec.md +43 -0
- package/docs/sdd/security-hardening-phase/think.md +129 -0
- package/docs/sdd/security-observability/review.md +96 -0
- package/docs/sdd/security-observability/spec.md +55 -0
- package/docs/sdd/skill-provenance-update/review.md +152 -0
- package/docs/sdd/skill-provenance-update/schema.json +193 -0
- package/docs/sdd/skill-provenance-update/spec.md +134 -0
- package/docs/sdd/supply-chain-hardening/review.md +50 -0
- package/docs/sdd/supply-chain-hardening/spec.md +42 -0
- package/docs/sdd/target-marker-rehome/spec.md +33 -0
- package/docs/skill-management.md +1 -1
- package/docs/troubleshooting.md +18 -2
- package/package.json +15 -8
- package/src/adapters.ts +0 -438
- package/src/audit.ts +0 -21
- package/src/cli.ts +0 -928
- package/src/clients.ts +0 -368
- package/src/commands/audit.ts +0 -85
- package/src/commands/config.ts +0 -224
- package/src/commands/context.ts +0 -104
- package/src/commands/core.ts +0 -56
- package/src/commands/doctor.ts +0 -97
- package/src/commands/eval.ts +0 -88
- package/src/commands/init.ts +0 -672
- package/src/commands/install.ts +0 -155
- package/src/commands/local-vault.ts +0 -60
- package/src/commands/models.ts +0 -10
- package/src/commands/outdated.ts +0 -115
- package/src/commands/project.ts +0 -580
- package/src/commands/report.ts +0 -66
- package/src/commands/scan.ts +0 -71
- package/src/commands/shared.ts +0 -38
- package/src/commands/skill.ts +0 -33
- package/src/commands/sync.ts +0 -233
- package/src/commands/target.ts +0 -225
- package/src/commands/update.ts +0 -277
- package/src/completions.ts +0 -196
- package/src/concurrency-limiter.ts +0 -61
- package/src/config-service.ts +0 -398
- package/src/config-watcher.ts +0 -253
- package/src/config.ts +0 -583
- package/src/context.ts +0 -188
- package/src/db-audit.ts +0 -286
- package/src/db-index.ts +0 -238
- package/src/db.ts +0 -3
- package/src/deployment.ts +0 -39
- package/src/doctor.ts +0 -205
- package/src/eval.ts +0 -302
- package/src/global-flags.ts +0 -46
- package/src/init-agents.ts +0 -329
- package/src/init-instructions.ts +0 -192
- package/src/init.ts +0 -389
- package/src/install.ts +0 -232
- package/src/lifecycle.ts +0 -51
- package/src/logger.ts +0 -26
- package/src/manifest.ts +0 -352
- package/src/mcp-registration.ts +0 -89
- package/src/metrics.ts +0 -121
- package/src/models.ts +0 -20
- package/src/output.ts +0 -214
- package/src/project-setup.ts +0 -36
- package/src/prompts.ts +0 -124
- package/src/provenance.ts +0 -99
- package/src/rate-limiter.ts +0 -137
- package/src/readiness.ts +0 -30
- package/src/redact.ts +0 -52
- package/src/router-core.ts +0 -666
- package/src/rrf.ts +0 -31
- package/src/scan.ts +0 -300
- package/src/server.ts +0 -930
- package/src/setup.ts +0 -145
- package/src/snapshot.ts +0 -135
- package/src/stats.ts +0 -340
- package/src/sync.ts +0 -482
- package/src/toml-writer.ts +0 -51
- package/src/types.ts +0 -212
- package/src/vault.ts +0 -207
package/src/scan.ts
DELETED
|
@@ -1,300 +0,0 @@
|
|
|
1
|
-
import { existsSync, lstatSync } from "node:fs";
|
|
2
|
-
import { basename, dirname, join } from "node:path";
|
|
3
|
-
import { decodeUtf8Strict, listSupportingFiles, scanVault } from "./vault";
|
|
4
|
-
|
|
5
|
-
export type ScanSeverity = "low" | "medium" | "high";
|
|
6
|
-
|
|
7
|
-
export interface RuleMatch {
|
|
8
|
-
rule_id: string;
|
|
9
|
-
severity: ScanSeverity;
|
|
10
|
-
message: string;
|
|
11
|
-
line?: number;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export type Rule = (content: string) => RuleMatch[];
|
|
15
|
-
|
|
16
|
-
function lineOf(content: string, index: number): number {
|
|
17
|
-
return content.slice(0, index).split("\n").length;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
const INJECTION_PHRASES = [
|
|
21
|
-
"ignore previous instructions",
|
|
22
|
-
"ignore all previous instructions",
|
|
23
|
-
"ignore your instructions",
|
|
24
|
-
"disregard all prior instructions",
|
|
25
|
-
"disregard the above",
|
|
26
|
-
"disregard previous instructions",
|
|
27
|
-
"new instructions:",
|
|
28
|
-
"system prompt:",
|
|
29
|
-
"you are now",
|
|
30
|
-
];
|
|
31
|
-
|
|
32
|
-
export const promptInjectionPhraseRule: Rule = (content) => {
|
|
33
|
-
const lower = content.toLowerCase();
|
|
34
|
-
const matches: RuleMatch[] = [];
|
|
35
|
-
for (const phrase of INJECTION_PHRASES) {
|
|
36
|
-
const index = lower.indexOf(phrase);
|
|
37
|
-
if (index !== -1) {
|
|
38
|
-
matches.push({
|
|
39
|
-
rule_id: "prompt-injection-phrase",
|
|
40
|
-
severity: "high",
|
|
41
|
-
message: `contains instruction-override phrase: "${phrase}"`,
|
|
42
|
-
line: lineOf(content, index),
|
|
43
|
-
});
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
return matches;
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
const INVISIBLE_CODE_POINTS = new Set([0x200b, 0x200c, 0x200d, 0xfeff]);
|
|
50
|
-
const TAG_CHAR_START = 0xe0000;
|
|
51
|
-
const TAG_CHAR_END = 0xe007f;
|
|
52
|
-
|
|
53
|
-
function isInvisibleCodePoint(codePoint: number): boolean {
|
|
54
|
-
return INVISIBLE_CODE_POINTS.has(codePoint) || (codePoint >= TAG_CHAR_START && codePoint <= TAG_CHAR_END);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export const invisibleUnicodeRule: Rule = (content) => {
|
|
58
|
-
let count = 0;
|
|
59
|
-
let firstIndex = -1;
|
|
60
|
-
let searchOffset = 0;
|
|
61
|
-
for (const char of content) {
|
|
62
|
-
const codePoint = char.codePointAt(0)!;
|
|
63
|
-
if (isInvisibleCodePoint(codePoint)) {
|
|
64
|
-
count++;
|
|
65
|
-
if (firstIndex === -1) firstIndex = searchOffset;
|
|
66
|
-
}
|
|
67
|
-
searchOffset += char.length;
|
|
68
|
-
}
|
|
69
|
-
if (count === 0) return [];
|
|
70
|
-
return [
|
|
71
|
-
{
|
|
72
|
-
rule_id: "invisible-unicode",
|
|
73
|
-
severity: "high",
|
|
74
|
-
message: `contains ${count} invisible/zero-width Unicode character${count === 1 ? "" : "s"}`,
|
|
75
|
-
line: lineOf(content, firstIndex),
|
|
76
|
-
},
|
|
77
|
-
];
|
|
78
|
-
};
|
|
79
|
-
|
|
80
|
-
const SECRET_PATTERNS: { pattern: RegExp; describe: string }[] = [
|
|
81
|
-
{ pattern: /AKIA[0-9A-Z]{16}/, describe: "AWS-style access key" },
|
|
82
|
-
{ pattern: /-----BEGIN (RSA |EC |OPENSSH |)PRIVATE KEY-----/, describe: "PEM private key block" },
|
|
83
|
-
{
|
|
84
|
-
pattern: /\b(api[_-]?key|token|secret)\s*[:=]\s*["']?[A-Za-z0-9_-]{16,}["']?/i,
|
|
85
|
-
describe: "hardcoded credential-shaped assignment",
|
|
86
|
-
},
|
|
87
|
-
];
|
|
88
|
-
|
|
89
|
-
export const secretPatternRule: Rule = (content) => {
|
|
90
|
-
const matches: RuleMatch[] = [];
|
|
91
|
-
for (const { pattern, describe } of SECRET_PATTERNS) {
|
|
92
|
-
const match = pattern.exec(content);
|
|
93
|
-
if (match) {
|
|
94
|
-
matches.push({
|
|
95
|
-
rule_id: "secret-pattern",
|
|
96
|
-
severity: "high",
|
|
97
|
-
message: `contains a ${describe}`,
|
|
98
|
-
line: lineOf(content, match.index),
|
|
99
|
-
});
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
return matches;
|
|
103
|
-
};
|
|
104
|
-
|
|
105
|
-
const URL_PATTERN = /https?:\/\/[^\s)"']+/g;
|
|
106
|
-
const BARE_IP_URL_PATTERN = /^https?:\/\/(?:\d{1,3}\.){3}\d{1,3}(?::\d+)?/;
|
|
107
|
-
const EXFIL_PHRASES = [
|
|
108
|
-
"post this to",
|
|
109
|
-
"send this to",
|
|
110
|
-
"upload this to",
|
|
111
|
-
"send the contents",
|
|
112
|
-
"exfiltrate",
|
|
113
|
-
];
|
|
114
|
-
|
|
115
|
-
export const suspiciousUrlRule: Rule = (content) => {
|
|
116
|
-
const lower = content.toLowerCase();
|
|
117
|
-
const matches: RuleMatch[] = [];
|
|
118
|
-
for (const match of content.matchAll(URL_PATTERN)) {
|
|
119
|
-
const url = match[0]!;
|
|
120
|
-
if (BARE_IP_URL_PATTERN.test(url)) {
|
|
121
|
-
matches.push({
|
|
122
|
-
rule_id: "suspicious-url",
|
|
123
|
-
severity: "medium",
|
|
124
|
-
message: `bare-IP-address URL: ${url}`,
|
|
125
|
-
line: lineOf(content, match.index!),
|
|
126
|
-
});
|
|
127
|
-
continue;
|
|
128
|
-
}
|
|
129
|
-
const nearby = lower.slice(Math.max(0, match.index! - 60), match.index!);
|
|
130
|
-
const exfilPhrase = EXFIL_PHRASES.find((phrase) => nearby.includes(phrase));
|
|
131
|
-
if (exfilPhrase) {
|
|
132
|
-
matches.push({
|
|
133
|
-
rule_id: "suspicious-url",
|
|
134
|
-
severity: "medium",
|
|
135
|
-
message: `URL paired with exfiltration-suggesting text ("${exfilPhrase}"): ${url}`,
|
|
136
|
-
line: lineOf(content, match.index!),
|
|
137
|
-
});
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
return matches;
|
|
141
|
-
};
|
|
142
|
-
|
|
143
|
-
export const RULES: Rule[] = [promptInjectionPhraseRule, invisibleUnicodeRule, secretPatternRule, suspiciousUrlRule];
|
|
144
|
-
|
|
145
|
-
export function scanContent(content: string): RuleMatch[] {
|
|
146
|
-
return RULES.flatMap((rule) => rule(content));
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
export interface ScanFinding extends RuleMatch {
|
|
150
|
-
skill_id: string;
|
|
151
|
-
file: string;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
export interface ScanResult {
|
|
155
|
-
scanned: number;
|
|
156
|
-
findings: ScanFinding[];
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
interface ScanContentFile {
|
|
160
|
-
skill_id: string;
|
|
161
|
-
file: string;
|
|
162
|
-
content: string;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
/** Refuses (returns null for) a symlinked path instead of following it — every
|
|
166
|
-
* caller feeds vault content into scan findings, so a symlink swapped in after
|
|
167
|
-
* an earlier symlink-filtering pass (e.g. listSupportingFiles' walk, which runs
|
|
168
|
-
* before this read, not at it) must still be caught right here, not trusted to
|
|
169
|
-
* have stayed excluded. Same defense-in-depth pattern as readSkill/hashSkillContent. */
|
|
170
|
-
export async function readTextFileOrNull(path: string): Promise<string | null> {
|
|
171
|
-
try {
|
|
172
|
-
if (lstatSync(path).isSymbolicLink()) return null;
|
|
173
|
-
const bytes = await Bun.file(path).bytes();
|
|
174
|
-
return decodeUtf8Strict(bytes);
|
|
175
|
-
} catch {
|
|
176
|
-
return null;
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
async function collectSkillFiles(
|
|
181
|
-
vaultPath: string,
|
|
182
|
-
skillId: string,
|
|
183
|
-
skillMdBody: string,
|
|
184
|
-
): Promise<ScanContentFile[]> {
|
|
185
|
-
const files: ScanContentFile[] = [{ skill_id: skillId, file: "SKILL.md", content: skillMdBody }];
|
|
186
|
-
for (const rel of listSupportingFiles(vaultPath, skillId)) {
|
|
187
|
-
const content = await readTextFileOrNull(join(vaultPath, skillId, rel));
|
|
188
|
-
if (content !== null) files.push({ skill_id: skillId, file: rel, content });
|
|
189
|
-
}
|
|
190
|
-
return files;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
interface ResolvedScanFiles {
|
|
194
|
-
files: ScanContentFile[];
|
|
195
|
-
/** skill_ids whose SKILL.md could not be parsed/decoded — must still be counted and
|
|
196
|
-
* flagged, never silently dropped, or a malformed SKILL.md becomes a scan-evasion trick. */
|
|
197
|
-
unparseable: string[];
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
/** Single-skill-dir mode when `rootPath` itself holds a SKILL.md; otherwise treats
|
|
201
|
-
* `rootPath` as a vault root and enumerates every skill dir under it. */
|
|
202
|
-
async function resolveScanFiles(rootPath: string): Promise<ResolvedScanFiles> {
|
|
203
|
-
if (existsSync(join(rootPath, "SKILL.md"))) {
|
|
204
|
-
const skillId = basename(rootPath);
|
|
205
|
-
const vaultPath = dirname(rootPath);
|
|
206
|
-
const body = await readTextFileOrNull(join(rootPath, "SKILL.md"));
|
|
207
|
-
if (body === null) return { files: [], unparseable: [skillId] };
|
|
208
|
-
return { files: await collectSkillFiles(vaultPath, skillId, body), unparseable: [] };
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
const unparseable: string[] = [];
|
|
212
|
-
const skills = await scanVault(rootPath, (skillId) => unparseable.push(skillId));
|
|
213
|
-
const files: ScanContentFile[] = [];
|
|
214
|
-
for (const skill of skills) {
|
|
215
|
-
files.push(...(await collectSkillFiles(rootPath, skill.skill_id, skill.body)));
|
|
216
|
-
}
|
|
217
|
-
return { files, unparseable };
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
export async function scanPath(rootPath: string): Promise<ScanResult> {
|
|
221
|
-
const { files, unparseable } = await resolveScanFiles(rootPath);
|
|
222
|
-
const findings: ScanFinding[] = [];
|
|
223
|
-
const skillIds = new Set<string>();
|
|
224
|
-
for (const file of files) {
|
|
225
|
-
skillIds.add(file.skill_id);
|
|
226
|
-
for (const match of scanContent(file.content)) {
|
|
227
|
-
findings.push({ ...match, skill_id: file.skill_id, file: file.file });
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
for (const skillId of unparseable) {
|
|
231
|
-
skillIds.add(skillId);
|
|
232
|
-
findings.push({
|
|
233
|
-
skill_id: skillId,
|
|
234
|
-
file: "SKILL.md",
|
|
235
|
-
rule_id: "unparseable-skill",
|
|
236
|
-
severity: "medium",
|
|
237
|
-
message: "SKILL.md could not be parsed or decoded — content was not scanned; review manually",
|
|
238
|
-
});
|
|
239
|
-
}
|
|
240
|
-
return { scanned: skillIds.size, findings };
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
export function renderScanText(result: ScanResult): string {
|
|
244
|
-
const skillWord = result.scanned === 1 ? "skill" : "skills";
|
|
245
|
-
if (result.findings.length === 0) {
|
|
246
|
-
return `scanned ${result.scanned} ${skillWord}, no findings`;
|
|
247
|
-
}
|
|
248
|
-
const lines: string[] = [`scanned ${result.scanned} ${skillWord}, ${result.findings.length} finding(s)`];
|
|
249
|
-
for (const finding of result.findings) {
|
|
250
|
-
const location = finding.line !== undefined ? `${finding.file}:${finding.line}` : finding.file;
|
|
251
|
-
lines.push(`[${finding.severity}] ${finding.skill_id}/${location} ${finding.rule_id} — ${finding.message}`);
|
|
252
|
-
}
|
|
253
|
-
return lines.join("\n");
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
export function renderScanJson(result: ScanResult): string {
|
|
257
|
-
return JSON.stringify(result, null, 2);
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
const SEVERITY_RANK: Record<ScanSeverity, number> = { low: 0, medium: 1, high: 2 };
|
|
261
|
-
|
|
262
|
-
export function scanExitCode(findings: RuleMatch[], failOn: ScanSeverity | undefined): number {
|
|
263
|
-
if (!failOn) return 0;
|
|
264
|
-
const threshold = SEVERITY_RANK[failOn];
|
|
265
|
-
return findings.some((f) => SEVERITY_RANK[f.severity] >= threshold) ? 1 : 0;
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
/**
|
|
269
|
-
* A parsed `--fail-on` value. "none" is accepted on the command line to mean
|
|
270
|
-
* "never fail" and is not a finding severity, which is why it is kept out of
|
|
271
|
-
* ScanSeverity itself.
|
|
272
|
-
*
|
|
273
|
-
* Note the ordering: a lower threshold is stricter. `low` fails on low, medium
|
|
274
|
-
* and high; `high` fails only on high.
|
|
275
|
-
*/
|
|
276
|
-
export type FailOnOption = ScanSeverity | "none";
|
|
277
|
-
|
|
278
|
-
export const FAIL_ON_USAGE = "low|medium|high|none";
|
|
279
|
-
|
|
280
|
-
export function parseFailOn(value: string | undefined): FailOnOption {
|
|
281
|
-
if (value !== "low" && value !== "medium" && value !== "high" && value !== "none") {
|
|
282
|
-
throw new Error("--fail-on must be low, medium, high, or none");
|
|
283
|
-
}
|
|
284
|
-
return value;
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
/**
|
|
288
|
-
* Resolves the threshold for the commands that fetch remote content into the
|
|
289
|
-
* vault (install, update). They default to blocking on a high-severity finding,
|
|
290
|
-
* because printing a warning and proceeding anyway is the wrong default on the
|
|
291
|
-
* supply-chain path. `--fail-on none` restores the permissive behavior.
|
|
292
|
-
*
|
|
293
|
-
* `skillmux scan` deliberately does NOT use this: it is a reporting command
|
|
294
|
-
* whose exit code the caller opts into, and giving it a default would change
|
|
295
|
-
* the exit code of existing CI pipelines that just run `skillmux scan`.
|
|
296
|
-
*/
|
|
297
|
-
export function resolveMutatingFailOn(failOn: FailOnOption | undefined): ScanSeverity | undefined {
|
|
298
|
-
if (failOn === undefined) return "high";
|
|
299
|
-
return failOn === "none" ? undefined : failOn;
|
|
300
|
-
}
|