Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@
</p>

<p align="center">
<a href="https://github.com/hetaoBackend/MiniMax-Code-Plugins/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/hetaoBackend/MiniMax-Code-Plugins/ci.yml?branch=main&amp;style=flat-square&amp;label=build" alt="Build status" /></a>
<a href="https://github.com/MiniMax-AI/MiniMax-Code-Plugins/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/MiniMax-AI/MiniMax-Code-Plugins/ci.yml?branch=main&amp;style=flat-square&amp;label=build" alt="Build status" /></a>
<img src="https://img.shields.io/badge/Agent_Plugins-1.0-8b5cf6?style=flat-square" alt="Agent Plugins 1.0" />
<img src="https://img.shields.io/github/license/hetaoBackend/MiniMax-Code-Plugins?style=flat-square&amp;color=22c55e" alt="Apache-2.0 license" />
<img src="https://img.shields.io/github/license/MiniMax-AI/MiniMax-Code-Plugins?style=flat-square&amp;color=22c55e" alt="Apache-2.0 license" />
<img src="https://img.shields.io/badge/PRs-welcome-ec4899?style=flat-square" alt="Pull requests welcome" />
</p>

Expand Down
4 changes: 2 additions & 2 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@
</p>

<p align="center">
<a href="https://github.com/hetaoBackend/MiniMax-Code-Plugins/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/hetaoBackend/MiniMax-Code-Plugins/ci.yml?branch=main&amp;style=flat-square&amp;label=build" alt="构建状态" /></a>
<a href="https://github.com/MiniMax-AI/MiniMax-Code-Plugins/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/MiniMax-AI/MiniMax-Code-Plugins/ci.yml?branch=main&amp;style=flat-square&amp;label=build" alt="构建状态" /></a>
<img src="https://img.shields.io/badge/Agent_Plugins-1.0-8b5cf6?style=flat-square" alt="Agent Plugins 1.0" />
<img src="https://img.shields.io/github/license/hetaoBackend/MiniMax-Code-Plugins?style=flat-square&amp;color=22c55e" alt="Apache-2.0 License" />
<img src="https://img.shields.io/github/license/MiniMax-AI/MiniMax-Code-Plugins?style=flat-square&amp;color=22c55e" alt="Apache-2.0 License" />
<img src="https://img.shields.io/badge/PRs-welcome-ec4899?style=flat-square" alt="欢迎提交 PR" />
</p>

Expand Down
82 changes: 67 additions & 15 deletions scripts/lib/validation.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -62,38 +62,57 @@ export function validatePluginManifest(value, label = 'plugin.json') {
}

export function validateSkillText(text, expectedName, label = 'SKILL.md') {
assert(text.startsWith('---\n'), `${label}: YAML frontmatter is required`);
const end = text.indexOf('\n---\n', 4);
assert(!text.startsWith('\uFEFF'), `${label}: UTF-8 BOM is not allowed`);
const normalized = text.includes('\r') ? text.replace(/\r\n?/gu, '\n') : text;
assert(normalized.startsWith('---\n'), `${label}: YAML frontmatter is required`);
const end = normalized.indexOf('\n---\n', 4);
assert(end > 4, `${label}: YAML frontmatter is not closed`);
const frontmatter = text.slice(4, end);
const frontmatter = normalized.slice(4, end);
const name = frontmatter.match(/^name:\s*([^\n]+)$/mu)?.[1]?.trim();
const description = frontmatter.match(/^description:\s*([^\n]+)$/mu)?.[1]?.trim();
assert(name === expectedName, `${label}: frontmatter name must equal ${expectedName}`);
assert(SKILL_NAME.test(name) && name.length <= 64, `${label}: invalid Skill name`);
assert(Boolean(description) && description.length <= 1024, `${label}: description is required and must be at most 1024 characters`);
assert(text.slice(end + 5).trim().length > 0, `${label}: instructions are required`);
assert(normalized.slice(end + 5).trim().length > 0, `${label}: instructions are required`);
return { name, description };
}

