security-mcp 1.1.1 → 1.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 (70) hide show
  1. package/README.md +4 -1
  2. package/dist/ci/pr-gate.js +18 -1
  3. package/dist/cli/onboarding.js +78 -7
  4. package/dist/gate/checks/api.js +93 -0
  5. package/dist/gate/checks/ci-pipeline.js +135 -0
  6. package/dist/gate/checks/crypto.js +91 -22
  7. package/dist/gate/checks/database.js +5 -1
  8. package/dist/gate/checks/dependencies.js +297 -2
  9. package/dist/gate/checks/dlp.js +6 -1
  10. package/dist/gate/checks/graphql.js +6 -1
  11. package/dist/gate/checks/k8s.js +229 -181
  12. package/dist/gate/checks/nuclei.js +133 -0
  13. package/dist/gate/checks/runtime.js +32 -18
  14. package/dist/gate/checks/scanners.js +2 -1
  15. package/dist/gate/diff.js +2 -0
  16. package/dist/gate/policy.js +47 -4
  17. package/dist/gate/result.js +7 -1
  18. package/dist/mcp/audit-chain.js +253 -0
  19. package/dist/mcp/learning.js +228 -0
  20. package/dist/mcp/model-router.js +544 -0
  21. package/dist/mcp/orchestration.js +22 -4
  22. package/dist/mcp/server.js +92 -1
  23. package/dist/review/store.js +10 -0
  24. package/package.json +1 -1
  25. package/skills/_TEMPLATE/SKILL.md +99 -0
  26. package/skills/advanced-dos-tester/SKILL.md +225 -0
  27. package/skills/ai-model-supply-chain-agent/SKILL.md +198 -0
  28. package/skills/anti-replay-tester/SKILL.md +195 -0
  29. package/skills/binary-auth-validator/SKILL.md +184 -0
  30. package/skills/bot-detection-specialist/SKILL.md +221 -0
  31. package/skills/capec-code-mapper/SKILL.md +163 -0
  32. package/skills/cert-pin-rotation-specialist/SKILL.md +200 -0
  33. package/skills/compliance-lifecycle-tracker/SKILL.md +169 -0
  34. package/skills/credential-stuffing-specialist/SKILL.md +192 -0
  35. package/skills/csa-ccm-mapper/SKILL.md +178 -0
  36. package/skills/csf2-governance-mapper/SKILL.md +159 -0
  37. package/skills/deep-link-fuzzer/SKILL.md +195 -0
  38. package/skills/device-integrity-aggregator/SKILL.md +221 -0
  39. package/skills/dos-resilience-tester/SKILL.md +184 -0
  40. package/skills/dread-scorer/SKILL.md +157 -0
  41. package/skills/egress-policy-enforcer/SKILL.md +208 -0
  42. package/skills/file-upload-attacker/SKILL.md +208 -0
  43. package/skills/git-history-secret-scanner/SKILL.md +182 -0
  44. package/skills/iam-privesc-graph-builder/SKILL.md +216 -0
  45. package/skills/incident-responder/SKILL.md +192 -0
  46. package/skills/json-ambiguity-tester/SKILL.md +175 -0
  47. package/skills/kill-switch-engineer/SKILL.md +205 -0
  48. package/skills/linddun-privacy-analyst/SKILL.md +196 -0
  49. package/skills/mobile-binary-hardener/SKILL.md +199 -0
  50. package/skills/mobile-webview-auditor/SKILL.md +200 -0
  51. package/skills/multipart-abuse-tester/SKILL.md +146 -0
  52. package/skills/oauth-pkce-specialist/SKILL.md +191 -0
  53. package/skills/parser-exhaustion-tester/SKILL.md +177 -0
  54. package/skills/quantum-migration-planner/SKILL.md +184 -0
  55. package/skills/registry-mirror-enforcer/SKILL.md +142 -0
  56. package/skills/rotation-validation-agent/SKILL.md +188 -0
  57. package/skills/samm-assessor/SKILL.md +168 -0
  58. package/skills/secrets-mask-bypass-tester/SKILL.md +167 -0
  59. package/skills/session-timeout-tester/SKILL.md +197 -0
  60. package/skills/slsa-level3-enforcer/SKILL.md +185 -0
  61. package/skills/slsa-provenance-enforcer/SKILL.md +181 -0
  62. package/skills/ssrf-detection-validator/SKILL.md +229 -0
  63. package/skills/step-up-auth-enforcer/SKILL.md +176 -0
  64. package/skills/threat-infrastructure-analyst/SKILL.md +167 -0
  65. package/skills/token-reuse-detector/SKILL.md +203 -0
  66. package/skills/trike-risk-modeler/SKILL.md +139 -0
  67. package/skills/unicode-homograph-tester/SKILL.md +179 -0
  68. package/skills/waf-rule-lifecycle-agent/SKILL.md +213 -0
  69. package/skills/webhook-security-tester/SKILL.md +184 -0
  70. package/skills/zero-trust-architect/SKILL.md +211 -0
