loopctl-mcp-server 2.91.0 → 2.91.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +4 -55
- package/lib/payload-path.js +96 -0
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -43,6 +43,7 @@ import {
|
|
|
43
43
|
GENERATED_TOOL_PREFIX,
|
|
44
44
|
} from "./lib/generated-tools.js";
|
|
45
45
|
import { createHandoff } from "./lib/handoff.js";
|
|
46
|
+
import { readPayloadFile } from "./lib/payload-path.js";
|
|
46
47
|
|
|
47
48
|
// Single source of truth for the server version: the package.json this file
|
|
48
49
|
// ships with (npm always includes package.json in the published tarball).
|
|
@@ -967,13 +968,7 @@ async function importStories({ project_id, payload, payload_path, merge }) {
|
|
|
967
968
|
|
|
968
969
|
// Reads JSON payload from either an inline object or an absolute file path.
|
|
969
970
|
// Returns the object on success, or an { error, body } shape on failure.
|
|
970
|
-
//
|
|
971
|
-
// Security: `payload_path` is read with the MCP process's filesystem
|
|
972
|
-
// privileges. Because agents can set this argument via prompt injection,
|
|
973
|
-
// we validate aggressively:
|
|
974
|
-
// * require absolute path
|
|
975
|
-
// * reject /proc, /dev, /sys (pseudo-filesystems that could DoS or leak)
|
|
976
|
-
// * stat first and cap at 5 MiB (server also enforces a body size limit)
|
|
971
|
+
// The file path is validated in lib/payload-path.js (see its security notes).
|
|
977
972
|
async function resolvePayload(inline, payloadPath) {
|
|
978
973
|
if (inline && typeof inline === "object") return inline;
|
|
979
974
|
if (!payloadPath) {
|
|
@@ -983,53 +978,7 @@ async function resolvePayload(inline, payloadPath) {
|
|
|
983
978
|
body: "Must provide either `payload` (object) or `payload_path` (absolute JSON file path).",
|
|
984
979
|
};
|
|
985
980
|
}
|
|
986
|
-
|
|
987
|
-
const nodePath = await import("node:path");
|
|
988
|
-
if (!nodePath.isAbsolute(payloadPath)) {
|
|
989
|
-
return {
|
|
990
|
-
error: true,
|
|
991
|
-
status: 0,
|
|
992
|
-
body: `payload_path must be absolute (got '${payloadPath}').`,
|
|
993
|
-
};
|
|
994
|
-
}
|
|
995
|
-
|
|
996
|
-
const blockedPrefixes = ["/proc/", "/dev/", "/sys/", "/proc", "/dev", "/sys"];
|
|
997
|
-
if (blockedPrefixes.some((p) => payloadPath === p.replace(/\/$/, "") || payloadPath.startsWith(p))) {
|
|
998
|
-
return {
|
|
999
|
-
error: true,
|
|
1000
|
-
status: 0,
|
|
1001
|
-
body: `payload_path refused: '${payloadPath}' targets a pseudo-filesystem path.`,
|
|
1002
|
-
};
|
|
1003
|
-
}
|
|
1004
|
-
|
|
1005
|
-
const MAX_PAYLOAD_BYTES = 5 * 1024 * 1024;
|
|
1006
|
-
const fs = await import("node:fs/promises");
|
|
1007
|
-
|
|
1008
|
-
try {
|
|
1009
|
-
const stat = await fs.stat(payloadPath);
|
|
1010
|
-
if (!stat.isFile()) {
|
|
1011
|
-
return {
|
|
1012
|
-
error: true,
|
|
1013
|
-
status: 0,
|
|
1014
|
-
body: `payload_path '${payloadPath}' is not a regular file.`,
|
|
1015
|
-
};
|
|
1016
|
-
}
|
|
1017
|
-
if (stat.size > MAX_PAYLOAD_BYTES) {
|
|
1018
|
-
return {
|
|
1019
|
-
error: true,
|
|
1020
|
-
status: 0,
|
|
1021
|
-
body: `payload_path '${payloadPath}' is ${stat.size} bytes, exceeds max ${MAX_PAYLOAD_BYTES}.`,
|
|
1022
|
-
};
|
|
1023
|
-
}
|
|
1024
|
-
const raw = await fs.readFile(payloadPath, "utf8");
|
|
1025
|
-
return JSON.parse(raw);
|
|
1026
|
-
} catch (err) {
|
|
1027
|
-
return {
|
|
1028
|
-
error: true,
|
|
1029
|
-
status: 0,
|
|
1030
|
-
body: `Could not read payload_path '${payloadPath}': ${err.message}`,
|
|
1031
|
-
};
|
|
1032
|
-
}
|
|
981
|
+
return readPayloadFile(payloadPath);
|
|
1033
982
|
}
|
|
1034
983
|
|
|
1035
984
|
// --- Story Tools ---
|
|
@@ -3927,7 +3876,7 @@ const TOOLS = [
|
|
|
3927
3876
|
payload_path: {
|
|
3928
3877
|
type: "string",
|
|
3929
3878
|
description:
|
|
3930
|
-
"Absolute path to a
|
|
3879
|
+
"Absolute path to a .json file holding the import payload (an object with an `epics` array). Avoids inline size limits for large epics. Ignored if `payload` is also passed.",
|
|
3931
3880
|
},
|
|
3932
3881
|
merge: {
|
|
3933
3882
|
type: "boolean",
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Reads an import_stories payload from an absolute file path.
|
|
2
|
+
//
|
|
3
|
+
// Security: `payload_path` is read with the MCP process's filesystem privileges and an
|
|
4
|
+
// agent can set it via prompt injection, so no file CONTENT is ever returned, and a file
|
|
5
|
+
// that is not an import payload is never uploaded. The refusals still say which check
|
|
6
|
+
// failed (missing, not a file, too large, not JSON, wrong shape) for a .json path; that
|
|
7
|
+
// is metadata an agent needs to fix its own call, not content:
|
|
8
|
+
// * require an absolute path ending in .json, checked on the path AND on its realpath,
|
|
9
|
+
// so a symlink named x.json cannot point at /etc/passwd or /proc
|
|
10
|
+
// * reject /proc, /dev, /sys (pseudo-filesystems that could DoS or leak)
|
|
11
|
+
// * stat first and cap at 5 MiB (server also enforces a body size limit)
|
|
12
|
+
// * never echo a JSON.parse or fs error message: V8's SyntaxError quotes the file's
|
|
13
|
+
// content ("root:x:0:0"... is not valid JSON), so only err.code is reported
|
|
14
|
+
// * require the Epic 12 import shape (an object with an `epics` array) before returning,
|
|
15
|
+
// so a valid JSON file that is not a payload (a credentials file) is never POSTed
|
|
16
|
+
//
|
|
17
|
+
// Returns the parsed payload on success, or an { error, status, body } shape on failure.
|
|
18
|
+
|
|
19
|
+
import nodePath from "node:path";
|
|
20
|
+
import defaultFs from "node:fs/promises";
|
|
21
|
+
|
|
22
|
+
export const MAX_PAYLOAD_BYTES = 5 * 1024 * 1024;
|
|
23
|
+
|
|
24
|
+
const BLOCKED_ROOTS = ["/proc", "/dev", "/sys"];
|
|
25
|
+
|
|
26
|
+
function refuse(body) {
|
|
27
|
+
return { error: true, status: 0, body };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isBlocked(p) {
|
|
31
|
+
return BLOCKED_ROOTS.some((root) => p === root || p.startsWith(root + "/"));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function hasJsonExtension(p) {
|
|
35
|
+
return nodePath.extname(p).toLowerCase() === ".json";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function readPayloadFile(payloadPath, { fs = defaultFs } = {}) {
|
|
39
|
+
if (typeof payloadPath !== "string" || !nodePath.isAbsolute(payloadPath)) {
|
|
40
|
+
return refuse(`payload_path must be absolute (got '${payloadPath}').`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (isBlocked(nodePath.resolve(payloadPath))) {
|
|
44
|
+
return refuse(`payload_path refused: '${payloadPath}' targets a pseudo-filesystem path.`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (!hasJsonExtension(payloadPath)) {
|
|
48
|
+
return refuse(`payload_path refused: '${payloadPath}' must be a .json file.`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let realPath;
|
|
52
|
+
try {
|
|
53
|
+
realPath = await fs.realpath(payloadPath);
|
|
54
|
+
} catch (err) {
|
|
55
|
+
return refuse(`Could not read payload_path '${payloadPath}' (${err.code || "error"}).`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (isBlocked(realPath)) {
|
|
59
|
+
return refuse(`payload_path refused: '${payloadPath}' targets a pseudo-filesystem path.`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (!hasJsonExtension(realPath)) {
|
|
63
|
+
return refuse(`payload_path refused: '${payloadPath}' must be a .json file.`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let raw;
|
|
67
|
+
try {
|
|
68
|
+
const stat = await fs.stat(realPath);
|
|
69
|
+
if (!stat.isFile()) {
|
|
70
|
+
return refuse(`payload_path '${payloadPath}' is not a regular file.`);
|
|
71
|
+
}
|
|
72
|
+
if (stat.size > MAX_PAYLOAD_BYTES) {
|
|
73
|
+
return refuse(
|
|
74
|
+
`payload_path '${payloadPath}' exceeds max ${MAX_PAYLOAD_BYTES} bytes.`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
raw = await fs.readFile(realPath, "utf8");
|
|
78
|
+
} catch (err) {
|
|
79
|
+
return refuse(`Could not read payload_path '${payloadPath}' (${err.code || "error"}).`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let parsed;
|
|
83
|
+
try {
|
|
84
|
+
parsed = JSON.parse(raw);
|
|
85
|
+
} catch {
|
|
86
|
+
return refuse(`payload_path '${payloadPath}' is not valid JSON.`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || !Array.isArray(parsed.epics)) {
|
|
90
|
+
return refuse(
|
|
91
|
+
`payload_path '${payloadPath}' is not an import payload (expected an object with an "epics" array).`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return parsed;
|
|
96
|
+
}
|