Advisory Details
Title: Mercury Agent raw skill install trusts attacker-controlled skill names and writes SKILL.md outside the intended skills root
Description:
Summary
Mercury Agent's authenticated raw skill install flow accepts an attacker-controlled remote SKILL.md, reads the frontmatter name, and uses that value directly as a filesystem path segment under MERCURY_HOME/skills. Because the raw installer does not reject .. or enforce post-join containment, a logged-in web user can install a remote skill whose declared name escapes the skills root and causes Mercury to create attacker-controlled SKILL.md files elsewhere on disk. I verified this through both POST /api/skills/install and the slash-command path reachable through POST /api/chat/send.
Details
The direct web API path validates only that the submitted string is an http(s) URL and then hands it to SkillLoader.installFromUrl():
system.post('/api/skills/install', async (c) => {
const body = await c.req.json();
const url = String(body?.url || '').trim();
// ...
const loader = new SkillLoader();
const installed = await loader.installFromUrl(url);
return c.json({ success: true, name: installed.name, path: installed.skillDir });
});
The chat entrypoint also accepts attacker-controlled content and forwards it into the normal web agent loop:
chat.post('/api/chat/send', async (c) => {
const body = await c.req.json<{ content: string; threadId?: string }>();
const threadId = (body.threadId && body.threadId.trim()) ? body.threadId.trim() : 'web:default';
webChannel.emitMessageInThread(body.content.trim(), threadId);
return c.json({ sent: true });
});
Once the user sends /skills install <url>, the slash-command handler treats the argument as a raw URL and reuses the same loader path:
if (/^https?:\/\//i.test(arg)) {
const { SkillLoader } = await import('../skills/loader.js');
const loader = new SkillLoader();
const installed = await loader.installFromUrl(arg);
await channel.send(`✅ Installed \`${installed.name}\` from URL.\n${installed.skillDir}`, channelId);
}
The root cause is in src/skills/loader.ts. installFromContent() parses the remote file and passes the untrusted frontmatter name directly to saveSkill():
installFromContent(content: string): { name: string; skillDir: string } {
const parsed = parseSkillMd(content);
const skillDir = this.saveSkill(parsed.meta.name, content);
return { name: parsed.meta.name, skillDir };
}
saveSkill() then joins that string with the local skills directory and writes SKILL.md with no rejection of traversal tokens or post-resolution containment check:
saveSkill(name: string, content: string): string {
const skillDir = join(this.skillsDir, name);
if (!existsSync(skillDir)) {
mkdirSync(skillDir, { recursive: true });
}
writeFileSync(join(skillDir, SKILL_FILE), content, 'utf-8');
return skillDir;
}
As a result, a remote payload like:
---
name: ../../outside-api/owned-skill
description: path traversal payload
---
causes Mercury to create:
<run-root>/outside-api/owned-skill/SKILL.md
instead of staying under:
I re-ran the exploit end-to-end against the real local Mercury daemon built from this repository. The exploit path wrote attacker-controlled files outside the intended skills root through both public authenticated interfaces, while a same-interface control case using name: safe-skill stayed inside .../skills/safe-skill/SKILL.md and did not create any outside file.
For released versions, the direct raw-install API is present by GitHub release tag v1.1.9, the chat slash-command URL install path is present by v1.1.11, and the latest published npm package and GitHub release I could verify are both 1.1.13 / v1.1.13, which still contain the bug. I also checked the separate upstream tag v1.2.0; it still appears vulnerable on the raw web install path, so I could not identify a patch.
PoC
Prerequisites
- A local checkout of
cosmicstack-labs/mercury-agent
- Node.js and Python 3 installed
- The Mercury build artifacts present at
dist/index.js
curl and nc available for the harness liveness checks
- The three downloaded PoC files kept in the same directory
- Run from the Mercury repository root, or set
MERCURY_REPO_ROOT=/path/to/mercury-agent
Reproduction Steps
- Download the PoC harness from: harness.py
- Download the exploit driver from: verification_test.py
- Download the same-interface control from: control-safe-install.py
- In the Mercury repository root, ensure the project is built:
npm install && npm run build
- Run the exploit case:
python3 verification_test.py
- The script starts real Mercury web daemons, serves attacker-controlled
SKILL.md payloads locally, logs in through Mercury's default web authentication, and exercises both:
POST /api/skills/install
and
POST /api/chat/send with /skills install <url>
- Confirm the exploit output shows:
Direct API traversal escaped skills root: True
and
Slash-command traversal escaped skills root: True
- Confirm the created outside files exist:
runs/verification-run/outside-api/owned-skill/SKILL.md
and
runs/verification-run/outside-chat/owned-skill/SKILL.md
- Run the control case:
python3 control-safe-install.py
- Confirm the control output shows:
Control install remained in skills root: True
- Confirm the control case creates only:
runs/control-run/control-api-home/skills/safe-skill/SKILL.md
and does not create:
runs/control-run/outside-api/owned-skill/SKILL.md
Log of Evidence
Exploit rerun:
Verification mode: End-to-End
[DEFECT-CONFIRMED]
Direct API traversal escaped skills root: True
Slash-command traversal escaped skills root: True
response={"name": "../../outside-api/owned-skill", "path": ".../runs/verification-run/outside-api/owned-skill", "success": true}
outside_target=.../runs/verification-run/outside-api/owned-skill/SKILL.md exists=True
send_response={"sent": true}
outside_target=.../runs/verification-run/outside-chat/owned-skill/SKILL.md exists=True
Key exploit evidence from verification_result.json:
{
"classification": "DEFECT-CONFIRMED",
"api": {
"response": {
"name": "../../outside-api/owned-skill",
"success": true
},
"outside_exists": true
},
"chat": {
"response": {
"sent": true
},
"outside_exists": true
}
}
Control rerun:
Verification mode: End-to-End
[CONTROL-PASS]
Control install remained in skills root: True
response={"name": "safe-skill", "path": ".../runs/control-run/control-api-home/skills/safe-skill", "success": true}
outside_target=.../runs/control-run/outside-api/owned-skill/SKILL.md exists=False
Key control evidence from control_result.json:
{
"classification": "CONTROL-PASS",
"control": {
"response": {
"name": "safe-skill",
"success": true
},
"safe_exists": true,
"outside_exists": false
}
}
Impact
This is an authenticated path traversal / arbitrary relative write vulnerability on Mercury's raw skill install surface. A logged-in Mercury web user can cause the Mercury process to create attacker-controlled SKILL.md files outside the intended install root, subject only to the process user's underlying OS write permissions. The filename is constrained to SKILL.md, but that is still enough to poison other writable Mercury-related directories, plant persistent skill content in sibling locations, or disrupt filesystem layout assumptions relied on by the host. In practice this crosses the trust boundary from authenticated web input and remote skill metadata into unrestricted filesystem writes outside MERCURY_HOME/skills.
Affected products
- Ecosystem: npm
- Package name:
@cosmicstack/mercury-agent
- Affected versions:
>= 1.1.9, <= 1.1.13
- Patched versions:
Severity
- Severity: High
- Vector string:
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:L
Weaknesses
- CWE: CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')
Occurrences
| Permalink |
Description |
|
system.post('/api/skills/install', async (c) => { |
|
const body = await c.req.json(); |
|
const url = String(body?.url || '').trim(); |
|
if (!url) return c.json({ success: false, error: 'url is required' }, 400); |
|
try { |
|
const parsed = new URL(url); |
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { |
|
return c.json({ success: false, error: 'url must start with http:// or https://' }, 400); |
|
} |
|
} catch { |
|
return c.json({ success: false, error: 'invalid url' }, 400); |
|
} |
|
|
|
try { |
|
const loader = new SkillLoader(); |
|
const installed = await loader.installFromUrl(url); |
|
return c.json({ success: true, name: installed.name, path: installed.skillDir }); |
|
The authenticated raw install endpoint validates only URL syntax/protocol and then passes the attacker-controlled URL into SkillLoader.installFromUrl(). |
|
chat.post('/api/chat/send', async (c) => { |
|
if (!webChannel) { |
|
return c.json({ error: 'Web channel not initialized' }, 503); |
|
} |
|
|
|
const body = await c.req.json<{ content: string; threadId?: string }>(); |
|
if (!body.content?.trim()) { |
|
return c.json({ error: 'Message content required' }, 400); |
|
} |
|
|
|
try { |
|
const threadId = (body.threadId && body.threadId.trim()) ? body.threadId.trim() : 'web:default'; |
|
webChannel.emitMessageInThread(body.content.trim(), threadId); |
|
return c.json({ sent: true }); |
|
The authenticated web chat endpoint forwards attacker-controlled slash-command content into the normal agent execution path. |
|
if (/^https?:\/\//i.test(arg)) { |
|
const { SkillLoader } = await import('../skills/loader.js'); |
|
const loader = new SkillLoader(); |
|
await channel.send(`📦 Installing from \`${arg}\`…`, channelId); |
|
const installed = await loader.installFromUrl(arg); |
|
await channel.send( |
|
`✅ Installed \`${installed.name}\` from URL.\n${installed.skillDir}`, |
|
channelId, |
|
The /skills install <url> handler treats URL arguments as raw skill installs and reuses loader.installFromUrl(arg) with no name/path validation. |
|
installFromContent(content: string): { name: string; skillDir: string } { |
|
const parsed = parseSkillMd(content); |
|
if (!parsed) { |
|
throw new Error('Invalid SKILL.md: missing or malformed YAML frontmatter with name and description'); |
|
} |
|
const skillDir = this.saveSkill(parsed.meta.name, content); |
|
return { name: parsed.meta.name, skillDir }; |
|
installFromContent() parses the remote SKILL.md and passes the untrusted frontmatter parsed.meta.name directly to saveSkill(). |
|
saveSkill(name: string, content: string): string { |
|
const skillDir = join(this.skillsDir, name); |
|
if (!existsSync(skillDir)) { |
|
mkdirSync(skillDir, { recursive: true }); |
|
} |
|
writeFileSync(join(skillDir, SKILL_FILE), content, 'utf-8'); |
|
saveSkill() builds join(this.skillsDir, name) and writes SKILL.md there without rejecting traversal segments or enforcing install-root containment. |
Advisory Details
Title: Mercury Agent raw skill install trusts attacker-controlled skill names and writes
SKILL.mdoutside the intended skills rootDescription:
Summary
Mercury Agent's authenticated raw skill install flow accepts an attacker-controlled remote
SKILL.md, reads the frontmattername, and uses that value directly as a filesystem path segment underMERCURY_HOME/skills. Because the raw installer does not reject..or enforce post-join containment, a logged-in web user can install a remote skill whose declared name escapes the skills root and causes Mercury to create attacker-controlledSKILL.mdfiles elsewhere on disk. I verified this through bothPOST /api/skills/installand the slash-command path reachable throughPOST /api/chat/send.Details
The direct web API path validates only that the submitted string is an
http(s)URL and then hands it toSkillLoader.installFromUrl():The chat entrypoint also accepts attacker-controlled content and forwards it into the normal web agent loop:
Once the user sends
/skills install <url>, the slash-command handler treats the argument as a raw URL and reuses the same loader path:The root cause is in
src/skills/loader.ts.installFromContent()parses the remote file and passes the untrusted frontmatternamedirectly tosaveSkill():saveSkill()then joins that string with the local skills directory and writesSKILL.mdwith no rejection of traversal tokens or post-resolution containment check:As a result, a remote payload like:
causes Mercury to create:
instead of staying under:
I re-ran the exploit end-to-end against the real local Mercury daemon built from this repository. The exploit path wrote attacker-controlled files outside the intended skills root through both public authenticated interfaces, while a same-interface control case using
name: safe-skillstayed inside.../skills/safe-skill/SKILL.mdand did not create any outside file.For released versions, the direct raw-install API is present by GitHub release tag
v1.1.9, the chat slash-command URL install path is present byv1.1.11, and the latest published npm package and GitHub release I could verify are both1.1.13/v1.1.13, which still contain the bug. I also checked the separate upstream tagv1.2.0; it still appears vulnerable on the raw web install path, so I could not identify a patch.PoC
Prerequisites
cosmicstack-labs/mercury-agentdist/index.jscurlandncavailable for the harness liveness checksMERCURY_REPO_ROOT=/path/to/mercury-agentReproduction Steps
npm install && npm run buildpython3 verification_test.pySKILL.mdpayloads locally, logs in through Mercury's default web authentication, and exercises both:POST /api/skills/installand
POST /api/chat/sendwith/skills install <url>Direct API traversal escaped skills root: Trueand
Slash-command traversal escaped skills root: Trueruns/verification-run/outside-api/owned-skill/SKILL.mdand
runs/verification-run/outside-chat/owned-skill/SKILL.mdpython3 control-safe-install.pyControl install remained in skills root: Trueruns/control-run/control-api-home/skills/safe-skill/SKILL.mdand does not create:
runs/control-run/outside-api/owned-skill/SKILL.mdLog of Evidence
Exploit rerun:
Key exploit evidence from
verification_result.json:{ "classification": "DEFECT-CONFIRMED", "api": { "response": { "name": "../../outside-api/owned-skill", "success": true }, "outside_exists": true }, "chat": { "response": { "sent": true }, "outside_exists": true } }Control rerun:
Key control evidence from
control_result.json:{ "classification": "CONTROL-PASS", "control": { "response": { "name": "safe-skill", "success": true }, "safe_exists": true, "outside_exists": false } }Impact
This is an authenticated path traversal / arbitrary relative write vulnerability on Mercury's raw skill install surface. A logged-in Mercury web user can cause the Mercury process to create attacker-controlled
SKILL.mdfiles outside the intended install root, subject only to the process user's underlying OS write permissions. The filename is constrained toSKILL.md, but that is still enough to poison other writable Mercury-related directories, plant persistent skill content in sibling locations, or disrupt filesystem layout assumptions relied on by the host. In practice this crosses the trust boundary from authenticated web input and remote skill metadata into unrestricted filesystem writes outsideMERCURY_HOME/skills.Affected products
@cosmicstack/mercury-agent>= 1.1.9, <= 1.1.13Severity
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:LWeaknesses
Occurrences
mercury-agent/src/web/api/system.ts
Lines 37 to 53 in 0de8955
SkillLoader.installFromUrl().mercury-agent/src/web/api/chat.ts
Lines 110 to 123 in 0de8955
mercury-agent/src/core/agent.ts
Lines 2904 to 2911 in 0de8955
/skills install <url>handler treats URL arguments as raw skill installs and reusesloader.installFromUrl(arg)with no name/path validation.mercury-agent/src/skills/loader.ts
Lines 279 to 285 in 0de8955
installFromContent()parses the remoteSKILL.mdand passes the untrusted frontmatterparsed.meta.namedirectly tosaveSkill().mercury-agent/src/skills/loader.ts
Lines 209 to 214 in 0de8955
saveSkill()buildsjoin(this.skillsDir, name)and writesSKILL.mdthere without rejecting traversal segments or enforcing install-root containment.