@@ -9,6 +9,7 @@ import { execFile } from "node:child_process";
9
9
  import { promisify } from "node:util";
10
10
  import { tmpdir } from "node:os";
11
11
  import { z } from "zod";
12
+ import { sanitizeErrorMessage } from "../result.js";
12
13
  const execFileAsync = promisify(execFile);
13
14
  const __dirname = dirname(fileURLToPath(import.meta.url));
14
15
  const PKG_ROOT = resolve(__dirname, "../../..");
@@ -438,7 +439,7 @@ export async function runScanners(opts) {
438
439
  allFindings.push(...res.value);
439
440
  }
440
441
  else {
441
- console.warn(`[scanners] Scanner ${taskId} failed: ${String(res.reason)}`);
442
+ console.warn(`[scanners] Scanner ${taskId} failed: ${sanitizeErrorMessage(String(res.reason))}`);
442
443
  allFindings.push({
443
444
  id: "SCANNER_EXECUTION_ERROR",
444
445
  title: `Security scanner '${taskId}' failed unexpectedly`,
package/dist/gate/diff.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import { execa } from "execa";
2
2
  // Allowlist for git ref strings. Blocks option injection (e.g. --upload-pack=…)
3
3
  // and git pathspec magic characters. CWE-88 / MITRE ATT&CK T1059.
4
+ // Note: ~ and ^ are intentionally included — they are safe because { and } are NOT
5
+ // in the allowlist, which blocks ^{} tag-dereferencing and $(...) command substitution.
4
6
  const SAFE_REF_RE = /^[a-zA-Z0-9_./~^-]+$/;
5
7
  function validateRef(name, value) {
6
8
  if (!value || !SAFE_REF_RE.test(value)) {
@@ -25,6 +25,10 @@ import { runSbomChecks } from "./checks/sbom.js";
25
25
  import { runPlaybookChecks } from "./checks/playbook.js";
26
26
  import { runAiRedteamChecks } from "./checks/ai-redteam.js";
27
27
  import { runRuntimeChecks } from "./checks/runtime.js";
28
+ import { runCiPipelineChecks } from "./checks/ci-pipeline.js";
29
+ import { runNucleiChecks } from "./checks/nuclei.js";
30
+ import { getCommitHash, loadBaseline, saveBaseline, compareBaseline } from "./baseline.js";
31
+ import { randomUUID } from "node:crypto";
28
32
  const PolicySchema = z.object({
29
33
  name: z.string(),
30
34
  version: z.string(),
@@ -107,8 +111,22 @@ function classifyChangeType(files) {
107
111
  return "config";
108
112
  return "general";
109
113
  }
114
+ const SLA_MAP = {
115
+ CRITICAL: "24h",
116
+ HIGH: "7d",
117
+ MEDIUM: "30d",
118
+ LOW: "90d"
119
+ };
120
+ function assignRiskSlas(findings) {
121
+ const now = new Date().toISOString();
122
+ return findings.map((f) => ({ ...f, sla: SLA_MAP[f.severity], slaAssignedAt: now }));
123
+ }
110
124
  export async function runPrGate(opts) {
111
- const policy = await loadPolicy(opts.policyPath);
125
+ const [policy, commitHash, previousBaseline] = await Promise.all([
126
+ loadPolicy(opts.policyPath),
127
+ getCommitHash(),
128
+ loadBaseline()
129
+ ]);
112
130
  const mode = opts.mode ?? "recent_changes";
113
131
  const targets = normalizeTargets(opts.targets);
114
132
  const changedFiles = await resolveScopedFiles({
@@ -150,7 +168,9 @@ export async function runPrGate(opts) {
150
168
  runSbomChecks({ changedFiles, targets }),
151
169
  runPlaybookChecks({ changedFiles, surfaces }),
152
170
  surfaces.ai ? runAiRedteamChecks({ changedFiles }) : Promise.resolve([]),
153
- process.env["SECURITY_STAGING_URL"] ? runRuntimeChecks({ targets, changedFiles }) : Promise.resolve([])
171
+ process.env["SECURITY_STAGING_URL"] ? runRuntimeChecks({ targets, changedFiles }) : Promise.resolve([]),
172
+ runCiPipelineChecks({ changedFiles }),
173
+ process.env["SECURITY_STAGING_URL"] ? runNucleiChecks({ changedFiles }) : Promise.resolve([])
154
174
  ]);
155
175
  rawFindings = [];
156
176
  for (const result of checkResults) {
@@ -162,6 +182,7 @@ export async function runPrGate(opts) {
162
182
  }
163
183
  }
164
184
  }
185
+ rawFindings = assignRiskSlas(rawFindings);
165
186
  const toolingCoverage = catalog.controls
166
187
  .filter((control) => control.automation === "tooling" && controlApplies(control, surfaces))
167
188
  .map((control) => {
@@ -213,10 +234,28 @@ export async function runPrGate(opts) {
213
234
  : Math.round(((scannerReadiness.configured.length - scannerReadiness.missing.length) / scannerReadiness.configured.length) * 100);
214
235
  const confidenceScore = Math.max(0, Math.min(100, Math.round((automatedCoverage * 0.7) + (scannerScore * 0.3))));
215
236
  const missingControls = relevantControls.filter((control) => control.status === "missing").length;
237
+ // Baseline regression detection: compare current run against previous baseline
238
+ let baselineDiff;
239
+ if (previousBaseline) {
240
+ baselineDiff = compareBaseline({ findings: effectiveFindings, controlCoverage: controlCoverageWithExceptions, confidence: { automatedCoverage, score: 0, missingControls: 0, scannerReadiness: 0, summary: "" }, status: "PASS", policyVersion: "", evaluatedAt: "", scope: { changedFiles, surfaces } }, previousBaseline);
241
+ if (baselineDiff.regressions.length > 0) {
242
+ const regressionFindings = baselineDiff.regressions.map((r) => ({
243
+ id: "BASELINE_REGRESSION",
244
+ title: `Security regression: control "${r.controlId}" was previously satisfied but is now missing`,
245
+ severity: "HIGH",
246
+ evidence: [`Control ${r.controlId}: "satisfied" → "missing" since last gate run`],
247
+ requiredActions: [
248
+ `Restore control "${r.controlId}" to a satisfied state.`,
249
+ "Investigate what change caused this regression and revert or remediate."
250
+ ]
251
+ }));
252
+ effectiveFindings = [...regressionFindings, ...effectiveFindings];
253
+ }
254
+ }
216
255
  const status = effectiveFindings.some((f) => f.severity === "HIGH" || f.severity === "CRITICAL")
217
256
  ? "FAIL"
218
257
  : "PASS";
219
- return {
258
+ const result = {
220
259
  status,
221
260
  policyVersion: policy.version,
222
261
  evaluatedAt: new Date().toISOString(),
@@ -235,6 +274,10 @@ export async function runPrGate(opts) {
235
274
  riskAcceptedControls,
236
275
  scannerReadiness: scannerScore,
237
276
  summary: `Automated coverage ${automatedCoverage}%, scanner readiness ${scannerScore}%, missing controls ${missingControls}, risk-accepted controls ${riskAcceptedControls}. Change type: ${changeType}.`
238
- }
277
+ },
278
+ baselineDiff
239
279
  };
280
+ // Persist as new baseline — fire-and-forget, never blocks the gate result
281
+ saveBaseline(randomUUID(), result, commitHash).catch(() => { });
282
+ return result;
240
283
  }
@@ -1 +1,7 @@
1
- export {};
1
+ // CWE-209: strip absolute file system paths from error messages before logging
2
+ // to prevent leaking internal directory structure to observers of stderr/stdout.
3
+ export function sanitizeErrorMessage(msg) {
4
+ return msg
5
+ .replace(/\/[^\s:'"]+/g, "[path]") // Unix: /foo/bar/baz
6
+ .replace(/[A-Za-z]:\\[^\s:'"]+/g, "[path]"); // Windows: C:\Users\...
7
+ }
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Audit Chain — per-agent attestation with SHA-256 hash chaining.
3
+ *
4
+ * Each agent that completes work on an agent run produces an AttestationRecord
5
+ * that:
6
+ * 1. Hashes the agent's findings output
7
+ * 2. Includes the hash of the previous link in the chain (parent hash)
8
+ * 3. Signs both together to produce a chain hash
9
+ *
10
+ * This creates a tamper-evident audit log: if any prior attestation is modified,
11
+ * all subsequent chain hashes become invalid and `verifyChain()` will detect it.
12
+ *
13
+ * Chain is persisted to .mcp/agent-runs/{agentRunId}/attestation-chain.json.
14
+ *
15
+ * The genesis block (link 0) contains only the agentRunId and a timestamp —
16
+ * its parent hash is all-zeros.
17
+ */
18
+ import { createHash } from "node:crypto";
19
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
20
+ import { join } from "node:path";
21
+ import { z } from "zod";
22
+ // ---------------------------------------------------------------------------
23
+ // Constants
24
+ // ---------------------------------------------------------------------------
25
+ const AGENT_RUNS_DIR = join(".mcp", "agent-runs");
26
+ const GENESIS_PARENT_HASH = "0".repeat(64);
27
+ // CWE-22: agentRunId used as a path component — must be the 32-char hex digest
28
+ // produced by orchestration.createAgentRun, or a UUID (36-char with hyphens).
29
+ const SAFE_AGENT_RUN_ID_RE = /^[0-9a-f]{32}$|^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
30
+ function validateAgentRunId(agentRunId) {
31
+ if (!agentRunId || !SAFE_AGENT_RUN_ID_RE.test(agentRunId)) {
32
+ throw new Error(`Invalid agentRunId "${agentRunId}" — must be a 32-char hex digest or UUID`);
33
+ }
34
+ }
35
+ // ---------------------------------------------------------------------------
36
+ // Hash helpers
37
+ // ---------------------------------------------------------------------------
38
+ function sha256(data) {
39
+ return createHash("sha256").update(data, "utf-8").digest("hex");
40
+ }
41
+ function hashFindings(findings) {
42
+ return sha256(JSON.stringify(findings));
43
+ }
44
+ function computeChainHash(record) {
45
+ const payload = [
46
+ record.agentRunId,
47
+ record.agentName,
48
+ record.completedAt,
49
+ record.findingsHash,
50
+ record.parentHash
51
+ ].join("|");
52
+ return sha256(payload);
53
+ }
54
+ // ---------------------------------------------------------------------------
55
+ // Storage helpers
56
+ // ---------------------------------------------------------------------------
57
+ async function ensureRunDir(agentRunId) {
58
+ const dir = join(AGENT_RUNS_DIR, agentRunId);
59
+ await mkdir(dir, { recursive: true });
60
+ }
61
+ function chainPath(agentRunId) {
62
+ return join(AGENT_RUNS_DIR, agentRunId, "attestation-chain.json");
63
+ }
64
+ async function loadChain(agentRunId) {
65
+ validateAgentRunId(agentRunId); // CWE-22: guard before any path operation
66
+ try {
67
+ const raw = await readFile(chainPath(agentRunId), "utf-8");
68
+ return JSON.parse(raw);
69
+ }
70
+ catch {
71
+ const now = new Date().toISOString();
72
+ return { agentRunId, createdAt: now, updatedAt: now, links: [] };
73
+ }
74
+ }
75
+ async function saveChain(chain) {
76
+ await ensureRunDir(chain.agentRunId);
77
+ chain.updatedAt = new Date().toISOString();
78
+ await writeFile(chainPath(chain.agentRunId), JSON.stringify(chain, null, 2) + "\n", "utf-8");
79
+ }
80
+ // ---------------------------------------------------------------------------
81
+ // Core functions
82
+ // ---------------------------------------------------------------------------
83
+ /**
84
+ * Initialise the attestation chain for a new agent run.
85
+ * Creates the genesis block (link 0) with all-zero parent hash.
86
+ * Idempotent — returns the existing chain if already initialised.
87
+ */
88
+ export async function initChain(agentRunId) {
89
+ const chain = await loadChain(agentRunId);
90
+ if (chain.links.length > 0)
91
+ return chain; // already initialised
92
+ const completedAt = new Date().toISOString();
93
+ const genesis = {
94
+ link: 0,
95
+ agentRunId,
96
+ agentName: "genesis",
97
+ completedAt,
98
+ findingsHash: sha256("genesis:" + agentRunId),
99
+ parentHash: GENESIS_PARENT_HASH,
100
+ findingCount: 0,
101
+ criticalCount: 0,
102
+ highCount: 0
103
+ };
104
+ const record = {
105
+ ...genesis,
106
+ chainHash: computeChainHash(genesis)
107
+ };
108
+ chain.links.push(record);
109
+ await saveChain(chain);
110
+ return chain;
111
+ }
112
+ /**
113
+ * Append a new attestation to the chain for the named agent.
114
+ * The parent hash is taken from the last link already in the chain.
115
+ * If the chain hasn't been initialised, `initChain` is called first.
116
+ */
117
+ export async function attestAgent(params) {
118
+ const chain = await loadChain(params.agentRunId);
119
+ if (chain.links.length === 0) {
120
+ await initChain(params.agentRunId);
121
+ return attestAgent(params); // retry after init
122
+ }
123
+ // length === 0 is already guarded above; this satisfies TypeScript narrowing
124
+ const parent = chain.links.at(-1) ?? chain.links[0];
125
+ const completedAt = new Date().toISOString();
126
+ const partial = {
127
+ link: parent.link + 1,
128
+ agentRunId: params.agentRunId,
129
+ agentName: params.agentName,
130
+ completedAt,
131
+ findingsHash: hashFindings(params.findings),
132
+ parentHash: parent.chainHash,
133
+ findingCount: params.findings.length,
134
+ criticalCount: params.findings.filter((f) => f.severity === "CRITICAL").length,
135
+ highCount: params.findings.filter((f) => f.severity === "HIGH").length
136
+ };
137
+ const record = {
138
+ ...partial,
139
+ chainHash: computeChainHash(partial)
140
+ };
141
+ chain.links.push(record);
142
+ await saveChain(chain);
143
+ return record;
144
+ }
145
+ /**
146
+ * Verify the integrity of the entire attestation chain for an agent run.
147
+ * Recomputes every chain hash from scratch and checks parent linkage.
148
+ * Returns `valid: true` only if every link is intact.
149
+ */
150
+ export async function verifyChain(agentRunId) {
151
+ const chain = await loadChain(agentRunId);
152
+ const verifiedAt = new Date().toISOString();
153
+ if (chain.links.length === 0) {
154
+ return {
155
+ agentRunId,
156
+ valid: false,
157
+ linkCount: 0,
158
+ verifiedAt,
159
+ broken: {
160
+ linkIndex: 0,
161
+ agentName: "genesis",
162
+ reason: "Chain is empty — no genesis block found."
163
+ }
164
+ };
165
+ }
166
+ // Verify genesis parent hash
167
+ if (chain.links[0].parentHash !== GENESIS_PARENT_HASH) {
168
+ return {
169
+ agentRunId,
170
+ valid: false,
171
+ linkCount: chain.links.length,
172
+ verifiedAt,
173
+ broken: {
174
+ linkIndex: 0,
175
+ agentName: chain.links[0].agentName,
176
+ reason: "Genesis block has non-zero parent hash — chain has been tampered."
177
+ }
178
+ };
179
+ }
180
+ for (let i = 0; i < chain.links.length; i++) {
181
+ const link = chain.links[i];
182
+ // Recompute chain hash
183
+ const { chainHash: _stored, ...rest } = link;
184
+ const recomputed = computeChainHash(rest);
185
+ if (recomputed !== link.chainHash) {
186
+ return {
187
+ agentRunId,
188
+ valid: false,
189
+ linkCount: chain.links.length,
190
+ verifiedAt,
191
+ broken: {
192
+ linkIndex: i,
193
+ agentName: link.agentName,
194
+ reason: `Chain hash mismatch at link ${i} — findings or metadata may have been modified.`
195
+ }
196
+ };
197
+ }
198
+ // Verify parent linkage
199
+ if (i > 0 && link.parentHash !== chain.links[i - 1].chainHash) {
200
+ return {
201
+ agentRunId,
202
+ valid: false,
203
+ linkCount: chain.links.length,
204
+ verifiedAt,
205
+ broken: {
206
+ linkIndex: i,
207
+ agentName: link.agentName,
208
+ reason: `Parent hash at link ${i} does not match chain hash of link ${i - 1} — chain is broken.`
209
+ }
210
+ };
211
+ }
212
+ }
213
+ return {
214
+ agentRunId,
215
+ valid: true,
216
+ linkCount: chain.links.length,
217
+ verifiedAt,
218
+ broken: null
219
+ };
220
+ }
221
+ /**
222
+ * Read the attestation chain for inspection (without verification).
223
+ */
224
+ export async function getChain(agentRunId) {
225
+ return loadChain(agentRunId);
226
+ }
227
+ // ---------------------------------------------------------------------------
228
+ // Zod schemas for MCP tool params
229
+ // ---------------------------------------------------------------------------
230
+ export const InitChainParams = {
231
+ agentRunId: z.string().min(1).max(128).describe("Agent run ID to initialise the attestation chain for.")
232
+ };
233
+ export const InitChainSchema = z.object(InitChainParams);
234
+ export const AttestAgentParams = {
235
+ agentRunId: z.string().min(1).max(128).describe("Agent run ID this attestation belongs to."),
236
+ agentName: z.string().min(1).max(128).describe("Name of the agent completing its work."),
237
+ findings: z.array(z.object({
238
+ id: z.string(),
239
+ title: z.string(),
240
+ severity: z.enum(["LOW", "MEDIUM", "HIGH", "CRITICAL"]),
241
+ remediated: z.boolean(),
242
+ requiredActions: z.array(z.string())
243
+ }).passthrough()).describe("Agent findings to attest. Must match AgentFinding shape.")
244
+ };
245
+ export const AttestAgentSchema = z.object(AttestAgentParams);
246
+ export const VerifyChainParams = {
247
+ agentRunId: z.string().min(1).max(128).describe("Agent run ID whose chain should be verified.")
248
+ };
249
+ export const VerifyChainSchema = z.object(VerifyChainParams);
250
+ export const GetChainParams = {
251
+ agentRunId: z.string().min(1).max(128).describe("Agent run ID to retrieve the attestation chain for.")
252
+ };
253
+ export const GetChainSchema = z.object(GetChainParams);
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Learning Engine — pattern memory and agent routing.
3
+ *
4
+ * Tracks which agents resolve which finding types most successfully.
5
+ * Routes future findings to the highest-performing agent automatically.
6
+ * Persists to .mcp/memory/patterns.json (per-project, gitignore-safe).
7
+ */
8
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
9
+ import { join } from "node:path";
10
+ import { z } from "zod";
11
+ // ---------------------------------------------------------------------------
12
+ // Constants
13
+ // ---------------------------------------------------------------------------
14
+ const MEMORY_DIR = join(".mcp", "memory");
15
+ const PATTERNS_FILE = join(MEMORY_DIR, "patterns.json");
16
+ const MIN_SAMPLE_SIZE = 3; // need ≥3 outcomes before routing is trusted
17
+ const HIGH_CONFIDENCE = 0.85; // route automatically above this success rate
18
+ const LOW_CONFIDENCE = 0.40; // escalate below this success rate
19
+ // ---------------------------------------------------------------------------
20
+ // Schemas
21
+ // ---------------------------------------------------------------------------
22
+ export const OutcomeSchema = z.object({
23
+ findingId: z.string().min(1).max(128).regex(/^[A-Z][A-Z0-9_]{0,127}$/, "findingId must be SCREAMING_SNAKE_CASE"),
24
+ agentName: z.string().min(1).max(128),
25
+ resolved: z.boolean(),
26
+ falsePositive: z.boolean().default(false),
27
+ remediationTemplate: z.string().max(512).optional(),
28
+ durationMs: z.number().int().min(0).optional()
29
+ });
30
+ // ---------------------------------------------------------------------------
31
+ // Storage helpers
32
+ // ---------------------------------------------------------------------------
33
+ async function ensureMemoryDir() {
34
+ await mkdir(MEMORY_DIR, { recursive: true });
35
+ }
36
+ async function loadStore() {
37
+ try {
38
+ const raw = await readFile(PATTERNS_FILE, "utf-8");
39
+ return JSON.parse(raw);
40
+ }
41
+ catch {
42
+ return { version: 1, updatedAt: new Date().toISOString(), patterns: {} };
43
+ }
44
+ }
45
+ async function saveStore(store) {
46
+ await ensureMemoryDir();
47
+ store.updatedAt = new Date().toISOString();
48
+ await writeFile(PATTERNS_FILE, JSON.stringify(store, null, 2) + "\n", "utf-8");
49
+ }
50
+ // ---------------------------------------------------------------------------
51
+ // Core functions
52
+ // ---------------------------------------------------------------------------
53
+ /**
54
+ * Record the outcome of an agent resolving (or failing to resolve) a finding.
55
+ * Called after every agent completes work on a specific finding.
56
+ */
57
+ export async function recordOutcome(outcome) {
58
+ const validated = OutcomeSchema.parse(outcome);
59
+ const store = await loadStore();
60
+ const existing = store.patterns[validated.findingId] ?? {
61
+ findingId: validated.findingId,
62
+ bestAgent: validated.agentName,
63
+ sampleSize: 0,
64
+ successRate: 0,
65
+ falsePositiveRate: 0,
66
+ avgDurationMs: 0,
67
+ remediationTemplate: "",
68
+ lastSeen: new Date().toISOString(),
69
+ agentStats: {}
70
+ };
71
+ // Update agent-specific stats
72
+ const agentStat = existing.agentStats[validated.agentName] ?? {
73
+ attempts: 0,
74
+ successes: 0,
75
+ falsePositives: 0,
76
+ totalDurationMs: 0,
77
+ remediationTemplates: []
78
+ };
79
+ agentStat.attempts += 1;
80
+ if (validated.resolved && !validated.falsePositive)
81
+ agentStat.successes += 1;
82
+ if (validated.falsePositive)
83
+ agentStat.falsePositives += 1;
84
+ if (validated.durationMs)
85
+ agentStat.totalDurationMs += validated.durationMs;
86
+ if (validated.remediationTemplate && !agentStat.remediationTemplates.includes(validated.remediationTemplate)) {
87
+ agentStat.remediationTemplates.push(validated.remediationTemplate);
88
+ }
89
+ existing.agentStats[validated.agentName] = agentStat;
90
+ // Recompute aggregate stats
91
+ let totalAttempts = 0;
92
+ let totalSuccesses = 0;
93
+ let totalFalsePositives = 0;
94
+ let totalDuration = 0;
95
+ let bestAgentName = validated.agentName;
96
+ let bestRate = 0;
97
+ for (const [name, stat] of Object.entries(existing.agentStats)) {
98
+ totalAttempts += stat.attempts;
99
+ totalSuccesses += stat.successes;
100
+ totalFalsePositives += stat.falsePositives;
101
+ totalDuration += stat.totalDurationMs;
102
+ const rate = stat.attempts > 0 ? stat.successes / stat.attempts : 0;
103
+ if (rate > bestRate || (rate === bestRate && stat.attempts > (existing.agentStats[bestAgentName]?.attempts ?? 0))) {
104
+ bestRate = rate;
105
+ bestAgentName = name;
106
+ }
107
+ }
108
+ // Best remediation template comes from the best agent
109
+ const bestStat = existing.agentStats[bestAgentName];
110
+ const template = bestStat?.remediationTemplates[0] ?? existing.remediationTemplate;
111
+ const updated = {
112
+ ...existing,
113
+ bestAgent: bestAgentName,
114
+ sampleSize: totalAttempts,
115
+ successRate: totalAttempts > 0 ? totalSuccesses / totalAttempts : 0,
116
+ falsePositiveRate: totalAttempts > 0 ? totalFalsePositives / totalAttempts : 0,
117
+ avgDurationMs: totalAttempts > 0 ? Math.round(totalDuration / totalAttempts) : 0,
118
+ remediationTemplate: template,
119
+ lastSeen: new Date().toISOString(),
120
+ agentStats: existing.agentStats
121
+ };
122
+ store.patterns[validated.findingId] = updated;
123
+ await saveStore(store);
124
+ return { recorded: true, pattern: updated };
125
+ }
126
+ /**
127
+ * Get the routing recommendation for a finding type.
128
+ * Returns which agent to use, or signals escalation if confidence is low.
129
+ */
130
+ export async function getRouting(findingId) {
131
+ if (!findingId || !/^[A-Z][A-Z0-9_]{0,127}$/.test(findingId)) {
132
+ return {
133
+ findingId,
134
+ recommendation: "insufficient_data",
135
+ bestAgent: null,
136
+ successRate: null,
137
+ sampleSize: 0,
138
+ reason: "Invalid findingId format — no routing data available."
139
+ };
140
+ }
141
+ const store = await loadStore();
142
+ const pattern = store.patterns[findingId];
143
+ if (!pattern || pattern.sampleSize < MIN_SAMPLE_SIZE) {
144
+ return {
145
+ findingId,
146
+ recommendation: "insufficient_data",
147
+ bestAgent: null,
148
+ successRate: null,
149
+ sampleSize: pattern?.sampleSize ?? 0,
150
+ reason: `Fewer than ${MIN_SAMPLE_SIZE} outcomes recorded — using standard agent selection.`
151
+ };
152
+ }
153
+ if (pattern.successRate >= HIGH_CONFIDENCE) {
154
+ return {
155
+ findingId,
156
+ recommendation: "route",
157
+ bestAgent: pattern.bestAgent,
158
+ successRate: pattern.successRate,
159
+ sampleSize: pattern.sampleSize,
160
+ reason: `${Math.round(pattern.successRate * 100)}% success rate across ${pattern.sampleSize} runs — routing to ${pattern.bestAgent}.`
161
+ };
162
+ }
163
+ if (pattern.successRate < LOW_CONFIDENCE) {
164
+ return {
165
+ findingId,
166
+ recommendation: "escalate",
167
+ bestAgent: pattern.bestAgent,
168
+ successRate: pattern.successRate,
169
+ sampleSize: pattern.sampleSize,
170
+ reason: `Low success rate (${Math.round(pattern.successRate * 100)}%) — escalate to senior-security-engineer or manual review.`
171
+ };
172
+ }
173
+ return {
174
+ findingId,
175
+ recommendation: "route",
176
+ bestAgent: pattern.bestAgent,
177
+ successRate: pattern.successRate,
178
+ sampleSize: pattern.sampleSize,
179
+ reason: `Moderate confidence (${Math.round(pattern.successRate * 100)}%) — routing to ${pattern.bestAgent} with monitoring.`
180
+ };
181
+ }
182
+ /**
183
+ * Generate a full report of learned patterns and agent performance.
184
+ */
185
+ export async function getPatternReport() {
186
+ const store = await loadStore();
187
+ const patterns = Object.values(store.patterns);
188
+ const agentMap = new Map();
189
+ for (const p of patterns) {
190
+ const existing = agentMap.get(p.bestAgent) ?? { count: 0, totalRate: 0 };
191
+ agentMap.set(p.bestAgent, {
192
+ count: existing.count + 1,
193
+ totalRate: existing.totalRate + p.successRate
194
+ });
195
+ }
196
+ const topAgents = Array.from(agentMap.entries())
197
+ .map(([agentName, stats]) => ({
198
+ agentName,
199
+ findingsCovered: stats.count,
200
+ avgSuccessRate: stats.count > 0 ? stats.totalRate / stats.count : 0
201
+ }))
202
+ .sort((a, b) => b.findingsCovered - a.findingsCovered)
203
+ .slice(0, 10);
204
+ return {
205
+ totalPatterns: patterns.length,
206
+ highConfidence: patterns.filter((p) => p.successRate >= HIGH_CONFIDENCE && p.sampleSize >= MIN_SAMPLE_SIZE).length,
207
+ lowConfidence: patterns.filter((p) => p.successRate < LOW_CONFIDENCE && p.sampleSize >= MIN_SAMPLE_SIZE).length,
208
+ insufficientData: patterns.filter((p) => p.sampleSize < MIN_SAMPLE_SIZE).length,
209
+ topAgents,
210
+ patterns: patterns.sort((a, b) => b.sampleSize - a.sampleSize)
211
+ };
212
+ }
213
+ // ---------------------------------------------------------------------------
214
+ // Zod schemas for MCP tool params
215
+ // ---------------------------------------------------------------------------
216
+ export const RecordOutcomeParams = {
217
+ findingId: z.string().min(1).max(128).describe("Finding ID in SCREAMING_SNAKE_CASE (e.g. CI_UNPINNED_ACTION)."),
218
+ agentName: z.string().min(1).max(128).describe("Name of the agent that worked on this finding."),
219
+ resolved: z.boolean().describe("True if the finding was successfully remediated."),
220
+ falsePositive: z.boolean().optional().describe("True if this was a false positive. Default false."),
221
+ remediationTemplate: z.string().max(512).optional().describe("One-line description of what was done to fix it."),
222
+ durationMs: z.number().int().min(0).optional().describe("Time taken to resolve in milliseconds.")
223
+ };
224
+ export const RecordOutcomeSchema = z.object(RecordOutcomeParams);
225
+ export const GetRoutingParams = {
226
+ findingId: z.string().min(1).max(128).describe("Finding ID to look up routing recommendation for.")
227
+ };
228
+ export const GetRoutingSchema = z.object(GetRoutingParams);