security-mcp 1.1.0 → 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.
- package/README.md +966 -193
- package/defaults/agent-run-schema.json +98 -0
- package/dist/ci/pr-gate.js +18 -1
- package/dist/cli/install.js +69 -2
- package/dist/cli/onboarding.js +82 -11
- package/dist/cli/update.js +83 -15
- package/dist/gate/checks/ai-redteam.js +83 -59
- package/dist/gate/checks/api.js +93 -0
- package/dist/gate/checks/ci-pipeline.js +135 -0
- package/dist/gate/checks/crypto.js +91 -22
- package/dist/gate/checks/database.js +5 -1
- package/dist/gate/checks/dependencies.js +297 -2
- package/dist/gate/checks/dlp.js +6 -1
- package/dist/gate/checks/graphql.js +6 -1
- package/dist/gate/checks/k8s.js +229 -181
- package/dist/gate/checks/nuclei.js +133 -0
- package/dist/gate/checks/runtime.js +75 -8
- package/dist/gate/checks/scanners.js +8 -2
- package/dist/gate/diff.js +2 -0
- package/dist/gate/exceptions.js +6 -1
- package/dist/gate/policy.js +47 -4
- package/dist/gate/result.js +7 -1
- package/dist/mcp/audit-chain.js +253 -0
- package/dist/mcp/learning.js +228 -0
- package/dist/mcp/model-router.js +544 -0
- package/dist/mcp/orchestration.js +604 -0
- package/dist/mcp/server.js +160 -12
- package/dist/repo/search.js +5 -7
- package/dist/review/store.js +15 -0
- package/dist/types/agent-run.js +8 -0
- package/package.json +5 -5
- package/skills/_TEMPLATE/SKILL.md +99 -0
- package/skills/advanced-dos-tester/SKILL.md +225 -0
- package/skills/agentic-loop-exploiter/SKILL.md +69 -0
- package/skills/ai-llm-redteam/SKILL.md +118 -0
- package/skills/ai-model-supply-chain-agent/SKILL.md +198 -0
- package/skills/algorithm-implementation-reviewer/SKILL.md +85 -0
- package/skills/android-penetration-tester/SKILL.md +83 -0
- package/skills/anti-replay-tester/SKILL.md +195 -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/binary-auth-validator/SKILL.md +184 -0
- package/skills/bot-detection-specialist/SKILL.md +221 -0
- package/skills/business-logic-attacker/SKILL.md +76 -0
- package/skills/capec-code-mapper/SKILL.md +163 -0
- package/skills/cert-pin-rotation-specialist/SKILL.md +200 -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/compliance-lifecycle-tracker/SKILL.md +169 -0
- package/skills/credential-stuffing-specialist/SKILL.md +192 -0
- package/skills/crypto-pki-specialist/SKILL.md +136 -0
- package/skills/csa-ccm-mapper/SKILL.md +178 -0
- package/skills/csf2-governance-mapper/SKILL.md +159 -0
- package/skills/deep-link-fuzzer/SKILL.md +195 -0
- package/skills/dependency-confusion-attacker/SKILL.md +78 -0
- package/skills/device-integrity-aggregator/SKILL.md +221 -0
- package/skills/dos-resilience-tester/SKILL.md +184 -0
- package/skills/dread-scorer/SKILL.md +157 -0
- package/skills/egress-policy-enforcer/SKILL.md +208 -0
- package/skills/evidence-collector/SKILL.md +86 -0
- package/skills/file-upload-attacker/SKILL.md +208 -0
- package/skills/gcp-penetration-tester/SKILL.md +63 -0
- package/skills/git-history-secret-scanner/SKILL.md +182 -0
- package/skills/iam-privesc-graph-builder/SKILL.md +216 -0
- package/skills/incident-responder/SKILL.md +192 -0
- package/skills/injection-specialist/SKILL.md +62 -0
- package/skills/ios-security-auditor/SKILL.md +77 -0
- package/skills/json-ambiguity-tester/SKILL.md +175 -0
- package/skills/k8s-container-escaper/SKILL.md +74 -0
- package/skills/key-management-lifecycle-analyst/SKILL.md +92 -0
- package/skills/kill-switch-engineer/SKILL.md +205 -0
- package/skills/linddun-privacy-analyst/SKILL.md +196 -0
- package/skills/logic-race-fuzzer/SKILL.md +67 -0
- package/skills/mobile-api-network-attacker/SKILL.md +81 -0
- package/skills/mobile-binary-hardener/SKILL.md +199 -0
- package/skills/mobile-security-specialist/SKILL.md +124 -0
- package/skills/mobile-webview-auditor/SKILL.md +200 -0
- package/skills/model-extraction-attacker/SKILL.md +68 -0
- package/skills/multipart-abuse-tester/SKILL.md +146 -0
- package/skills/oauth-pkce-specialist/SKILL.md +191 -0
- package/skills/parser-exhaustion-tester/SKILL.md +177 -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/quantum-migration-planner/SKILL.md +184 -0
- package/skills/rag-poisoning-specialist/SKILL.md +71 -0
- package/skills/registry-mirror-enforcer/SKILL.md +142 -0
- package/skills/rotation-validation-agent/SKILL.md +188 -0
- package/skills/samm-assessor/SKILL.md +168 -0
- package/skills/secrets-mask-bypass-tester/SKILL.md +167 -0
- package/skills/senior-security-engineer/SKILL.md +42 -12
- package/skills/serialization-memory-attacker/SKILL.md +78 -0
- package/skills/session-timeout-tester/SKILL.md +197 -0
- package/skills/slsa-level3-enforcer/SKILL.md +185 -0
- package/skills/slsa-provenance-enforcer/SKILL.md +181 -0
- package/skills/ssrf-detection-validator/SKILL.md +229 -0
- package/skills/step-up-auth-enforcer/SKILL.md +176 -0
- package/skills/stride-pasta-analyst/SKILL.md +72 -0
- package/skills/supply-chain-devsecops/SKILL.md +82 -0
- package/skills/threat-infrastructure-analyst/SKILL.md +167 -0
- package/skills/threat-modeler/SKILL.md +116 -0
- package/skills/tls-certificate-auditor/SKILL.md +76 -0
- package/skills/token-reuse-detector/SKILL.md +203 -0
- package/skills/trike-risk-modeler/SKILL.md +139 -0
- package/skills/unicode-homograph-tester/SKILL.md +179 -0
- package/skills/waf-rule-lifecycle-agent/SKILL.md +213 -0
- package/skills/webhook-security-tester/SKILL.md +184 -0
- 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, "../../..");
|
|
@@ -29,7 +30,12 @@ const ScannerConfigSchema = z.object({
|
|
|
29
30
|
async function loadScannerConfig() {
|
|
30
31
|
const overridePath = process.env["SECURITY_GATE_SCANNERS"];
|
|
31
32
|
if (overridePath) {
|
|
32
|
-
|
|
33
|
+
// CWE-22: resolve to absolute path and ensure it stays within cwd
|
|
34
|
+
const resolved = resolve(process.cwd(), overridePath);
|
|
35
|
+
if (!resolved.startsWith(process.cwd() + "/") && resolved !== process.cwd()) {
|
|
36
|
+
throw new Error(`SECURITY_GATE_SCANNERS path '${overridePath}' escapes the project directory`);
|
|
37
|
+
}
|
|
38
|
+
const raw = await readFile(resolved, "utf-8");
|
|
33
39
|
return ScannerConfigSchema.parse(JSON.parse(raw));
|
|
34
40
|
}
|
|
35
41
|
try {
|
|
@@ -433,7 +439,7 @@ export async function runScanners(opts) {
|
|
|
433
439
|
allFindings.push(...res.value);
|
|
434
440
|
}
|
|
435
441
|
else {
|
|
436
|
-
console.warn(`[scanners] Scanner ${taskId} failed: ${String(res.reason)}`);
|
|
442
|
+
console.warn(`[scanners] Scanner ${taskId} failed: ${sanitizeErrorMessage(String(res.reason))}`);
|
|
437
443
|
allFindings.push({
|
|
438
444
|
id: "SCANNER_EXECUTION_ERROR",
|
|
439
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)) {
|
package/dist/gate/exceptions.js
CHANGED
|
@@ -22,7 +22,12 @@ const ExceptionFileSchema = z.object({
|
|
|
22
22
|
async function readExceptionsJson() {
|
|
23
23
|
const overridePath = process.env["SECURITY_GATE_EXCEPTIONS"];
|
|
24
24
|
if (overridePath) {
|
|
25
|
-
|
|
25
|
+
// CWE-22: ensure path stays within the project directory
|
|
26
|
+
const resolved = resolve(process.cwd(), overridePath);
|
|
27
|
+
if (!resolved.startsWith(process.cwd() + "/") && resolved !== process.cwd()) {
|
|
28
|
+
throw new Error(`SECURITY_GATE_EXCEPTIONS path '${overridePath}' escapes the project directory`);
|
|
29
|
+
}
|
|
30
|
+
return await readFile(resolved, "utf-8");
|
|
26
31
|
}
|
|
27
32
|
try {
|
|
28
33
|
return await readFile(join(process.cwd(), ".mcp", "exceptions", "security-exceptions.json"), "utf-8");
|
package/dist/gate/policy.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
}
|
package/dist/gate/result.js
CHANGED
|
@@ -1 +1,7 @@
|
|
|
1
|
-
|
|
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);
|