export function validateMcp(value, label = 'mcp.json') {
export function validateMcp(value, label = 'mcp.json', options = {}) {
assert(isRecord(value), `${label}: root must be an object`);
assert(value.$schema === MCP_SCHEMA, `${label}: unsupported $schema`);
assert(Object.keys(value).every((key) => ['$schema', 'mcpServers'].includes(key)), `${label}: unknown root field`);
assert(isRecord(value.mcpServers), `${label}: mcpServers must be an object`);
const entries = Object.entries(value.mcpServers);
assert(entries.length <= 8, `${label}: MiniMax Code supports at most 8 MCP servers per plugin`);
for (const [name, server] of entries) {
assert(PLUGIN_NAME.test(name), `${label}: invalid MCP server name ${name}`);
assert(PLUGIN_NAME.test(name) && name.length <= 64, `${label}: invalid MCP server name ${name}`);
assert(isRecord(server), `${label}: MCP server ${name} must be an object`);
if (server.type === 'stdio') {
assert(typeof server.command === 'string' && server.command.length > 0 && (isBareCommand(server.command) || isContainedRelativePath(server.command)), `${label}: ${name} needs a bare executable or contained ./ path`);
assert(server.args === undefined || (Array.isArray(server.args) && server.args.every((item) => typeof item === 'string')), `${label}: ${name}.args must be strings`);
assert(server.env === undefined || (isRecord(server.env) && Object.entries(server.env).every(([key, item]) => !['PLUGIN_ROOT', 'PLUGIN_DATA'].includes(key) && typeof item === 'string')), `${label}: ${name}.env is invalid`);
assert(server.cwd === undefined || (typeof server.cwd === 'string' && /^(?:\.\/|\$\{PLUGIN_ROOT\}(?:\/|$)|\$\{PLUGIN_DATA\}(?:\/|$))/u.test(server.cwd)), `${label}: ${name}.cwd is invalid`);
assert(typeof server.command === 'string' && server.command.length > 0 && server.command.length <= 1024 && !/\x00/u.test(server.command) && (isBareCommand(server.command) || isContainedRelativePath(server.command)), `${label}: ${name} needs a bare executable or contained ./ path without NUL bytes (max 1024 chars)`);
assert(server.args === undefined || (Array.isArray(server.args) && server.args.length <= 1024 && server.args.every((item) => typeof item === 'string' && item.length <= 4096 && !/\x00/u.test(item))), `${label}: ${name}.args must be strings without NUL bytes (max 1024 items, 4096 chars each)`);
assert(server.env === undefined || (isRecord(server.env) && Object.entries(server.env).length <= 256 && Object.entries(server.env).every(([key, item]) => !['PLUGIN_ROOT', 'PLUGIN_DATA'].includes(key) && typeof item === 'string' && !/\x00/u.test(item) && !/\x00/u.test(key))), `${label}: ${name}.env is invalid (max 256 entries)`);
if (server.cwd !== undefined) {
assert(typeof server.cwd === 'string' && server.cwd.length <= 1024, `${label}: ${name}.cwd must be a string of at most 1024 chars`);
resolveCwd(server.cwd, options.pluginRoot, options.pluginData, label, name);
}
assert(Object.keys(server).every((key) => ['type', 'command', 'args', 'env', 'cwd'].includes(key)), `${label}: ${name} has unsupported fields`);
} else if (server.type === 'streamable-http' || server.type === 'sse') {
assert(typeof server.url === 'string' && isSafeRemoteUrl(server.url), `${label}: ${name}.url must be HTTPS or loopback HTTP without credentials or fragment`);
assert(server.headers === undefined || (isRecord(server.headers) && Object.values(server.headers).every((item) => typeof item === 'string')), `${label}: ${name}.headers must contain strings`);
assert(server.headers === undefined || (isRecord(server.headers) && Object.entries(server.headers).length <= 100 && Object.entries(server.headers).every(([key, item]) => {
// Trim before matching so trailing whitespace cannot smuggle a credential header past the blacklist.
const trimmedKey = key.trim();
const reservedKeys = ['PLUGIN_ROOT', 'PLUGIN_DATA'];
const credentialHeaders = /^(authorization|cookie|set-cookie|proxy-authorization|x-api-key|x-auth-token|x-access-token|x-token|x-secret|x-api-token|api-key|auth-token|access-token)$/iu;
// \x0a-\x1f covers LF (\x0a) and CR (\x0d); tab (\x09) is allowed as RFC 7230 OWS.
const controlBytes = /[\x00-\x08\x0a-\x1f\x7f]/u;
return !reservedKeys.includes(trimmedKey)
&& !credentialHeaders.test(trimmedKey)
&& typeof item === 'string'
&& item.length <= 8192
&& key.length <= 256
&& !controlBytes.test(item)
&& !controlBytes.test(key);
})), `${label}: ${name}.headers must be strings without reserved keys, credentials, or control bytes (NUL/CR/LF/>0x7f; tab is allowed as RFC 7230 OWS; max 100 entries, key 256 chars, value 8192 chars)`);
assert(Object.keys(server).every((key) => ['type', 'url', 'headers'].includes(key)), `${label}: ${name} has unsupported fields`);
} else {
throw new Error(`${label}: ${name} uses unsupported transport ${String(server.type)}`);
Expand Down Expand Up @@ -123,7 +142,38 @@ function isSafeRemoteUrl(value) {
}
}

export async function validatePluginDirectory(root) {
function isContainedWithin(parent, child) {
if (typeof parent !== 'string' || typeof child !== 'string') return false;
const rel = path.relative(parent, child);
if (rel === '') return true;
return !rel.startsWith('..') && !path.isAbsolute(rel);
}

export function resolveCwd(cwd, pluginRoot, pluginData, label = 'mcp.json', serverName = 'cwd') {
assert(typeof cwd === 'string' && cwd.length > 0, `${label}: ${serverName}.cwd must be a non-empty string`);
assert(!path.isAbsolute(cwd), `${label}: ${serverName}.cwd must be relative: ${cwd}`);
assert(!cwd.includes('\\'), `${label}: ${serverName}.cwd must use forward slashes: ${cwd}`);
assert(!/\x00/u.test(cwd), `${label}: ${serverName}.cwd must not contain NUL bytes: ${cwd}`);
let resolved;
if (cwd.startsWith('./')) {
assert(typeof pluginRoot === 'string' && pluginRoot.length > 0, `${label}: ${serverName}.cwd requires a plugin root to resolve ${cwd}`);
resolved = path.resolve(pluginRoot, cwd);
} else if (cwd.startsWith('${PLUGIN_ROOT}')) {
assert(typeof pluginRoot === 'string' && pluginRoot.length > 0, `${label}: ${serverName}.cwd requires a plugin root to resolve ${cwd}`);
resolved = path.resolve(pluginRoot, cwd.slice('${PLUGIN_ROOT}'.length).replace(/^\/+/u, ''));
} else if (cwd.startsWith('${PLUGIN_DATA}')) {
assert(typeof pluginData === 'string' && pluginData.length > 0, `${label}: ${serverName}.cwd requires a plugin data directory to resolve ${cwd}`);
resolved = path.resolve(pluginData, cwd.slice('${PLUGIN_DATA}'.length).replace(/^\/+/u, ''));
} else {
throw new Error(`${label}: ${serverName}.cwd must start with "./", "\${PLUGIN_ROOT}", or "\${PLUGIN_DATA}": ${cwd}`);
}
const insideRoot = isContainedWithin(pluginRoot, resolved);
const insideData = typeof pluginData === 'string' && pluginData.length > 0 && isContainedWithin(pluginData, resolved);
assert(insideRoot || insideData, `${label}: ${serverName}.cwd escapes the Plugin sandbox: ${cwd}`);
return resolved;
}

export async function validatePluginDirectory(root, options = {}) {
const manifestPath = path.join(root, 'plugin.json');
const manifest = validatePluginManifest(parseJson(await readFile(manifestPath, 'utf8'), manifestPath), manifestPath);
const skills = [];
Expand All @@ -143,19 +193,21 @@ export async function validatePluginDirectory(root) {
let mcpServers = [];
const mcpPath = path.join(root, 'mcp.json');
try {
mcpServers = validateMcp(parseJson(await readFile(mcpPath, 'utf8'), mcpPath), mcpPath);
mcpServers = validateMcp(parseJson(await readFile(mcpPath, 'utf8'), mcpPath), mcpPath, options);
} catch (error) {
if (error.code !== 'ENOENT') throw error;
}
assert(skills.length + mcpServers.length > 0, `${root}: plugin must expose at least one Skill or MCP server`);
return { manifest, skills: skills.sort(), mcpServers };
}

export async function validateHostedPluginDirectory(root, { owner, pluginName }) {
export async function validateHostedPluginDirectory(root, { owner, pluginName, pluginRoot, pluginData } = {}) {
assert(OWNER_NAME.test(owner), `${root}: invalid GitHub owner directory ${owner}`);
assert(PLUGIN_NAME.test(pluginName) && pluginName.length <= 64, `${root}: invalid Plugin directory ${pluginName}`);
await assertNoSymlinks(root);
const result = await validatePluginDirectory(root);
const resolvedPluginRoot = pluginRoot ?? root;
const resolvedPluginData = pluginData ?? path.join(root, '.data');
const result = await validatePluginDirectory(root, { pluginRoot: resolvedPluginRoot, pluginData: resolvedPluginData });
assert(result.manifest.name === pluginName, `${root}: plugin.json name must equal directory name ${pluginName}`);
assert(typeof result.manifest.license === 'string' && result.manifest.license.length > 0, `${root}: plugin.json must declare a license`);
for (const file of ['README.md', 'LICENSE']) {
Expand Down
70 changes: 70 additions & 0 deletions test/hosted-plugins.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -185,3 +185,73 @@ test('scaffold rejects paths that cannot identify a GitHub owner and portable Pl
);
}
});

test('hosted Plugin accepts a stdio server whose cwd stays inside the sandbox', async (context) => {
const workspace = await mkdtemp(path.join(os.tmpdir(), 'minimax-code-plugin-cwd-safe-'));
context.after(async () => {
const { rm } = await import('node:fs/promises');
await rm(workspace, { recursive: true, force: true });
});
const pluginRoot = path.join(workspace, 'plugins', 'alice', 'hello-world');
const skillsRoot = path.join(pluginRoot, 'skills', 'hello-world');
await mkdir(skillsRoot, { recursive: true });
await Promise.all([
writeFile(path.join(pluginRoot, 'plugin.json'), `${JSON.stringify({
$schema: 'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json',
name: 'hello-world',
version: '1.0.0',
description: 'Greets MiniMax Code users with a reusable Skill and a sandboxed stdio MCP server.',
license: 'Apache-2.0',
})}\n`),
writeFile(path.join(pluginRoot, 'README.md'), '# Hello World\n\nUse the Skill and MCP server safely.\n'),
writeFile(path.join(pluginRoot, 'LICENSE'), 'Apache License\nVersion 2.0\n'),
writeFile(path.join(skillsRoot, 'SKILL.md'), '---\nname: hello-world\ndescription: Greet the user when they ask MiniMax Code to say hello.\n---\n\n# Instructions\n\nRespond with a friendly greeting.\n'),
writeFile(path.join(pluginRoot, 'mcp.json'), `${JSON.stringify({
$schema: 'https://agent-plugins.org/schemas/1.0.0/mcp.schema.json',
mcpServers: {
local: { type: 'stdio', command: './server.js', cwd: '${PLUGIN_DATA}/subdir' },
},
})}\n`),
]);

const result = await validateHostedPluginDirectory(pluginRoot, {
owner: 'alice',
pluginName: 'hello-world',
});
assert.equal(result.id, 'alice/hello-world');
assert.deepEqual(result.mcpServers, ['local']);
});

test('hosted Plugin rejects a stdio server whose cwd escapes the sandbox', async (context) => {
const workspace = await mkdtemp(path.join(os.tmpdir(), 'minimax-code-plugin-cwd-escape-'));
context.after(async () => {
const { rm } = await import('node:fs/promises');
await rm(workspace, { recursive: true, force: true });
});
const pluginRoot = path.join(workspace, 'plugins', 'alice', 'hello-world');
const skillsRoot = path.join(pluginRoot, 'skills', 'hello-world');
await mkdir(skillsRoot, { recursive: true });
await Promise.all([
writeFile(path.join(pluginRoot, 'plugin.json'), `${JSON.stringify({
$schema: 'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json',
name: 'hello-world',
version: '1.0.0',
description: 'Greets MiniMax Code users with a malicious stdio MCP server.',
license: 'Apache-2.0',
})}\n`),
writeFile(path.join(pluginRoot, 'README.md'), '# Hello World\n\nUse the Skill and MCP server safely.\n'),
writeFile(path.join(pluginRoot, 'LICENSE'), 'Apache License\nVersion 2.0\n'),
writeFile(path.join(skillsRoot, 'SKILL.md'), '---\nname: hello-world\ndescription: Greet the user when they ask MiniMax Code to say hello.\n---\n\n# Instructions\n\nRespond with a friendly greeting.\n'),
writeFile(path.join(pluginRoot, 'mcp.json'), `${JSON.stringify({
$schema: 'https://agent-plugins.org/schemas/1.0.0/mcp.schema.json',
mcpServers: {
bad: { type: 'stdio', command: './server.js', cwd: '${PLUGIN_DATA}/../../etc' },
},
})}\n`),
]);

await assert.rejects(
validateHostedPluginDirectory(pluginRoot, { owner: 'alice', pluginName: 'hello-world' }),
/escapes the Plugin sandbox/u,
);
});
Loading