@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.
Files changed (104) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +20 -10
  3. package/bin/skillmux.js +89 -0
  4. package/docs/README.md +4 -4
  5. package/docs/cli.md +15 -5
  6. package/docs/concepts.md +4 -3
  7. package/docs/configuration.md +22 -5
  8. package/docs/deployment.md +3 -3
  9. package/docs/getting-started.md +32 -7
  10. package/docs/releasing.md +17 -2
  11. package/docs/sdd/cli-remote-target-parity/schema-bucket-b.yaml +297 -0
  12. package/docs/sdd/cli-remote-target-parity/spec-bucket-b.md +95 -0
  13. package/docs/sdd/cli-remote-target-parity/spec.md +111 -0
  14. package/docs/sdd/cli-remote-target-parity/think.md +183 -0
  15. package/docs/sdd/cli-surface-consistency/think.md +149 -0
  16. package/docs/sdd/config-authority-rerank-resilience/plan.md +338 -0
  17. package/docs/sdd/config-authority-rerank-resilience/review.md +124 -0
  18. package/docs/sdd/config-authority-rerank-resilience/spec.md +78 -0
  19. package/docs/sdd/feat-packaging-deployment/review.md +95 -0
  20. package/docs/sdd/fetch-outcome-flywheel/review.md +103 -0
  21. package/docs/sdd/fetch-outcome-flywheel/schema.json +347 -0
  22. package/docs/sdd/fetch-outcome-flywheel/spec.md +185 -0
  23. package/docs/sdd/npm-native-binaries/review.md +103 -0
  24. package/docs/sdd/npm-native-binaries/spec.md +148 -0
  25. package/docs/sdd/ranked-shortlist-2/pr3-calibration-removal.md +56 -0
  26. package/docs/sdd/refactor-builtin-target-paths/spec.md +41 -0
  27. package/docs/sdd/runtime-resource-hardening/review.md +86 -0
  28. package/docs/sdd/runtime-resource-hardening/spec.md +43 -0
  29. package/docs/sdd/security-hardening-phase/think.md +129 -0
  30. package/docs/sdd/security-observability/review.md +96 -0
  31. package/docs/sdd/security-observability/spec.md +55 -0
  32. package/docs/sdd/skill-provenance-update/review.md +152 -0
  33. package/docs/sdd/skill-provenance-update/schema.json +193 -0
  34. package/docs/sdd/skill-provenance-update/spec.md +134 -0
  35. package/docs/sdd/supply-chain-hardening/review.md +50 -0
  36. package/docs/sdd/supply-chain-hardening/spec.md +42 -0
  37. package/docs/sdd/target-marker-rehome/spec.md +33 -0
  38. package/docs/skill-management.md +1 -1
  39. package/docs/troubleshooting.md +18 -2
  40. package/package.json +15 -8
  41. package/src/adapters.ts +0 -438
  42. package/src/audit.ts +0 -21
  43. package/src/cli.ts +0 -928
  44. package/src/clients.ts +0 -368
  45. package/src/commands/audit.ts +0 -85
  46. package/src/commands/config.ts +0 -224
  47. package/src/commands/context.ts +0 -104
  48. package/src/commands/core.ts +0 -56
  49. package/src/commands/doctor.ts +0 -97
  50. package/src/commands/eval.ts +0 -88
  51. package/src/commands/init.ts +0 -672
  52. package/src/commands/install.ts +0 -155
  53. package/src/commands/local-vault.ts +0 -60
  54. package/src/commands/models.ts +0 -10
  55. package/src/commands/outdated.ts +0 -115
  56. package/src/commands/project.ts +0 -580
  57. package/src/commands/report.ts +0 -66
  58. package/src/commands/scan.ts +0 -71
  59. package/src/commands/shared.ts +0 -38
  60. package/src/commands/skill.ts +0 -33
  61. package/src/commands/sync.ts +0 -233
  62. package/src/commands/target.ts +0 -225
  63. package/src/commands/update.ts +0 -277
  64. package/src/completions.ts +0 -196
  65. package/src/concurrency-limiter.ts +0 -61
  66. package/src/config-service.ts +0 -398
  67. package/src/config-watcher.ts +0 -253
  68. package/src/config.ts +0 -583
  69. package/src/context.ts +0 -188
  70. package/src/db-audit.ts +0 -286
  71. package/src/db-index.ts +0 -238
  72. package/src/db.ts +0 -3
  73. package/src/deployment.ts +0 -39
  74. package/src/doctor.ts +0 -205
  75. package/src/eval.ts +0 -302
  76. package/src/global-flags.ts +0 -46
  77. package/src/init-agents.ts +0 -329
  78. package/src/init-instructions.ts +0 -192
  79. package/src/init.ts +0 -389
  80. package/src/install.ts +0 -232
  81. package/src/lifecycle.ts +0 -51
  82. package/src/logger.ts +0 -26
  83. package/src/manifest.ts +0 -352
  84. package/src/mcp-registration.ts +0 -89
  85. package/src/metrics.ts +0 -121
  86. package/src/models.ts +0 -20
  87. package/src/output.ts +0 -214
  88. package/src/project-setup.ts +0 -36
  89. package/src/prompts.ts +0 -124
  90. package/src/provenance.ts +0 -99
  91. package/src/rate-limiter.ts +0 -137
  92. package/src/readiness.ts +0 -30
  93. package/src/redact.ts +0 -52
  94. package/src/router-core.ts +0 -666
  95. package/src/rrf.ts +0 -31
  96. package/src/scan.ts +0 -300
  97. package/src/server.ts +0 -930
  98. package/src/setup.ts +0 -145
  99. package/src/snapshot.ts +0 -135
  100. package/src/stats.ts +0 -340
  101. package/src/sync.ts +0 -482
  102. package/src/toml-writer.ts +0 -51
  103. package/src/types.ts +0 -212
  104. 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
- }