@skaleagents/swarm 0.6.18 → 0.6.20
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 +4 -3
- package/dist/application/tools.js +12 -4
- package/dist/http.js +65 -46
- package/dist/iac/parse.js +52 -26
- package/dist/review.js +2 -2
- package/dist/saved-review.d.ts +36 -0
- package/dist/saved-review.js +177 -0
- package/dist/server.js +21 -9
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -113,9 +113,10 @@ in every result. An empty finding list is not proof that a system is secure.
|
|
|
113
113
|
The web app hosts `https://skaleagents.com/mcp` using `handleMcpRequest` from
|
|
114
114
|
`@skaleagents/swarm/http`. Audit workers import `scanIac` from
|
|
115
115
|
`@skaleagents/swarm/scan`. It validates each bearer credential with the API's
|
|
116
|
-
`/api/oauth/mcp-token` endpoint before running a tool.
|
|
117
|
-
|
|
118
|
-
|
|
116
|
+
`/api/oauth/mcp-token` endpoint before running a tool. A successful check is
|
|
117
|
+
reused for up to 60 seconds, so a revoked token can run tools for up to a
|
|
118
|
+
minute. Tokens are bound to the MCP resource and cannot access unrelated API
|
|
119
|
+
routes. Credentials are not forwarded to the public bot directory.
|
|
119
120
|
|
|
120
121
|
`protectedResourceMetadata` exports the discovery response for
|
|
121
122
|
`/.well-known/oauth-protected-resource/mcp`.
|
|
@@ -3,14 +3,16 @@ import { requireApiAuth, unauthorizedContent } from "../auth.js";
|
|
|
3
3
|
import { ScanInputError } from "../iac/parse.js";
|
|
4
4
|
import { contextSchema, fileSchema, planReview, consultationInstructions, } from "./intake.js";
|
|
5
5
|
import { reviewApplication } from "./review.js";
|
|
6
|
-
|
|
6
|
+
import { applicationTargetSchema, withSavedReview } from "../saved-review.js";
|
|
7
|
+
async function run(remote, compute, save) {
|
|
7
8
|
if (!remote) {
|
|
8
9
|
const auth = await requireApiAuth();
|
|
9
10
|
if (!auth.ok)
|
|
10
11
|
return unauthorizedContent(auth);
|
|
11
12
|
}
|
|
12
13
|
try {
|
|
13
|
-
const
|
|
14
|
+
const computed = compute();
|
|
15
|
+
const output = save ? await withSavedReview(computed, save) : computed;
|
|
14
16
|
return {
|
|
15
17
|
content: [
|
|
16
18
|
{ type: "text", text: JSON.stringify(output, null, 2) },
|
|
@@ -58,7 +60,8 @@ export function registerConsultation(server, remote) {
|
|
|
58
60
|
server.registerTool("review_application_architecture", {
|
|
59
61
|
title: "Review whole-application architecture",
|
|
60
62
|
description: "Review related source files as an application. Builds a JS/TS import graph, checks Next.js server/client and runtime boundaries, maps routes and mutations, and returns evidence-backed findings plus questions covering data, auth, reliability, tests, deployment and product fit. Pass answers in context on subsequent calls. No repository fetch or execution; the connected assistant reasons about system-level tradeoffs using the returned evidence.",
|
|
61
|
-
|
|
63
|
+
// Saves a review to the account when authorized, so it is not read-only.
|
|
64
|
+
annotations: { ...annotations, readOnlyHint: false },
|
|
62
65
|
_meta: security,
|
|
63
66
|
inputSchema: {
|
|
64
67
|
files: z
|
|
@@ -67,8 +70,13 @@ export function registerConsultation(server, remote) {
|
|
|
67
70
|
.max(80)
|
|
68
71
|
.describe("Related source/config/test files with repository-relative paths; include package.json and tsconfig.json. At most 500,000 combined content characters"),
|
|
69
72
|
context: contextSchema,
|
|
73
|
+
target: applicationTargetSchema,
|
|
70
74
|
},
|
|
71
|
-
}, ({ files, context }) => run(remote, () => reviewApplication(files, context)
|
|
75
|
+
}, ({ files, context, target }) => run(remote, () => reviewApplication(files, context), {
|
|
76
|
+
tool: "review_application_architecture",
|
|
77
|
+
target: target ?? null,
|
|
78
|
+
submittedPaths: files.map((file) => file.path),
|
|
79
|
+
}));
|
|
72
80
|
server.registerPrompt("architecture_consultation", {
|
|
73
81
|
title: "Independent application architecture consultation",
|
|
74
82
|
description: "Guide a multi-turn whole-application review with source evidence, follow-up questions, and design tradeoffs.",
|
package/dist/http.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
2
3
|
import { createServer } from "./server.js";
|
|
3
4
|
import { getApiUrl } from "./config.js";
|
|
5
|
+
import { requestAuthorization } from "./saved-review.js";
|
|
4
6
|
const resource = process.env.MCP_RESOURCE_URL ?? "https://skaleagents.com/mcp";
|
|
5
7
|
const metadataUrl = `${new URL(resource).origin}/.well-known/oauth-protected-resource/mcp`;
|
|
6
8
|
const allowedOrigins = new Set([
|
|
@@ -36,6 +38,16 @@ export function protectedResourceMetadata() {
|
|
|
36
38
|
resource_documentation: "https://skaleagents.com/mcp-config",
|
|
37
39
|
});
|
|
38
40
|
}
|
|
41
|
+
// Revocation can take up to one TTL to take effect for an already-validated token.
|
|
42
|
+
const validationTtlMs = 60_000;
|
|
43
|
+
const validationCacheLimit = 10_000;
|
|
44
|
+
const validatedUntil = new Map();
|
|
45
|
+
function rememberValidation(key, until) {
|
|
46
|
+
// Map keeps insertion order, so the first key is the oldest entry to evict.
|
|
47
|
+
if (!validatedUntil.has(key) && validatedUntil.size >= validationCacheLimit)
|
|
48
|
+
validatedUntil.delete(validatedUntil.keys().next().value);
|
|
49
|
+
validatedUntil.set(key, until);
|
|
50
|
+
}
|
|
39
51
|
export async function handleMcpRequest(request) {
|
|
40
52
|
const origin = request.headers.get("origin");
|
|
41
53
|
if (origin && !allowedOrigins.has(origin))
|
|
@@ -45,30 +57,35 @@ export async function handleMcpRequest(request) {
|
|
|
45
57
|
const authorization = request.headers.get("authorization");
|
|
46
58
|
if (!authorization || !/^Bearer [^\s]+$/i.test(authorization))
|
|
47
59
|
return challenge();
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
60
|
+
// Cache only successful validations, keyed by a digest so raw tokens are not kept.
|
|
61
|
+
const tokenKey = createHash("sha256").update(authorization.slice(7)).digest("hex");
|
|
62
|
+
if ((validatedUntil.get(tokenKey) ?? 0) <= Date.now()) {
|
|
63
|
+
try {
|
|
64
|
+
// Validate the opaque token with its issuer, including the MCP audience.
|
|
65
|
+
const validation = await fetch(`${getApiUrl()}/api/oauth/mcp-token`, {
|
|
66
|
+
headers: { Authorization: authorization, Accept: "application/json" },
|
|
67
|
+
signal: AbortSignal.timeout(10_000),
|
|
68
|
+
redirect: "error",
|
|
69
|
+
cache: "no-store",
|
|
70
|
+
});
|
|
71
|
+
if (validation.status === 401 || validation.status === 403)
|
|
72
|
+
return challenge();
|
|
73
|
+
if (!validation.ok)
|
|
74
|
+
return json(503, { error: "authorization_unavailable" });
|
|
75
|
+
const token = (await validation.json());
|
|
76
|
+
if (!token.active ||
|
|
77
|
+
token.resource !== resource ||
|
|
78
|
+
token.scope !== "mcp" ||
|
|
79
|
+
!token.expiresAt ||
|
|
80
|
+
token.expiresAt * 1000 <= Date.now()) {
|
|
81
|
+
return challenge();
|
|
82
|
+
}
|
|
83
|
+
rememberValidation(tokenKey, Math.min(Date.now() + validationTtlMs, token.expiresAt * 1000));
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
59
86
|
return json(503, { error: "authorization_unavailable" });
|
|
60
|
-
const token = (await validation.json());
|
|
61
|
-
if (!token.active ||
|
|
62
|
-
token.resource !== resource ||
|
|
63
|
-
token.scope !== "mcp" ||
|
|
64
|
-
!token.expiresAt ||
|
|
65
|
-
token.expiresAt * 1000 <= Date.now()) {
|
|
66
|
-
return challenge();
|
|
67
87
|
}
|
|
68
88
|
}
|
|
69
|
-
catch {
|
|
70
|
-
return json(503, { error: "authorization_unavailable" });
|
|
71
|
-
}
|
|
72
89
|
if (request.method !== "POST")
|
|
73
90
|
return json(405, { error: "method_not_allowed" }, { Allow: "POST, OPTIONS" });
|
|
74
91
|
if (!request.headers.get("content-type")?.startsWith("application/json"))
|
|
@@ -105,30 +122,32 @@ export async function handleMcpRequest(request) {
|
|
|
105
122
|
error: { code: -32700, message: "Invalid JSON" },
|
|
106
123
|
});
|
|
107
124
|
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
try {
|
|
114
|
-
await server.connect(transport);
|
|
115
|
-
const response = await transport.handleRequest(request, {
|
|
116
|
-
parsedBody: body,
|
|
117
|
-
});
|
|
118
|
-
// Finish the JSON response before closing this request-scoped transport.
|
|
119
|
-
const responseBody = await response.arrayBuffer();
|
|
120
|
-
const responseHeaders = new Headers(response.headers);
|
|
121
|
-
for (const [key, value] of Object.entries(headers))
|
|
122
|
-
responseHeaders.set(key, value);
|
|
123
|
-
return new Response(responseBody.byteLength ? responseBody : null, {
|
|
124
|
-
status: response.status,
|
|
125
|
-
headers: responseHeaders,
|
|
125
|
+
return requestAuthorization.run(authorization, async () => {
|
|
126
|
+
const server = createServer(true);
|
|
127
|
+
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
128
|
+
sessionIdGenerator: undefined,
|
|
129
|
+
enableJsonResponse: true,
|
|
126
130
|
});
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
131
|
+
try {
|
|
132
|
+
await server.connect(transport);
|
|
133
|
+
const response = await transport.handleRequest(request, {
|
|
134
|
+
parsedBody: body,
|
|
135
|
+
});
|
|
136
|
+
// Finish the JSON response before closing this request-scoped transport.
|
|
137
|
+
const responseBody = await response.arrayBuffer();
|
|
138
|
+
const responseHeaders = new Headers(response.headers);
|
|
139
|
+
for (const [key, value] of Object.entries(headers))
|
|
140
|
+
responseHeaders.set(key, value);
|
|
141
|
+
return new Response(responseBody.byteLength ? responseBody : null, {
|
|
142
|
+
status: response.status,
|
|
143
|
+
headers: responseHeaders,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return json(500, { error: "mcp_request_failed" });
|
|
148
|
+
}
|
|
149
|
+
finally {
|
|
150
|
+
await server.close();
|
|
151
|
+
}
|
|
152
|
+
});
|
|
134
153
|
}
|
package/dist/iac/parse.js
CHANGED
|
@@ -39,8 +39,8 @@ export function looksLikeIac(content) {
|
|
|
39
39
|
/* Other format detection still applies to incomplete input. */
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
|
-
return (
|
|
43
|
-
|
|
42
|
+
return (/^[^\S\n]*(?:resource|module|terraform|variable|provider)\s+["{]/m.test(content) ||
|
|
43
|
+
/^[^\S\n]*(?:["']?Resources["']?\s*:|apiVersion\s*:)/m.test(content) ||
|
|
44
44
|
/^\s*\{\s*"(?:resource|Resources|apiVersion|AWSTemplateFormatVersion)"\s*:/.test(content) ||
|
|
45
45
|
looksLikePulumiYaml(content) ||
|
|
46
46
|
looksLikePulumiProgram(content) ||
|
|
@@ -57,13 +57,13 @@ export function looksLikeIac(content) {
|
|
|
57
57
|
looksLikeIamPolicy(content));
|
|
58
58
|
}
|
|
59
59
|
export function looksLikePulumiProgram(content) {
|
|
60
|
-
return /(?:^|\n)\
|
|
60
|
+
return /(?:^|\n)[^\S\n]*(?:import\s+\*\s+as\s+pulumi\b|import\s+pulumi\b|from\s+["']@pulumi\/)/.test(content);
|
|
61
61
|
}
|
|
62
62
|
export function looksLikeCompose(content) {
|
|
63
|
-
return
|
|
63
|
+
return /^[^\S\n]*services\s*:/m.test(content) && /^[^\S\n]+image\s*:/m.test(content);
|
|
64
64
|
}
|
|
65
65
|
export function looksLikeDockerfile(content) {
|
|
66
|
-
return
|
|
66
|
+
return /^[^\S\n]*FROM\s+\S+/m.test(content);
|
|
67
67
|
}
|
|
68
68
|
function topLevelKey(content, name) {
|
|
69
69
|
return new RegExp(`^(?:"${name}"|'${name}'|${name})\\s*:`, "m").test(content);
|
|
@@ -71,7 +71,7 @@ function topLevelKey(content, name) {
|
|
|
71
71
|
function playbookShape(content) {
|
|
72
72
|
if (topLevelKey(content, "hosts") && topLevelKey(content, "tasks"))
|
|
73
73
|
return true;
|
|
74
|
-
return
|
|
74
|
+
return /^[^\S\n]*-\s+hosts\s*:/m.test(content) && /^[^\S\n]*tasks\s*:/m.test(content);
|
|
75
75
|
}
|
|
76
76
|
export function looksLikeAnsible(content) {
|
|
77
77
|
if (looksLikeGithubWorkflow(content))
|
|
@@ -86,27 +86,27 @@ export function looksLikeHelm(content) {
|
|
|
86
86
|
const visible = maskNonCode(content);
|
|
87
87
|
if (!visible.includes("{{"))
|
|
88
88
|
return false;
|
|
89
|
-
return
|
|
89
|
+
return /^[^\S\n]*(?:apiVersion|kind)\s*:/m.test(visible);
|
|
90
90
|
}
|
|
91
91
|
export function looksLikeGithubWorkflow(content) {
|
|
92
92
|
if (topLevelKey(content, "apiVersion") || topLevelKey(content, "kind"))
|
|
93
93
|
return false;
|
|
94
94
|
const topOn = topLevelKey(content, "on");
|
|
95
95
|
const composeOnly = topLevelKey(content, "services") &&
|
|
96
|
-
|
|
96
|
+
/^[^\S\n]+image\s*:/m.test(content) &&
|
|
97
97
|
!topOn;
|
|
98
98
|
if (composeOnly)
|
|
99
99
|
return false;
|
|
100
100
|
return topOn && topLevelKey(content, "jobs");
|
|
101
101
|
}
|
|
102
102
|
export function looksLikeTerraformHcl(content) {
|
|
103
|
-
return
|
|
103
|
+
return /^[^\S\n]*(?:(?:resource|module|variable|provider|data|output)\s+"|(?:terraform|locals)\s*\{)/m.test(content);
|
|
104
104
|
}
|
|
105
105
|
export function looksLikeBicep(content) {
|
|
106
106
|
const visible = maskIaCComments(content);
|
|
107
107
|
if (looksLikeTerraformHcl(visible))
|
|
108
108
|
return false;
|
|
109
|
-
return
|
|
109
|
+
return /^[^\S\n]*resource\s+[A-Za-z_][A-Za-z0-9_]*\s+['"]Microsoft\./m.test(visible);
|
|
110
110
|
}
|
|
111
111
|
export function looksLikeArm(content) {
|
|
112
112
|
const visible = maskIaCComments(content).trim();
|
|
@@ -138,8 +138,8 @@ export function looksLikeArm(content) {
|
|
|
138
138
|
return root.resources.some((item) => String(object(item).type ?? "").startsWith("Microsoft."));
|
|
139
139
|
}
|
|
140
140
|
export function looksLikePulumiYaml(content) {
|
|
141
|
-
return (
|
|
142
|
-
|
|
141
|
+
return (/^[^\S\n]*resources\s*:/m.test(content) &&
|
|
142
|
+
/^[^\S\n]+type\s*:\s*[A-Za-z0-9_.-]+:\S+/m.test(content));
|
|
143
143
|
}
|
|
144
144
|
const terraformExpressionWarning = "Variable and local literals from the submitted files are resolved. A string of those references, format of known literals, and join of known literals are resolved. Other functions and expressions are not evaluated.";
|
|
145
145
|
export function parseIac(content, requested = "auto", shared) {
|
|
@@ -221,7 +221,7 @@ export function parseIac(content, requested = "auto", shared) {
|
|
|
221
221
|
checkShape(data);
|
|
222
222
|
// The HCL parser preserves expressions but does not expose source ranges.
|
|
223
223
|
// Report the resource declaration line and the exact parsed property path.
|
|
224
|
-
const searchable = content
|
|
224
|
+
const searchable = blankComments(content, /(?:#|\/\/)[^\n]*/.source);
|
|
225
225
|
for (const [type, instances] of Object.entries(object(object(data).resource))) {
|
|
226
226
|
for (const [name, blocks] of Object.entries(object(instances))) {
|
|
227
227
|
const declaration = new RegExp(`\\bresource\\s+"${escapeRegex(type)}"\\s+"${escapeRegex(name)}"`).exec(searchable);
|
|
@@ -503,11 +503,35 @@ function programLocation(content, offset, path) {
|
|
|
503
503
|
path,
|
|
504
504
|
};
|
|
505
505
|
}
|
|
506
|
+
// One left-to-right pass that blanks block comments and, when given, line comments
|
|
507
|
+
// (newlines kept). A lazy /\*[\s\S]*?\*\// rescans to the end for every unclosed opener,
|
|
508
|
+
// which is quadratic on hostile input. An unclosed "/*" stays code, as before.
|
|
509
|
+
function blankComments(text, lineSource) {
|
|
510
|
+
const pattern = new RegExp(lineSource ? `\\/\\*|${lineSource}` : "\\/\\*", "g");
|
|
511
|
+
let out = "";
|
|
512
|
+
let last = 0;
|
|
513
|
+
let closable = true;
|
|
514
|
+
for (let match = pattern.exec(text); match; match = pattern.exec(text)) {
|
|
515
|
+
let end = pattern.lastIndex;
|
|
516
|
+
if (match[0] === "/*") {
|
|
517
|
+
// No "*/" after one opener means none after any later opener either.
|
|
518
|
+
const close = closable ? text.indexOf("*/", end) : -1;
|
|
519
|
+
if (close < 0) {
|
|
520
|
+
closable = false;
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
end = close + 2;
|
|
524
|
+
pattern.lastIndex = end;
|
|
525
|
+
}
|
|
526
|
+
out += text.slice(last, match.index) + text.slice(match.index, end).replace(/[^\n]/g, " ");
|
|
527
|
+
last = end;
|
|
528
|
+
}
|
|
529
|
+
return out + text.slice(last);
|
|
530
|
+
}
|
|
506
531
|
function stripProgramComments(content) {
|
|
507
|
-
return content
|
|
508
|
-
.replace(/\/\*[\s\S]*?\*\//g, (match) => match.replace(/[^\n]/g, " "))
|
|
532
|
+
return blankComments(content)
|
|
509
533
|
.replace(/(^|[^:])\/\/[^\n]*/g, (match) => match.replace(/[^\n]/g, " "))
|
|
510
|
-
.replace(/(^|\n)\
|
|
534
|
+
.replace(/(^|\n)[^\S\n]*#[^\n]*/g, (match) => match.replace(/[^\n]/g, " "));
|
|
511
535
|
}
|
|
512
536
|
function parsePulumiProgram(content) {
|
|
513
537
|
const searchable = stripProgramComments(content);
|
|
@@ -741,14 +765,14 @@ function gitlabShape(content) {
|
|
|
741
765
|
return false;
|
|
742
766
|
if (topLevelKey(content, "stages"))
|
|
743
767
|
return true;
|
|
744
|
-
return
|
|
768
|
+
return /^[^\S\n]*script\s*:/m.test(content);
|
|
745
769
|
}
|
|
746
770
|
function cloudBuildShape(content) {
|
|
747
771
|
if (!topLevelKey(content, "steps"))
|
|
748
772
|
return false;
|
|
749
773
|
if (topLevelKey(content, "on") && topLevelKey(content, "jobs"))
|
|
750
774
|
return false;
|
|
751
|
-
return
|
|
775
|
+
return /^[^\S\n]*-\s*name\s*:/m.test(content);
|
|
752
776
|
}
|
|
753
777
|
function looksLikePipeline(content) {
|
|
754
778
|
if (stolenPipelineInput(content) || looksLikeServerless(content))
|
|
@@ -918,11 +942,11 @@ function parsePipeline(content, format) {
|
|
|
918
942
|
}
|
|
919
943
|
function looksLikeTfvars(content) {
|
|
920
944
|
const visible = maskIaCComments(content);
|
|
921
|
-
if (
|
|
945
|
+
if (/^[^\S\n]*(?:resource|module|provider)\s+["']/m.test(visible))
|
|
922
946
|
return false;
|
|
923
|
-
if (
|
|
947
|
+
if (/^[^\S\n]*(?:terraform|provider|module)\s*\{/m.test(visible))
|
|
924
948
|
return false;
|
|
925
|
-
return
|
|
949
|
+
return /^[^\S\n]*[A-Za-z_][A-Za-z0-9_]*\s*=\s*\S/m.test(visible);
|
|
926
950
|
}
|
|
927
951
|
function parseTfvars(content) {
|
|
928
952
|
let data;
|
|
@@ -1235,8 +1259,8 @@ function escapeRegex(value) {
|
|
|
1235
1259
|
}
|
|
1236
1260
|
function maskNonCode(content) {
|
|
1237
1261
|
const withoutBlockComments = content
|
|
1238
|
-
.replace(/\{\{-?\s*\/\*[\s\S]
|
|
1239
|
-
.replace(/\{#[\s\S]
|
|
1262
|
+
.replace(/\{\{-?\s*\/\*[\s\S]{0,4096}?\*\/\s*-?\}\}/g, (match) => match.replace(/[^\n]/g, " "))
|
|
1263
|
+
.replace(/\{#[\s\S]{0,4096}?#\}/g, (match) => match.replace(/[^\n]/g, " "));
|
|
1240
1264
|
return withoutBlockComments
|
|
1241
1265
|
.split("\n")
|
|
1242
1266
|
.map((line) => {
|
|
@@ -1314,7 +1338,7 @@ function parseHelm(content) {
|
|
|
1314
1338
|
}
|
|
1315
1339
|
function parseAnsible(content) {
|
|
1316
1340
|
const visible = maskNonCode(content);
|
|
1317
|
-
const become = firstLiteral(visible, /(?:^|\n)\
|
|
1341
|
+
const become = firstLiteral(visible, /(?:^|\n)[^\S\n]*(?:-\s+)?become\s*:\s*["']?(?:true|yes)["']?(?=\s|$)/);
|
|
1318
1342
|
const cidr = firstLiteral(visible, /(?:cidr|source)\s*:\s*(?:\|\s*)?(?:\n\s*-\s*)?["']?0\.0\.0\.0\/0\b/);
|
|
1319
1343
|
return {
|
|
1320
1344
|
format: "ansible",
|
|
@@ -1532,7 +1556,7 @@ function substituteKnownParameters(template, parameters) {
|
|
|
1532
1556
|
let unresolved = false;
|
|
1533
1557
|
let secret = false;
|
|
1534
1558
|
const names = [];
|
|
1535
|
-
const text = template.replace(/\$\{(!?)([^}]
|
|
1559
|
+
const text = template.replace(/\$\{(!?)([^}]{0,200})\}/g, (full, literalMark, rawName) => {
|
|
1536
1560
|
if (literalMark === "!")
|
|
1537
1561
|
return `\${${rawName}}`;
|
|
1538
1562
|
const name = rawName.trim();
|
|
@@ -1726,7 +1750,9 @@ export function looksLikePackage(content) {
|
|
|
1726
1750
|
plainObject(root.scripts));
|
|
1727
1751
|
}
|
|
1728
1752
|
function scriptPipesDownload(command) {
|
|
1729
|
-
|
|
1753
|
+
// Search only after the first download word. A [\s\S]* prefix rescans per download word.
|
|
1754
|
+
const start = command.search(/\b(?:curl|wget)\b/);
|
|
1755
|
+
return start >= 0 && /\|\s*(?:ba)?sh\b/.test(command.slice(start));
|
|
1730
1756
|
}
|
|
1731
1757
|
function jsonKeyLine(content, key) {
|
|
1732
1758
|
const match = new RegExp(`"${escapeRegex(key)}"\\s*:`).exec(content);
|
package/dist/review.js
CHANGED
|
@@ -148,7 +148,7 @@ export function architectureFindings(content, focus) {
|
|
|
148
148
|
});
|
|
149
149
|
}
|
|
150
150
|
if (shouldInclude(focusValue, "security") &&
|
|
151
|
-
|
|
151
|
+
/^[^\S\n]*USER\s+root\s*$/im.test(content)) {
|
|
152
152
|
findings.push({
|
|
153
153
|
severity: "medium",
|
|
154
154
|
title: "Container runs as root",
|
|
@@ -175,7 +175,7 @@ export function architectureFindings(content, focus) {
|
|
|
175
175
|
"Retry limit is unbounded": /(?:max_?retries|retry_?count|retries)\s*[:=]\s*(?:-1|Infinity)\b/i,
|
|
176
176
|
"Database connection limit is unlimited": /(?:max_?open_?conns|maxOpenConns|SetMaxOpenConns)\s*(?:[:=]|\()\s*0\b/,
|
|
177
177
|
"Memory limit is disabled": /(?:mem_limit|memory_limit)\s*[:=]\s*["']?0\b/i,
|
|
178
|
-
"Container runs as root":
|
|
178
|
+
"Container runs as root": /^[^\S\n]*USER\s+root\s*$/im,
|
|
179
179
|
};
|
|
180
180
|
const pattern = matchers[finding.title];
|
|
181
181
|
const matches = pattern
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
export declare const savedTargetSchema: z.ZodOptional<z.ZodObject<{
|
|
4
|
+
repository: z.ZodOptional<z.ZodString>;
|
|
5
|
+
path: z.ZodOptional<z.ZodString>;
|
|
6
|
+
revision: z.ZodOptional<z.ZodString>;
|
|
7
|
+
}, z.core.$strict>>;
|
|
8
|
+
export declare const applicationTargetSchema: z.ZodOptional<z.ZodObject<{
|
|
9
|
+
repository: z.ZodOptional<z.ZodString>;
|
|
10
|
+
revision: z.ZodOptional<z.ZodString>;
|
|
11
|
+
}, z.core.$strict>>;
|
|
12
|
+
export declare const requestAuthorization: AsyncLocalStorage<string>;
|
|
13
|
+
export declare const ASSISTANT_INSTRUCTION = "Report the headline and the one recommended change. Include the share link. If the headline says the finding is gone, say that. If there are no findings, say that this does not prove the system is secure. Do not paste source, diffs, or account details. Do not invent a link when the share link is missing.";
|
|
14
|
+
type Target = {
|
|
15
|
+
repository?: string;
|
|
16
|
+
path?: string;
|
|
17
|
+
revision?: string;
|
|
18
|
+
};
|
|
19
|
+
type SaveInput = {
|
|
20
|
+
tool: string;
|
|
21
|
+
target?: Target | null;
|
|
22
|
+
submittedPaths: string[];
|
|
23
|
+
};
|
|
24
|
+
export declare function buildProjection(input: SaveInput, output: Record<string, unknown>): {
|
|
25
|
+
tool: string;
|
|
26
|
+
engineVersion: string;
|
|
27
|
+
target: Target | null;
|
|
28
|
+
riskSentence: unknown;
|
|
29
|
+
findings: Record<string, unknown>[];
|
|
30
|
+
truncated: boolean;
|
|
31
|
+
checked: string[];
|
|
32
|
+
skipped: string[];
|
|
33
|
+
submittedPaths: string[];
|
|
34
|
+
};
|
|
35
|
+
export declare function withSavedReview(output: Record<string, unknown>, input: SaveInput): Promise<Record<string, unknown>>;
|
|
36
|
+
export {};
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { getApiAccessToken } from "./auth.js";
|
|
4
|
+
import { getApiUrl } from "./config.js";
|
|
5
|
+
import { VERSION } from "./version.js";
|
|
6
|
+
const repositoryLabel = z
|
|
7
|
+
.string()
|
|
8
|
+
.regex(/^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)?$/)
|
|
9
|
+
.max(200);
|
|
10
|
+
const relativePath = z
|
|
11
|
+
.string()
|
|
12
|
+
.min(1)
|
|
13
|
+
.max(500)
|
|
14
|
+
.refine((path) => !path.startsWith("/") && !path.includes("..") && !path.includes("\\"));
|
|
15
|
+
const revision = z.string().regex(/^[A-Za-z0-9._-]{1,64}$/);
|
|
16
|
+
export const savedTargetSchema = z
|
|
17
|
+
.object({
|
|
18
|
+
repository: repositoryLabel.optional(),
|
|
19
|
+
path: relativePath.optional(),
|
|
20
|
+
revision: revision.optional(),
|
|
21
|
+
})
|
|
22
|
+
.strict()
|
|
23
|
+
.optional()
|
|
24
|
+
.describe("Repository label for the next comparison. The server does not fetch it.");
|
|
25
|
+
export const applicationTargetSchema = z
|
|
26
|
+
.object({
|
|
27
|
+
repository: repositoryLabel.optional(),
|
|
28
|
+
revision: revision.optional(),
|
|
29
|
+
})
|
|
30
|
+
.strict()
|
|
31
|
+
.optional()
|
|
32
|
+
.describe("Repository label for the next comparison. Send paths on each file.");
|
|
33
|
+
export const requestAuthorization = new AsyncLocalStorage();
|
|
34
|
+
export const ASSISTANT_INSTRUCTION = "Report the headline and the one recommended change. Include the share link. If the headline says the finding is gone, say that. If there are no findings, say that this does not prove the system is secure. Do not paste source, diffs, or account details. Do not invent a link when the share link is missing.";
|
|
35
|
+
const refused = {
|
|
36
|
+
saved: false,
|
|
37
|
+
id: null,
|
|
38
|
+
ownerUrl: null,
|
|
39
|
+
shareUrl: null,
|
|
40
|
+
shareExpiresAt: null,
|
|
41
|
+
comparison: null,
|
|
42
|
+
headline: null,
|
|
43
|
+
assistantInstruction: ASSISTANT_INSTRUCTION,
|
|
44
|
+
};
|
|
45
|
+
function clip(value, max = 200) {
|
|
46
|
+
if (typeof value !== "string")
|
|
47
|
+
return null;
|
|
48
|
+
const text = value.replaceAll("\n", " ").trim();
|
|
49
|
+
if (!text || text.length > max)
|
|
50
|
+
return null;
|
|
51
|
+
return text;
|
|
52
|
+
}
|
|
53
|
+
function findingPath(finding, fallback) {
|
|
54
|
+
const location = finding.location;
|
|
55
|
+
if (location && typeof location === "object" && "path" in location && fallback) {
|
|
56
|
+
return fallback;
|
|
57
|
+
}
|
|
58
|
+
const evidence = finding.evidence;
|
|
59
|
+
if (Array.isArray(evidence) && evidence[0] && typeof evidence[0] === "object" && "file" in evidence[0]) {
|
|
60
|
+
const file = clip(evidence[0].file, 500);
|
|
61
|
+
if (file)
|
|
62
|
+
return file;
|
|
63
|
+
}
|
|
64
|
+
return fallback;
|
|
65
|
+
}
|
|
66
|
+
export function buildProjection(input, output) {
|
|
67
|
+
const findings = Array.isArray(output.findings) ? output.findings : [];
|
|
68
|
+
const fallbackPath = input.target?.path ?? "";
|
|
69
|
+
const projected = findings.slice(0, 100).flatMap((item) => {
|
|
70
|
+
if (!item || typeof item !== "object")
|
|
71
|
+
return [];
|
|
72
|
+
const finding = item;
|
|
73
|
+
const title = clip(finding.title);
|
|
74
|
+
const severity = clip(finding.severity, 16);
|
|
75
|
+
if (!title || !severity)
|
|
76
|
+
return [];
|
|
77
|
+
const row = { title, severity };
|
|
78
|
+
const ruleId = clip(finding.ruleId, 64);
|
|
79
|
+
if (ruleId)
|
|
80
|
+
row.ruleId = ruleId;
|
|
81
|
+
for (const field of ["detail", "resource", "remediation"]) {
|
|
82
|
+
const text = clip(finding[field]);
|
|
83
|
+
if (text)
|
|
84
|
+
row[field] = text;
|
|
85
|
+
}
|
|
86
|
+
const path = findingPath(finding, fallbackPath);
|
|
87
|
+
if (path)
|
|
88
|
+
row.path = path;
|
|
89
|
+
const line = finding.location && typeof finding.location === "object" && "line" in finding.location
|
|
90
|
+
? finding.location.line
|
|
91
|
+
: Array.isArray(finding.evidence) && finding.evidence[0] && typeof finding.evidence[0] === "object"
|
|
92
|
+
? finding.evidence[0].line
|
|
93
|
+
: null;
|
|
94
|
+
if (typeof line === "number" && Number.isInteger(line) && line > 0)
|
|
95
|
+
row.line = line;
|
|
96
|
+
return [row];
|
|
97
|
+
});
|
|
98
|
+
const checked = Array.isArray(output.rulesEvaluated)
|
|
99
|
+
? output.rulesEvaluated.flatMap((rule) => {
|
|
100
|
+
const text = clip(rule, 64);
|
|
101
|
+
return text ? [text] : [];
|
|
102
|
+
})
|
|
103
|
+
: [];
|
|
104
|
+
if (input.tool === "review_application_architecture") {
|
|
105
|
+
checked.unshift(`Reviewed ${input.submittedPaths.length} files.`);
|
|
106
|
+
checked.push(...input.submittedPaths);
|
|
107
|
+
}
|
|
108
|
+
const skipped = [];
|
|
109
|
+
if (Array.isArray(output.warnings) && output.warnings.length > 0) {
|
|
110
|
+
const count = output.warnings.length;
|
|
111
|
+
skipped.push(`The scanner reported ${count} ${count === 1 ? "warning" : "warnings"}.`);
|
|
112
|
+
}
|
|
113
|
+
for (const field of ["evidenceGaps", "limitations"]) {
|
|
114
|
+
if (!Array.isArray(output[field]))
|
|
115
|
+
continue;
|
|
116
|
+
for (const entry of output[field]) {
|
|
117
|
+
const text = clip(entry);
|
|
118
|
+
if (text)
|
|
119
|
+
skipped.push(text);
|
|
120
|
+
if (skipped.length >= 20)
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const summary = clip(output.summary);
|
|
125
|
+
const riskSentence = summary
|
|
126
|
+
?? (projected.length === 0
|
|
127
|
+
? "No findings. That does not prove the system is secure."
|
|
128
|
+
: projected[0].title);
|
|
129
|
+
return {
|
|
130
|
+
tool: input.tool,
|
|
131
|
+
engineVersion: VERSION,
|
|
132
|
+
target: input.target ?? null,
|
|
133
|
+
riskSentence,
|
|
134
|
+
findings: projected,
|
|
135
|
+
truncated: output.truncated === true || findings.length > 100,
|
|
136
|
+
checked: checked.length > 0 ? checked.slice(0, 20) : ["Individual checks were not recorded."],
|
|
137
|
+
skipped: skipped.length > 0 ? skipped.slice(0, 20) : ["Nothing was recorded as skipped."],
|
|
138
|
+
submittedPaths: input.submittedPaths.length > 0 ? input.submittedPaths : [fallbackPath],
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
async function authorizationHeader() {
|
|
142
|
+
const fromRequest = requestAuthorization.getStore();
|
|
143
|
+
if (fromRequest)
|
|
144
|
+
return fromRequest;
|
|
145
|
+
const token = await getApiAccessToken();
|
|
146
|
+
return token ? `Bearer ${token}` : null;
|
|
147
|
+
}
|
|
148
|
+
export async function withSavedReview(output, input) {
|
|
149
|
+
const projection = buildProjection(input, output);
|
|
150
|
+
let savedReview = refused;
|
|
151
|
+
const authorization = await authorizationHeader();
|
|
152
|
+
if (authorization) {
|
|
153
|
+
try {
|
|
154
|
+
const response = await fetch(`${getApiUrl()}/api/mcp/reviews`, {
|
|
155
|
+
method: "POST",
|
|
156
|
+
headers: {
|
|
157
|
+
Authorization: authorization,
|
|
158
|
+
Accept: "application/json",
|
|
159
|
+
"Content-Type": "application/json",
|
|
160
|
+
},
|
|
161
|
+
body: JSON.stringify(projection),
|
|
162
|
+
signal: AbortSignal.timeout(10_000),
|
|
163
|
+
redirect: "error",
|
|
164
|
+
cache: "no-store",
|
|
165
|
+
});
|
|
166
|
+
if (response.ok) {
|
|
167
|
+
const body = await response.json();
|
|
168
|
+
if (body.savedReview)
|
|
169
|
+
savedReview = body.savedReview;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
savedReview = refused;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return { ...output, savedReview };
|
|
177
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -6,6 +6,7 @@ import { looksLikeIac, ScanInputError } from "./iac/parse.js";
|
|
|
6
6
|
import { filterFindings, scanIac, summarize } from "./iac/scan.js";
|
|
7
7
|
import { registerConsultation } from "./application/tools.js";
|
|
8
8
|
import { presentReview } from "./review-edits.js";
|
|
9
|
+
import { savedTargetSchema, withSavedReview } from "./saved-review.js";
|
|
9
10
|
import { VERSION } from "./version.js";
|
|
10
11
|
const contentSchema = z
|
|
11
12
|
.string()
|
|
@@ -59,7 +60,7 @@ export function createServer(remote = false) {
|
|
|
59
60
|
title: "Review architecture",
|
|
60
61
|
description: "Review individual source snippets or parsed infrastructure for security, reliability, and cost risks. Application checks include infrastructure bottlenecks: a disabled timeout, a one-connection pool, and an unbounded retry limit. For a whole application or Next.js architecture consultation, use plan_architecture_review and review_application_architecture instead.",
|
|
61
62
|
annotations: {
|
|
62
|
-
readOnlyHint:
|
|
63
|
+
readOnlyHint: false,
|
|
63
64
|
destructiveHint: false,
|
|
64
65
|
openWorldHint: false,
|
|
65
66
|
},
|
|
@@ -93,8 +94,9 @@ export function createServer(remote = false) {
|
|
|
93
94
|
.optional()
|
|
94
95
|
.default("auto")
|
|
95
96
|
.describe("Content format: terraform, cloudformation, kubernetes, pulumi, compose, dockerfile, github, helm, ansible, bicep, arm, gitlab, azure-pipelines, cloudbuild, tfvars, serverless, iam, package, application, auto."),
|
|
97
|
+
target: savedTargetSchema,
|
|
96
98
|
},
|
|
97
|
-
}, async ({ content, focus, format, minSeverity, maxFindings }) => {
|
|
99
|
+
}, async ({ content, focus, format, minSeverity, maxFindings, target }) => {
|
|
98
100
|
const botHints = fetchPublicBotHints();
|
|
99
101
|
if (!remote) {
|
|
100
102
|
const auth = await requireApiAuth();
|
|
@@ -103,15 +105,20 @@ export function createServer(remote = false) {
|
|
|
103
105
|
}
|
|
104
106
|
try {
|
|
105
107
|
const options = { focus, minSeverity, maxFindings };
|
|
108
|
+
const save = {
|
|
109
|
+
tool: "review_architecture",
|
|
110
|
+
target: target ?? null,
|
|
111
|
+
submittedPaths: [target?.path ?? ""],
|
|
112
|
+
};
|
|
106
113
|
if (format !== "application" &&
|
|
107
114
|
(format !== "auto" || looksLikeIac(content))) {
|
|
108
|
-
return result(presentReview(content, {
|
|
115
|
+
return result(await withSavedReview(presentReview(content, {
|
|
109
116
|
...scanIac(content, { ...options, format }),
|
|
110
117
|
botHints: await botHints,
|
|
111
|
-
}));
|
|
118
|
+
}), save));
|
|
112
119
|
}
|
|
113
120
|
const findings = filterFindings(architectureFindings(content, focus).slice(1), options);
|
|
114
|
-
return result(presentReview(content, {
|
|
121
|
+
return result(await withSavedReview(presentReview(content, {
|
|
115
122
|
status: "completed",
|
|
116
123
|
engineVersion: VERSION,
|
|
117
124
|
format: "application",
|
|
@@ -125,7 +132,7 @@ export function createServer(remote = false) {
|
|
|
125
132
|
"Infrastructure bottleneck checks cover a disabled timeout, a one-connection pool, and an unbounded retry limit.",
|
|
126
133
|
],
|
|
127
134
|
botHints: await botHints,
|
|
128
|
-
}));
|
|
135
|
+
}), save));
|
|
129
136
|
}
|
|
130
137
|
catch (error) {
|
|
131
138
|
return inputError(error);
|
|
@@ -141,7 +148,7 @@ export function createServer(remote = false) {
|
|
|
141
148
|
: "") +
|
|
142
149
|
"Parse Terraform HCL/JSON, CloudFormation YAML/JSON, Kubernetes manifests, Pulumi YAML, Pulumi programs, or Docker Compose. Includes Dockerfile, GitHub Actions, Helm templates, Ansible playbooks, Bicep files, ARM templates, GitLab CI, Azure Pipelines, Cloud Build, tfvars, Serverless Framework, a standalone IAM policy, and package.json. Helm templates, Ansible facts, Bicep modules, ARM expressions, pipelines, Serverless Framework config, and IAM policies are not executed. Conditions are not evaluated. Terraform module sources are not fetched. Check security, reliability and cost rules with resource locations and remediation. Pulumi programs are scanned as text.",
|
|
143
150
|
annotations: {
|
|
144
|
-
readOnlyHint:
|
|
151
|
+
readOnlyHint: false,
|
|
145
152
|
destructiveHint: false,
|
|
146
153
|
openWorldHint: false,
|
|
147
154
|
},
|
|
@@ -172,15 +179,20 @@ export function createServer(remote = false) {
|
|
|
172
179
|
])
|
|
173
180
|
.default("auto"),
|
|
174
181
|
...filters,
|
|
182
|
+
target: savedTargetSchema,
|
|
175
183
|
},
|
|
176
|
-
}, async ({ content, format, focus, minSeverity, maxFindings }) => {
|
|
184
|
+
}, async ({ content, format, focus, minSeverity, maxFindings, target }) => {
|
|
177
185
|
if (!remote) {
|
|
178
186
|
const auth = await requireApiAuth();
|
|
179
187
|
if (!auth.ok)
|
|
180
188
|
return unauthorizedContent(auth);
|
|
181
189
|
}
|
|
182
190
|
try {
|
|
183
|
-
return result(presentReview(content, scanIac(content, { format, focus, minSeverity, maxFindings }))
|
|
191
|
+
return result(await withSavedReview(presentReview(content, scanIac(content, { format, focus, minSeverity, maxFindings })), {
|
|
192
|
+
tool: name,
|
|
193
|
+
target: target ?? null,
|
|
194
|
+
submittedPaths: [target?.path ?? ""],
|
|
195
|
+
}));
|
|
184
196
|
}
|
|
185
197
|
catch (error) {
|
|
186
198
|
return inputError(error);
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const VERSION = "0.6.
|
|
1
|
+
export declare const VERSION = "0.6.20";
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "0.6.
|
|
1
|
+
export const VERSION = "0.6.20";
|
package/package.json
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skaleagents/swarm",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.20",
|
|
4
4
|
"private": false,
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
7
7
|
},
|
|
8
8
|
"type": "module",
|
|
9
9
|
"description": "SkaleAgents review tools with OAuth for local and hosted MCP clients",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/SkaleAgents/mcp-server.git"
|
|
13
|
+
},
|
|
10
14
|
"exports": {
|
|
11
15
|
"./http": {
|
|
12
16
|
"types": "./dist/http.d.ts",
|