security-mcp 1.1.0 → 1.1.1
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/README.md +963 -193
- package/defaults/agent-run-schema.json +98 -0
- package/dist/cli/install.js +69 -2
- package/dist/cli/onboarding.js +4 -4
- package/dist/cli/update.js +83 -15
- package/dist/gate/checks/ai-redteam.js +83 -59
- package/dist/gate/checks/runtime.js +55 -2
- package/dist/gate/checks/scanners.js +6 -1
- package/dist/gate/exceptions.js +6 -1
- package/dist/mcp/orchestration.js +586 -0
- package/dist/mcp/server.js +69 -12
- package/dist/repo/search.js +5 -7
- package/dist/review/store.js +5 -0
- package/dist/types/agent-run.js +8 -0
- package/package.json +5 -5
- package/skills/agentic-loop-exploiter/SKILL.md +69 -0
- package/skills/ai-llm-redteam/SKILL.md +118 -0
- package/skills/algorithm-implementation-reviewer/SKILL.md +85 -0
- package/skills/android-penetration-tester/SKILL.md +83 -0
- package/skills/appsec-code-auditor/SKILL.md +86 -0
- package/skills/artifact-integrity-analyst/SKILL.md +68 -0
- package/skills/attack-navigator/SKILL.md +64 -0
- package/skills/auth-session-hacker/SKILL.md +87 -0
- package/skills/aws-penetration-tester/SKILL.md +60 -0
- package/skills/azure-penetration-tester/SKILL.md +64 -0
- package/skills/business-logic-attacker/SKILL.md +76 -0
- package/skills/cicd-pipeline-hijacker/SKILL.md +81 -0
- package/skills/ciso-orchestrator/SKILL.md +165 -0
- package/skills/cloud-infra-specialist/SKILL.md +85 -0
- package/skills/compliance-gap-analyst/SKILL.md +77 -0
- package/skills/compliance-grc/SKILL.md +148 -0
- package/skills/crypto-pki-specialist/SKILL.md +136 -0
- package/skills/dependency-confusion-attacker/SKILL.md +78 -0
- package/skills/evidence-collector/SKILL.md +86 -0
- package/skills/gcp-penetration-tester/SKILL.md +63 -0
- package/skills/injection-specialist/SKILL.md +62 -0
- package/skills/ios-security-auditor/SKILL.md +77 -0
- package/skills/k8s-container-escaper/SKILL.md +74 -0
- package/skills/key-management-lifecycle-analyst/SKILL.md +92 -0
- package/skills/logic-race-fuzzer/SKILL.md +67 -0
- package/skills/mobile-api-network-attacker/SKILL.md +81 -0
- package/skills/mobile-security-specialist/SKILL.md +124 -0
- package/skills/model-extraction-attacker/SKILL.md +68 -0
- package/skills/pentest-infra/SKILL.md +69 -0
- package/skills/pentest-social/SKILL.md +72 -0
- package/skills/pentest-team/SKILL.md +126 -0
- package/skills/pentest-web-api/SKILL.md +71 -0
- package/skills/privacy-flow-analyst/SKILL.md +70 -0
- package/skills/prompt-injection-specialist/SKILL.md +76 -0
- package/skills/rag-poisoning-specialist/SKILL.md +71 -0
- package/skills/senior-security-engineer/SKILL.md +42 -12
- package/skills/serialization-memory-attacker/SKILL.md +78 -0
- package/skills/stride-pasta-analyst/SKILL.md +72 -0
- package/skills/supply-chain-devsecops/SKILL.md +82 -0
- package/skills/threat-modeler/SKILL.md +116 -0
- package/skills/tls-certificate-auditor/SKILL.md +76 -0
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Orchestration MCP tools for the multi-agent security flow.
|
|
3
|
+
*
|
|
4
|
+
* These tools manage the lifecycle of an agent run:
|
|
5
|
+
* 1. orchestration.create_agent_run — initialise manifest
|
|
6
|
+
* 2. orchestration.update_agent_status — per-agent lifecycle updates
|
|
7
|
+
* 3. orchestration.merge_agent_findings — deduplicate + sort all findings
|
|
8
|
+
* 4. orchestration.ensure_skill — lazy-download a skill from registry
|
|
9
|
+
* 5. orchestration.read_agent_memory — read per-agent memory files
|
|
10
|
+
* 6. orchestration.write_agent_memory — persist per-agent memory
|
|
11
|
+
* 7. orchestration.check_updates — check npm + skills-manifest for new versions
|
|
12
|
+
* 8. orchestration.apply_updates — run auto-update (auto | manual)
|
|
13
|
+
* 9. orchestration.verify_skill_coverage — report uncovered SKILL.md sections
|
|
14
|
+
*/
|
|
15
|
+
import { createHash } from "node:crypto";
|
|
16
|
+
import * as https from "node:https";
|
|
17
|
+
import { mkdir, readFile, writeFile, readdir } from "node:fs/promises";
|
|
18
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
19
|
+
import { homedir } from "node:os";
|
|
20
|
+
import { dirname, join } from "node:path";
|
|
21
|
+
import { z } from "zod";
|
|
22
|
+
import { updateReviewStep } from "../review/store.js";
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Constants
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
const AGENT_RUNS_DIR = join(".mcp", "agent-runs");
|
|
27
|
+
const MEMORY_DIR = join(homedir(), ".security-mcp", "agent-memory");
|
|
28
|
+
const SKILL_VERSIONS_PATH = join(homedir(), ".security-mcp", "skill-versions.json");
|
|
29
|
+
const SKILLS_MANIFEST_URL = "https://raw.githubusercontent.com/AbrahamOO/security-mcp/main/skills-manifest.json";
|
|
30
|
+
const CLAUDE_SKILLS_DIR = join(homedir(), ".claude", "skills");
|
|
31
|
+
const NPM_REGISTRY_URL = "https://registry.npmjs.org/security-mcp/latest";
|
|
32
|
+
// CWE-22: input validation patterns for path components
|
|
33
|
+
const SAFE_SKILL_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
|
|
34
|
+
const SAFE_AGENT_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
|
|
35
|
+
const SAFE_AGENT_RUN_ID_RE = /^[0-9a-f]{32}$/; // hex digest produced by createAgentRun
|
|
36
|
+
// CWE-918: skill download URLs must be from the expected GitHub raw domain
|
|
37
|
+
const ALLOWED_SKILL_URL_PREFIX = "https://raw.githubusercontent.com/";
|
|
38
|
+
// CWE-400: cap on HTTP response bodies
|
|
39
|
+
const MAX_MANIFEST_BYTES = 256 * 1024; // 256 KB
|
|
40
|
+
const MAX_SKILL_BYTES = 512 * 1024; // 512 KB
|
|
41
|
+
const MAX_NPM_BYTES = 64 * 1024; // 64 KB
|
|
42
|
+
// All SKILL.md sections that must be covered per run
|
|
43
|
+
const SKILL_MD_SECTIONS = [
|
|
44
|
+
"§1", "§2", "§3", "§4", "§5", "§6", "§7", "§8",
|
|
45
|
+
"§9", "§10", "§11", "§12", "§13", "§14", "§15",
|
|
46
|
+
"§16", "§17", "§18", "§19", "§20", "§21", "§22",
|
|
47
|
+
"§23", "§24"
|
|
48
|
+
];
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Internal helpers
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
async function ensureDir(p) {
|
|
53
|
+
await mkdir(p, { recursive: true });
|
|
54
|
+
}
|
|
55
|
+
function agentRunDir(agentRunId) {
|
|
56
|
+
// CWE-22: agentRunId must be the 32-char hex digest produced by createAgentRun
|
|
57
|
+
if (!SAFE_AGENT_RUN_ID_RE.test(agentRunId)) {
|
|
58
|
+
throw new Error(`Invalid agentRunId "${agentRunId}"`);
|
|
59
|
+
}
|
|
60
|
+
return join(process.cwd(), AGENT_RUNS_DIR, agentRunId);
|
|
61
|
+
}
|
|
62
|
+
function manifestPath(agentRunId) {
|
|
63
|
+
return join(agentRunDir(agentRunId), "manifest.json");
|
|
64
|
+
}
|
|
65
|
+
async function readManifest(agentRunId) {
|
|
66
|
+
const raw = await readFile(manifestPath(agentRunId), "utf-8");
|
|
67
|
+
return JSON.parse(raw);
|
|
68
|
+
}
|
|
69
|
+
async function writeManifest(manifest) {
|
|
70
|
+
manifest.updatedAt = new Date().toISOString();
|
|
71
|
+
await writeFile(manifestPath(manifest.agentRunId), JSON.stringify(manifest, null, 2) + "\n", "utf-8");
|
|
72
|
+
}
|
|
73
|
+
function defaultAgentRecord() {
|
|
74
|
+
return {
|
|
75
|
+
status: "pending",
|
|
76
|
+
startedAt: null,
|
|
77
|
+
completedAt: null,
|
|
78
|
+
findingsPath: null,
|
|
79
|
+
summary: null
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Build the initial agent registry for this run, gated on stackContext.
|
|
84
|
+
*
|
|
85
|
+
* Always-on agents cover the universal surfaces (code, dependencies, crypto,
|
|
86
|
+
* pentest, compliance). Stack-conditional agents are only registered when the
|
|
87
|
+
* relevant technology is actually detected — this avoids spawning and loading
|
|
88
|
+
* skill files for surfaces that don't exist in the project.
|
|
89
|
+
*/
|
|
90
|
+
function buildInitialAgents(stackContext) {
|
|
91
|
+
const hasAWS = stackContext.cloudProvider.includes("aws");
|
|
92
|
+
const hasGCP = stackContext.cloudProvider.includes("gcp");
|
|
93
|
+
const hasAzure = stackContext.cloudProvider.includes("azure");
|
|
94
|
+
const hasK8s = stackContext.frameworks.includes("kubernetes") ||
|
|
95
|
+
stackContext.frameworks.includes("docker") ||
|
|
96
|
+
stackContext.frameworks.includes("helm");
|
|
97
|
+
const names = [
|
|
98
|
+
// ── Always-on: core analysis ───────────────────────────────────────────
|
|
99
|
+
"threat-modeler",
|
|
100
|
+
"stride-pasta-analyst", "attack-navigator", "business-logic-attacker",
|
|
101
|
+
"privacy-flow-analyst",
|
|
102
|
+
"appsec-code-auditor",
|
|
103
|
+
"injection-specialist", "auth-session-hacker", "logic-race-fuzzer",
|
|
104
|
+
"serialization-memory-attacker",
|
|
105
|
+
"supply-chain-devsecops",
|
|
106
|
+
"dependency-confusion-attacker", "cicd-pipeline-hijacker", "artifact-integrity-analyst",
|
|
107
|
+
"crypto-pki-specialist",
|
|
108
|
+
"tls-certificate-auditor", "algorithm-implementation-reviewer",
|
|
109
|
+
"key-management-lifecycle-analyst",
|
|
110
|
+
// ── Always-on: cloud-infra lead (reports N/A if no cloud) ─────────────
|
|
111
|
+
"cloud-infra-specialist",
|
|
112
|
+
// ── Always-on: phase 2 ────────────────────────────────────────────────
|
|
113
|
+
"pentest-team", "pentest-web-api", "pentest-infra", "pentest-social",
|
|
114
|
+
"compliance-grc", "evidence-collector", "compliance-gap-analyst",
|
|
115
|
+
];
|
|
116
|
+
// Cloud-specific penetration testers — only when that provider is detected
|
|
117
|
+
if (hasAWS)
|
|
118
|
+
names.push("aws-penetration-tester");
|
|
119
|
+
if (hasGCP)
|
|
120
|
+
names.push("gcp-penetration-tester");
|
|
121
|
+
if (hasAzure)
|
|
122
|
+
names.push("azure-penetration-tester");
|
|
123
|
+
if (hasK8s)
|
|
124
|
+
names.push("k8s-container-escaper");
|
|
125
|
+
// AI/LLM agents — only when AI stack is detected
|
|
126
|
+
if (stackContext.hasAI) {
|
|
127
|
+
names.push("ai-llm-redteam", "prompt-injection-specialist", "model-extraction-attacker", "rag-poisoning-specialist", "agentic-loop-exploiter");
|
|
128
|
+
}
|
|
129
|
+
// Mobile agents — only when mobile surfaces are detected
|
|
130
|
+
if (stackContext.hasMobile) {
|
|
131
|
+
names.push("mobile-security-specialist", "ios-security-auditor", "android-penetration-tester", "mobile-api-network-attacker");
|
|
132
|
+
}
|
|
133
|
+
const record = {};
|
|
134
|
+
for (const name of names) {
|
|
135
|
+
record[name] = defaultAgentRecord();
|
|
136
|
+
}
|
|
137
|
+
return record;
|
|
138
|
+
}
|
|
139
|
+
function readJson(filePath, fallback) {
|
|
140
|
+
try {
|
|
141
|
+
return JSON.parse(readFileSync(filePath, "utf-8"));
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
return fallback;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function httpsGet(url, maxBytes, timeoutMs = 5000) {
|
|
148
|
+
return new Promise((resolve) => {
|
|
149
|
+
const req = https.get(url, { headers: { "User-Agent": "security-mcp" } }, (res) => {
|
|
150
|
+
if ((res.statusCode ?? 500) >= 400) {
|
|
151
|
+
res.resume();
|
|
152
|
+
resolve(null);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
let body = "";
|
|
156
|
+
res.setEncoding("utf8");
|
|
157
|
+
res.on("data", (c) => {
|
|
158
|
+
body += c;
|
|
159
|
+
// CWE-400: abort if response exceeds size cap
|
|
160
|
+
if (Buffer.byteLength(body, "utf8") > maxBytes) {
|
|
161
|
+
req.destroy();
|
|
162
|
+
resolve(null);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
res.on("end", () => resolve(body));
|
|
166
|
+
});
|
|
167
|
+
req.on("error", () => resolve(null));
|
|
168
|
+
req.setTimeout(timeoutMs, () => { req.destroy(); resolve(null); });
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
// Tool implementations
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
// 1. create_agent_run
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
export const CreateAgentRunSchema = z.object({
|
|
177
|
+
runId: z.string().uuid().describe("Review run ID from security.start_review."),
|
|
178
|
+
scope: z.object({
|
|
179
|
+
mode: z.enum(["recent_changes", "folder_by_folder", "file_by_file"]),
|
|
180
|
+
targets: z.array(z.string()).default([]),
|
|
181
|
+
baseRef: z.string().default("origin/main"),
|
|
182
|
+
headRef: z.string().default("HEAD")
|
|
183
|
+
}),
|
|
184
|
+
internetPermitted: z.boolean().default(false).describe("Whether user permitted internet access for this run."),
|
|
185
|
+
stackContext: z.object({
|
|
186
|
+
languages: z.array(z.string()).default([]),
|
|
187
|
+
frameworks: z.array(z.string()).default([]),
|
|
188
|
+
databases: z.array(z.string()).default([]),
|
|
189
|
+
cloudProvider: z.array(z.string()).default([]),
|
|
190
|
+
paymentProcessor: z.array(z.string()).default([]),
|
|
191
|
+
hasAI: z.boolean().default(false),
|
|
192
|
+
hasMobile: z.boolean().default(false),
|
|
193
|
+
hasPII: z.boolean().default(false),
|
|
194
|
+
hasPayments: z.boolean().default(false),
|
|
195
|
+
packageManagers: z.array(z.string()).default([]),
|
|
196
|
+
ciPlatform: z.array(z.string()).default([])
|
|
197
|
+
}).describe("Tech stack context derived from project scan.")
|
|
198
|
+
});
|
|
199
|
+
export async function createAgentRun(args) {
|
|
200
|
+
const { runId, scope, internetPermitted, stackContext } = args;
|
|
201
|
+
const agentRunId = createHash("sha256")
|
|
202
|
+
.update(`${runId}:${Date.now()}`)
|
|
203
|
+
.digest("hex")
|
|
204
|
+
.slice(0, 32);
|
|
205
|
+
await ensureDir(agentRunDir(agentRunId));
|
|
206
|
+
const manifest = {
|
|
207
|
+
agentRunId,
|
|
208
|
+
runId,
|
|
209
|
+
createdAt: new Date().toISOString(),
|
|
210
|
+
updatedAt: new Date().toISOString(),
|
|
211
|
+
phase: 0,
|
|
212
|
+
internetPermitted,
|
|
213
|
+
stackContext: stackContext,
|
|
214
|
+
scope,
|
|
215
|
+
agents: buildInitialAgents(stackContext)
|
|
216
|
+
};
|
|
217
|
+
await writeManifest(manifest);
|
|
218
|
+
return { agentRunId, manifestPath: manifestPath(agentRunId) };
|
|
219
|
+
}
|
|
220
|
+
// 2. update_agent_status
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
export const UpdateAgentStatusSchema = z.object({
|
|
223
|
+
agentRunId: z.string().describe("Agent run ID from orchestration.create_agent_run."),
|
|
224
|
+
agentName: z.string().describe("Name of the agent updating its status."),
|
|
225
|
+
status: z.enum(["running", "completed", "completed_partial", "failed"]),
|
|
226
|
+
findingsPath: z.string().optional().describe("Relative path to the agent findings JSON file."),
|
|
227
|
+
summary: z.string().optional().describe("One-line outcome summary.")
|
|
228
|
+
});
|
|
229
|
+
export async function updateAgentStatus(args) {
|
|
230
|
+
const { agentRunId, agentName, status, findingsPath, summary } = args;
|
|
231
|
+
const manifest = await readManifest(agentRunId);
|
|
232
|
+
const record = manifest.agents[agentName];
|
|
233
|
+
if (!record) {
|
|
234
|
+
throw new Error(`Unknown agent: ${agentName}`);
|
|
235
|
+
}
|
|
236
|
+
record.status = status;
|
|
237
|
+
if (status === "running")
|
|
238
|
+
record.startedAt = new Date().toISOString();
|
|
239
|
+
if (status === "completed" || status === "completed_partial" || status === "failed") {
|
|
240
|
+
record.completedAt = new Date().toISOString();
|
|
241
|
+
}
|
|
242
|
+
if (findingsPath)
|
|
243
|
+
record.findingsPath = findingsPath;
|
|
244
|
+
if (summary)
|
|
245
|
+
record.summary = summary;
|
|
246
|
+
// Advance phase when all phase-1 leads complete
|
|
247
|
+
const phase1Leads = [
|
|
248
|
+
"threat-modeler", "appsec-code-auditor", "cloud-infra-specialist",
|
|
249
|
+
"supply-chain-devsecops", "ai-llm-redteam", "mobile-security-specialist",
|
|
250
|
+
"crypto-pki-specialist"
|
|
251
|
+
];
|
|
252
|
+
const phase2Leads = ["pentest-team", "compliance-grc"];
|
|
253
|
+
const allPhase1Done = phase1Leads.every((n) => {
|
|
254
|
+
const s = manifest.agents[n].status;
|
|
255
|
+
return s === "completed" || s === "completed_partial" || s === "failed";
|
|
256
|
+
});
|
|
257
|
+
const allPhase2Done = phase2Leads.every((n) => {
|
|
258
|
+
const s = manifest.agents[n].status;
|
|
259
|
+
return s === "completed" || s === "completed_partial" || s === "failed";
|
|
260
|
+
});
|
|
261
|
+
if (manifest.phase === 1 && allPhase1Done)
|
|
262
|
+
manifest.phase = 2;
|
|
263
|
+
if (manifest.phase === 2 && allPhase2Done)
|
|
264
|
+
manifest.phase = 3;
|
|
265
|
+
await writeManifest(manifest);
|
|
266
|
+
return { manifest };
|
|
267
|
+
}
|
|
268
|
+
// 3. merge_agent_findings
|
|
269
|
+
// ---------------------------------------------------------------------------
|
|
270
|
+
export const MergeAgentFindingsSchema = z.object({
|
|
271
|
+
agentRunId: z.string().describe("Agent run ID."),
|
|
272
|
+
runId: z.string().uuid().describe("Review run ID — used to update the review step record.")
|
|
273
|
+
});
|
|
274
|
+
export async function mergeAgentFindings(args) {
|
|
275
|
+
const { agentRunId, runId } = args;
|
|
276
|
+
const dir = agentRunDir(agentRunId);
|
|
277
|
+
// Read all non-manifest JSON files in the agent-run directory
|
|
278
|
+
let files = [];
|
|
279
|
+
try {
|
|
280
|
+
const entries = await readdir(dir);
|
|
281
|
+
files = entries.filter((f) => f.endsWith(".json") && f !== "manifest.json" && f !== "merged-findings.json");
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
files = [];
|
|
285
|
+
}
|
|
286
|
+
const allFindings = [];
|
|
287
|
+
const agentsCovered = [];
|
|
288
|
+
const agentsPartial = [];
|
|
289
|
+
const sectionsSeen = new Set();
|
|
290
|
+
const beyondSkillMdNotes = [];
|
|
291
|
+
for (const file of files) {
|
|
292
|
+
try {
|
|
293
|
+
const raw = await readFile(join(dir, file), "utf-8");
|
|
294
|
+
const parsed = JSON.parse(raw);
|
|
295
|
+
allFindings.push(...parsed.findings);
|
|
296
|
+
if (parsed.agentName) {
|
|
297
|
+
const manifest = await readManifest(agentRunId);
|
|
298
|
+
const rec = manifest.agents[parsed.agentName];
|
|
299
|
+
if (rec?.status === "completed_partial") {
|
|
300
|
+
agentsPartial.push(parsed.agentName);
|
|
301
|
+
}
|
|
302
|
+
else {
|
|
303
|
+
agentsCovered.push(parsed.agentName);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
for (const s of (parsed.skillMdSectionsCovered ?? []))
|
|
307
|
+
sectionsSeen.add(s);
|
|
308
|
+
for (const n of (parsed.beyondSkillMd ?? []))
|
|
309
|
+
beyondSkillMdNotes.push(n);
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
// Corrupted file — skip, note partial
|
|
313
|
+
agentsPartial.push(file.replace(".json", ""));
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
// Deduplicate by id (first occurrence wins)
|
|
317
|
+
const seen = new Set();
|
|
318
|
+
const deduped = allFindings.filter((f) => {
|
|
319
|
+
if (seen.has(f.id))
|
|
320
|
+
return false;
|
|
321
|
+
seen.add(f.id);
|
|
322
|
+
return true;
|
|
323
|
+
});
|
|
324
|
+
// Sort: CRITICAL > HIGH > MEDIUM > LOW
|
|
325
|
+
const severityOrder = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3 };
|
|
326
|
+
deduped.sort((a, b) => (severityOrder[a.severity] ?? 3) - (severityOrder[b.severity] ?? 3));
|
|
327
|
+
const uncoveredSections = SKILL_MD_SECTIONS.filter((s) => !sectionsSeen.has(s));
|
|
328
|
+
const merged = {
|
|
329
|
+
agentRunId,
|
|
330
|
+
runId,
|
|
331
|
+
mergedAt: new Date().toISOString(),
|
|
332
|
+
agentsCovered,
|
|
333
|
+
agentsPartial,
|
|
334
|
+
totalFindings: deduped.length,
|
|
335
|
+
critical: deduped.filter((f) => f.severity === "CRITICAL").length,
|
|
336
|
+
high: deduped.filter((f) => f.severity === "HIGH").length,
|
|
337
|
+
medium: deduped.filter((f) => f.severity === "MEDIUM").length,
|
|
338
|
+
low: deduped.filter((f) => f.severity === "LOW").length,
|
|
339
|
+
skillMdSectionsCovered: Array.from(sectionsSeen),
|
|
340
|
+
uncoveredSections,
|
|
341
|
+
findings: deduped
|
|
342
|
+
};
|
|
343
|
+
// Write merged-findings.json
|
|
344
|
+
const mergedPath = join(dir, "merged-findings.json");
|
|
345
|
+
await writeFile(mergedPath, JSON.stringify(merged, null, 2) + "\n", "utf-8");
|
|
346
|
+
// Hook into existing attestation flow
|
|
347
|
+
const hasCritical = merged.critical > 0;
|
|
348
|
+
const hasHigh = merged.high > 0;
|
|
349
|
+
const gateStatus = hasCritical || hasHigh ? "FAIL" : "PASS";
|
|
350
|
+
await updateReviewStep(runId, "run_pr_gate", "completed", {
|
|
351
|
+
source: "multi-agent-run",
|
|
352
|
+
agentRunId,
|
|
353
|
+
agentsCovered: agentsCovered.length,
|
|
354
|
+
agentsPartial: agentsPartial.length,
|
|
355
|
+
totalFindings: merged.totalFindings,
|
|
356
|
+
critical: merged.critical,
|
|
357
|
+
high: merged.high,
|
|
358
|
+
medium: merged.medium,
|
|
359
|
+
low: merged.low,
|
|
360
|
+
uncoveredSkillMdSections: uncoveredSections,
|
|
361
|
+
gateStatus
|
|
362
|
+
});
|
|
363
|
+
return merged;
|
|
364
|
+
}
|
|
365
|
+
// 4. ensure_skill
|
|
366
|
+
// ---------------------------------------------------------------------------
|
|
367
|
+
export const EnsureSkillSchema = z.object({
|
|
368
|
+
skillName: z.string().describe("Name of the skill to ensure is installed (e.g. 'threat-modeler')."),
|
|
369
|
+
version: z.string().optional().describe("Required version; re-downloads if installed version differs.")
|
|
370
|
+
});
|
|
371
|
+
export async function ensureSkill(args) {
|
|
372
|
+
const { skillName, version: requiredVersion } = args;
|
|
373
|
+
// CWE-22: validate skillName before using it in a file path
|
|
374
|
+
if (!SAFE_SKILL_NAME_RE.test(skillName)) {
|
|
375
|
+
throw new Error(`Invalid skill name "${skillName}"`);
|
|
376
|
+
}
|
|
377
|
+
const skillPath = join(CLAUDE_SKILLS_DIR, skillName, "SKILL.md");
|
|
378
|
+
const versions = readJson(SKILL_VERSIONS_PATH, {});
|
|
379
|
+
const installed = versions[skillName];
|
|
380
|
+
const alreadyCurrent = installed &&
|
|
381
|
+
existsSync(skillPath) &&
|
|
382
|
+
(!requiredVersion || installed.version === requiredVersion);
|
|
383
|
+
if (alreadyCurrent) {
|
|
384
|
+
return { downloaded: false, version: installed.version, path: skillPath };
|
|
385
|
+
}
|
|
386
|
+
// Fetch manifest
|
|
387
|
+
const manifestRaw = await httpsGet(SKILLS_MANIFEST_URL, MAX_MANIFEST_BYTES);
|
|
388
|
+
if (!manifestRaw) {
|
|
389
|
+
throw new Error(`Cannot fetch skills manifest — check internet connection or run with internet permitted.`);
|
|
390
|
+
}
|
|
391
|
+
const manifest = JSON.parse(manifestRaw);
|
|
392
|
+
const entry = manifest.skills[skillName];
|
|
393
|
+
if (!entry) {
|
|
394
|
+
throw new Error(`Skill "${skillName}" not found in skills manifest.`);
|
|
395
|
+
}
|
|
396
|
+
// CWE-918: only allow downloads from the expected GitHub raw domain
|
|
397
|
+
if (!entry.url.startsWith(ALLOWED_SKILL_URL_PREFIX)) {
|
|
398
|
+
throw new Error(`Skill URL for "${skillName}" does not match allowed origin: ${entry.url}`);
|
|
399
|
+
}
|
|
400
|
+
// Fetch SKILL.md content
|
|
401
|
+
const content = await httpsGet(entry.url, MAX_SKILL_BYTES);
|
|
402
|
+
if (!content) {
|
|
403
|
+
throw new Error(`Failed to download SKILL.md for "${skillName}" from ${entry.url}`);
|
|
404
|
+
}
|
|
405
|
+
// Write skill
|
|
406
|
+
mkdirSync(dirname(skillPath), { recursive: true });
|
|
407
|
+
writeFileSync(skillPath, content, "utf-8");
|
|
408
|
+
// Update version cache
|
|
409
|
+
versions[skillName] = { version: entry.version, installedAt: new Date().toISOString(), path: skillPath };
|
|
410
|
+
mkdirSync(dirname(SKILL_VERSIONS_PATH), { recursive: true });
|
|
411
|
+
writeFileSync(SKILL_VERSIONS_PATH, JSON.stringify(versions, null, 2) + "\n", "utf-8");
|
|
412
|
+
return { downloaded: true, version: entry.version, path: skillPath };
|
|
413
|
+
}
|
|
414
|
+
// 5. read_agent_memory
|
|
415
|
+
// ---------------------------------------------------------------------------
|
|
416
|
+
export const ReadAgentMemorySchema = z.object({
|
|
417
|
+
agentName: z.string().describe("Agent name whose memory to read.")
|
|
418
|
+
});
|
|
419
|
+
export async function readAgentMemory(args) {
|
|
420
|
+
// CWE-22: validate agentName before using it as a directory component
|
|
421
|
+
if (!SAFE_AGENT_NAME_RE.test(args.agentName)) {
|
|
422
|
+
throw new Error(`Invalid agent name "${args.agentName}"`);
|
|
423
|
+
}
|
|
424
|
+
const dir = join(MEMORY_DIR, args.agentName);
|
|
425
|
+
const read = (file) => readJson(join(dir, file), null);
|
|
426
|
+
return {
|
|
427
|
+
patterns: read("patterns.json"),
|
|
428
|
+
falsePositives: read("false-positives.json"),
|
|
429
|
+
remediations: read("remediations.json"),
|
|
430
|
+
intel: read("intel.json"),
|
|
431
|
+
errors: read("errors.json")
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
// 6. write_agent_memory
|
|
435
|
+
// ---------------------------------------------------------------------------
|
|
436
|
+
export const WriteAgentMemorySchema = z.object({
|
|
437
|
+
agentName: z.string().describe("Agent name whose memory to update."),
|
|
438
|
+
data: z.object({
|
|
439
|
+
patterns: z.array(z.unknown()).optional(),
|
|
440
|
+
falsePositives: z.array(z.unknown()).optional(),
|
|
441
|
+
remediations: z.array(z.unknown()).optional(),
|
|
442
|
+
intel: z.unknown().optional(),
|
|
443
|
+
errors: z.array(z.unknown()).optional()
|
|
444
|
+
})
|
|
445
|
+
});
|
|
446
|
+
export async function writeAgentMemory(args) {
|
|
447
|
+
const { agentName, data } = args;
|
|
448
|
+
// CWE-22: validate agentName before using it as a directory component
|
|
449
|
+
if (!SAFE_AGENT_NAME_RE.test(agentName)) {
|
|
450
|
+
throw new Error(`Invalid agent name "${agentName}"`);
|
|
451
|
+
}
|
|
452
|
+
const dir = join(MEMORY_DIR, agentName);
|
|
453
|
+
mkdirSync(dir, { recursive: true });
|
|
454
|
+
const written = [];
|
|
455
|
+
const append = (file, newItems, existing) => {
|
|
456
|
+
if (!newItems?.length)
|
|
457
|
+
return;
|
|
458
|
+
const merged = [...existing, ...newItems];
|
|
459
|
+
const p = join(dir, file);
|
|
460
|
+
writeFileSync(p, JSON.stringify(merged, null, 2) + "\n", "utf-8");
|
|
461
|
+
written.push(p);
|
|
462
|
+
};
|
|
463
|
+
append("patterns.json", data.patterns, readJson(join(dir, "patterns.json"), []));
|
|
464
|
+
append("false-positives.json", data.falsePositives, readJson(join(dir, "false-positives.json"), []));
|
|
465
|
+
append("remediations.json", data.remediations, readJson(join(dir, "remediations.json"), []));
|
|
466
|
+
append("errors.json", data.errors, readJson(join(dir, "errors.json"), []));
|
|
467
|
+
if (data.intel !== undefined) {
|
|
468
|
+
const p = join(dir, "intel.json");
|
|
469
|
+
writeFileSync(p, JSON.stringify({ ...data.intel, fetchedAt: new Date().toISOString() }, null, 2) + "\n", "utf-8");
|
|
470
|
+
written.push(p);
|
|
471
|
+
}
|
|
472
|
+
return { written };
|
|
473
|
+
}
|
|
474
|
+
// 7. check_updates
|
|
475
|
+
// ---------------------------------------------------------------------------
|
|
476
|
+
export const CheckUpdatesSchema = z.object({
|
|
477
|
+
currentMcpVersion: z.string().describe("Currently installed security-mcp version (from package.json).")
|
|
478
|
+
});
|
|
479
|
+
export async function checkUpdates(args) {
|
|
480
|
+
const { currentMcpVersion } = args;
|
|
481
|
+
// Check npm for MCP update
|
|
482
|
+
let latestMcpVersion = null;
|
|
483
|
+
const npmRaw = await httpsGet(NPM_REGISTRY_URL, MAX_NPM_BYTES, 3000);
|
|
484
|
+
if (npmRaw) {
|
|
485
|
+
try {
|
|
486
|
+
latestMcpVersion = JSON.parse(npmRaw).version ?? null;
|
|
487
|
+
}
|
|
488
|
+
catch { /* ignore */ }
|
|
489
|
+
}
|
|
490
|
+
// Check skills manifest for skill updates
|
|
491
|
+
const skillUpdates = [];
|
|
492
|
+
const versions = readJson(SKILL_VERSIONS_PATH, {});
|
|
493
|
+
const manifestRaw = await httpsGet(SKILLS_MANIFEST_URL, MAX_MANIFEST_BYTES, 3000);
|
|
494
|
+
if (manifestRaw) {
|
|
495
|
+
try {
|
|
496
|
+
const manifest = JSON.parse(manifestRaw);
|
|
497
|
+
for (const [name, entry] of Object.entries(manifest.skills)) {
|
|
498
|
+
const current = versions[name]?.version;
|
|
499
|
+
if (current && current !== entry.version) {
|
|
500
|
+
skillUpdates.push({ skillName: name, currentVersion: current, latestVersion: entry.version });
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
catch { /* ignore */ }
|
|
505
|
+
}
|
|
506
|
+
const hasUpdate = (latestMcpVersion !== null && latestMcpVersion !== currentMcpVersion) ||
|
|
507
|
+
skillUpdates.length > 0;
|
|
508
|
+
let changelog = "";
|
|
509
|
+
if (latestMcpVersion && latestMcpVersion !== currentMcpVersion) {
|
|
510
|
+
changelog += `security-mcp: ${currentMcpVersion} → ${latestMcpVersion}\n`;
|
|
511
|
+
}
|
|
512
|
+
if (skillUpdates.length > 0) {
|
|
513
|
+
changelog += `Skills with updates: ${skillUpdates.map((s) => s.skillName).join(", ")}`;
|
|
514
|
+
}
|
|
515
|
+
return { hasUpdate, currentMcpVersion, latestMcpVersion, skillUpdates, changelog };
|
|
516
|
+
}
|
|
517
|
+
// 8. apply_updates (returns instructions for the SKILL.md to surface to user)
|
|
518
|
+
// ---------------------------------------------------------------------------
|
|
519
|
+
export const ApplyUpdatesSchema = z.object({
|
|
520
|
+
choice: z.enum(["auto", "manual"]).describe("auto = agent will run npm install command; manual = return commands for user to run."),
|
|
521
|
+
latestMcpVersion: z.string().optional().describe("Latest version to install (from check_updates)."),
|
|
522
|
+
skillUpdates: z.array(z.object({ skillName: z.string() })).optional()
|
|
523
|
+
.describe("Skills to re-download (from check_updates).")
|
|
524
|
+
});
|
|
525
|
+
export async function applyUpdates(args) {
|
|
526
|
+
const { choice, latestMcpVersion, skillUpdates } = args;
|
|
527
|
+
const commands = [];
|
|
528
|
+
if (latestMcpVersion) {
|
|
529
|
+
commands.push(`npm install -g security-mcp@${latestMcpVersion}`);
|
|
530
|
+
commands.push(`security-mcp install`);
|
|
531
|
+
}
|
|
532
|
+
if (skillUpdates?.length) {
|
|
533
|
+
commands.push(`# Re-download updated skills (handled automatically next time /ciso-orchestrator runs)`, ...skillUpdates.map((s) => `# skill: ${s.skillName} will be refreshed via orchestration.ensure_skill`));
|
|
534
|
+
}
|
|
535
|
+
const message = choice === "auto"
|
|
536
|
+
? `Run the following commands to update:\n${commands.filter((c) => !c.startsWith("#")).join("\n")}`
|
|
537
|
+
: `To update manually, run:\n${commands.join("\n")}`;
|
|
538
|
+
return { commands, message };
|
|
539
|
+
}
|
|
540
|
+
// 9. verify_skill_coverage
|
|
541
|
+
// ---------------------------------------------------------------------------
|
|
542
|
+
export const VerifySkillCoverageSchema = z.object({
|
|
543
|
+
agentRunId: z.string().describe("Agent run ID to verify coverage for.")
|
|
544
|
+
});
|
|
545
|
+
export async function verifySkillCoverage(args) {
|
|
546
|
+
const dir = agentRunDir(args.agentRunId);
|
|
547
|
+
const sectionsSeen = new Set();
|
|
548
|
+
let files = [];
|
|
549
|
+
try {
|
|
550
|
+
const entries = await readdir(dir);
|
|
551
|
+
files = entries.filter((f) => f.endsWith(".json") && f !== "manifest.json");
|
|
552
|
+
}
|
|
553
|
+
catch { /* empty */ }
|
|
554
|
+
for (const file of files) {
|
|
555
|
+
try {
|
|
556
|
+
const raw = await readFile(join(dir, file), "utf-8");
|
|
557
|
+
const parsed = JSON.parse(raw);
|
|
558
|
+
for (const s of (parsed.skillMdSectionsCovered ?? []))
|
|
559
|
+
sectionsSeen.add(s);
|
|
560
|
+
}
|
|
561
|
+
catch { /* skip */ }
|
|
562
|
+
}
|
|
563
|
+
const covered = SKILL_MD_SECTIONS.filter((s) => sectionsSeen.has(s));
|
|
564
|
+
const uncovered = SKILL_MD_SECTIONS.filter((s) => !sectionsSeen.has(s));
|
|
565
|
+
const coveragePercent = Math.round((covered.length / SKILL_MD_SECTIONS.length) * 100);
|
|
566
|
+
return {
|
|
567
|
+
covered,
|
|
568
|
+
uncovered,
|
|
569
|
+
coveragePercent,
|
|
570
|
+
status: uncovered.length === 0 ? "PASS" : "WARN"
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
// ---------------------------------------------------------------------------
|
|
574
|
+
// Export all schemas for server registration
|
|
575
|
+
// ---------------------------------------------------------------------------
|
|
576
|
+
export const orchestrationTools = {
|
|
577
|
+
createAgentRun: { schema: CreateAgentRunSchema, fn: createAgentRun },
|
|
578
|
+
updateAgentStatus: { schema: UpdateAgentStatusSchema, fn: updateAgentStatus },
|
|
579
|
+
mergeAgentFindings: { schema: MergeAgentFindingsSchema, fn: mergeAgentFindings },
|
|
580
|
+
ensureSkill: { schema: EnsureSkillSchema, fn: ensureSkill },
|
|
581
|
+
readAgentMemory: { schema: ReadAgentMemorySchema, fn: readAgentMemory },
|
|
582
|
+
writeAgentMemory: { schema: WriteAgentMemorySchema, fn: writeAgentMemory },
|
|
583
|
+
checkUpdates: { schema: CheckUpdatesSchema, fn: checkUpdates },
|
|
584
|
+
applyUpdates: { schema: ApplyUpdatesSchema, fn: applyUpdates },
|
|
585
|
+
verifySkillCoverage: { schema: VerifySkillCoverageSchema, fn: verifySkillCoverage }
|
|
586
|
+
};
|