@klhapp/skillmux 1.12.0 → 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 (103) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +18 -9
  3. package/bin/skillmux.js +89 -0
  4. package/docs/README.md +4 -4
  5. package/docs/cli.md +14 -4
  6. package/docs/concepts.md +2 -2
  7. package/docs/configuration.md +18 -3
  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/troubleshooting.md +18 -2
  39. package/package.json +15 -8
  40. package/src/adapters.ts +0 -438
  41. package/src/audit.ts +0 -21
  42. package/src/cli.ts +0 -928
  43. package/src/clients.ts +0 -368
  44. package/src/commands/audit.ts +0 -85
  45. package/src/commands/config.ts +0 -224
  46. package/src/commands/context.ts +0 -104
  47. package/src/commands/core.ts +0 -56
  48. package/src/commands/doctor.ts +0 -97
  49. package/src/commands/eval.ts +0 -88
  50. package/src/commands/init.ts +0 -672
  51. package/src/commands/install.ts +0 -155
  52. package/src/commands/local-vault.ts +0 -60
  53. package/src/commands/models.ts +0 -10
  54. package/src/commands/outdated.ts +0 -115
  55. package/src/commands/project.ts +0 -580
  56. package/src/commands/report.ts +0 -66
  57. package/src/commands/scan.ts +0 -71
  58. package/src/commands/shared.ts +0 -38
  59. package/src/commands/skill.ts +0 -33
  60. package/src/commands/sync.ts +0 -233
  61. package/src/commands/target.ts +0 -225
  62. package/src/commands/update.ts +0 -277
  63. package/src/completions.ts +0 -196
  64. package/src/concurrency-limiter.ts +0 -61
  65. package/src/config-service.ts +0 -398
  66. package/src/config-watcher.ts +0 -253
  67. package/src/config.ts +0 -583
  68. package/src/context.ts +0 -188
  69. package/src/db-audit.ts +0 -286
  70. package/src/db-index.ts +0 -238
  71. package/src/db.ts +0 -3
  72. package/src/deployment.ts +0 -39
  73. package/src/doctor.ts +0 -205
  74. package/src/eval.ts +0 -302
  75. package/src/global-flags.ts +0 -46
  76. package/src/init-agents.ts +0 -329
  77. package/src/init-instructions.ts +0 -192
  78. package/src/init.ts +0 -390
  79. package/src/install.ts +0 -232
  80. package/src/lifecycle.ts +0 -51
  81. package/src/logger.ts +0 -26
  82. package/src/manifest.ts +0 -359
  83. package/src/mcp-registration.ts +0 -89
  84. package/src/metrics.ts +0 -121
  85. package/src/models.ts +0 -20
  86. package/src/output.ts +0 -214
  87. package/src/project-setup.ts +0 -36
  88. package/src/prompts.ts +0 -124
  89. package/src/provenance.ts +0 -99
  90. package/src/rate-limiter.ts +0 -137
  91. package/src/readiness.ts +0 -30
  92. package/src/redact.ts +0 -52
  93. package/src/router-core.ts +0 -666
  94. package/src/rrf.ts +0 -31
  95. package/src/scan.ts +0 -300
  96. package/src/server.ts +0 -930
  97. package/src/setup.ts +0 -145
  98. package/src/snapshot.ts +0 -135
  99. package/src/stats.ts +0 -340
  100. package/src/sync.ts +0 -482
  101. package/src/toml-writer.ts +0 -51
  102. package/src/types.ts +0 -212
  103. 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
- }