Skip to content

[Security] Mercury Agent filesystem write tools follow in-scope symlinks and modify files outside approved scopes #105

Description

@YLChen-007

Advisory Details

Title: Mercury Agent filesystem write tools follow in-scope symlinks and modify files outside approved scopes

Description:

Summary

Mercury Agent's exported filesystem write tools authorize only the lexical pathname supplied to create_file, write_file, and edit_file. If that pathname is an approved in-workspace symlink whose target is outside the approved scope, Node follows the symlink at the sink and Mercury creates or modifies the outside file anyway. This breaks Mercury's folder-level write confinement guarantee and lets an authenticated Mercury user or model-driven tool call write beyond the approved workspace boundary.

Details

The vulnerable tool flow is registered through Mercury's normal capability surface in CapabilityRegistry.registerAll():

if (manifest.capabilities.filesystem.enabled) {
  this.tools.write_file = createWriteFileTool(this.permissions, () => this.getCwd());
  this.tools.create_file = createCreateFileTool(this.permissions, () => this.getCwd());
  this.tools.edit_file = createEditFileTool(this.permissions, () => this.getCwd());
}

The actual authorization problem is in PermissionManager.checkFsAccess(). For write operations it only resolves the user-controlled pathname and checks whether the resulting string falls under an approved scope:

const resolved = resolve(path);
const scope = this.findScope(resolved);
const tempScope = this.findTempScope(resolved);

if (mode === 'write' && !this.autoApproveAll && this.askHandler && this.currentChannelType !== 'internal') {
  const scopeAllows = (scope && scope.write) || (tempScope && tempScope.write);
  if (scopeAllows) {
    const result = await this.askHandler(`Write to file: ${resolved}`);
    if (result === 'yes') return { allowed: true };

findScope() is also lexical-only. It treats a path as in scope when the string matches or is prefixed by an approved scope path:

private findScope(resolvedPath: string): FileScope | undefined {
  for (const scope of scopes) {
    const scopeResolved = resolve(scope.path.replace(/^~/, homedir()));
    if (resolvedPath === scopeResolved || resolvedPath.startsWith(scopeResolved + sep)) {
      return scope;
    }
  }
}

After that approval step, the three write sinks immediately hand the same pathname to Node's filesystem APIs without canonicalizing the final target or rejecting symlinks:

const resolved = isAbsolute(path) ? resolve(path) : resolve(getCwd(), path);
const check = await permissions.checkFsAccess(resolved, 'write');
// ...
writeFileSync(resolved, content, 'utf-8');

edit_file performs the same pattern after first reading through the symlink with readFileSync() and then writing the replacement back with writeFileSync().

I verified the issue using Mercury's real exported create_file, write_file, and edit_file tools, the real CapabilityRegistry.registerAll() registration path, the real PermissionManager, and the real Node filesystem sinks. In the exploit run, a temporary workspace contained symlink aliases such as workspace/jump-write that pointed to files in a sibling outside/ directory. Mercury approved those alias paths because they were lexically inside the workspace and then wrote through them, producing all three out-of-scope mutations:

  • create_file created outside/created-outside.txt with CANARY_CREATE_2026_06_17
  • write_file overwrote outside/written-outside.txt with CANARY_WRITE_2026_06_17
  • edit_file replaced CANARY_EDIT_OLD_2026_06_17 with CANARY_EDIT_NEW_2026_06_17 in outside/edited-outside.txt

The same harness also ran a control case with direct out-of-scope paths under the same interface. Those direct paths were denied and the baseline outside files remained unchanged. That matters because it isolates the defect to the symlink alias bypass rather than a generic absence of write restrictions.

The latest published npm package and latest GitHub release I could verify are both 1.1.13, and that release still contains the vulnerable logic. There is an upstream v1.2.0 tag that also appears vulnerable, but it is not part of the published npm release line as of July 7, 2026, so the affected version below is bounded to the latest shipped package/release.

PoC

Prerequisites

  • A local checkout of cosmicstack-labs/mercury-agent
  • Node.js and Python 3 installed
  • Project dependencies installed so npx tsx can execute the TypeScript driver
  • Ability to run the scripts from the Mercury repository root
  • No privileged OS access is required beyond the permissions Mercury already has on the target filesystem

Reproduction Steps

  1. Download the TypeScript tool driver from: tool_driver.ts
  2. Download the exploit script from: verification_test.py
  3. Download the same-interface control script from: control-direct-outside.py
  4. In the Mercury repository root, place all three downloaded files in the same directory.
  5. Install dependencies if needed:
    npm install
  6. Run the exploit case from the Mercury repository root:
    python3 verification_test.py
  7. The script will create a temporary workspace, create in-workspace symlink aliases to outside files, invoke Mercury's real create_file, write_file, and edit_file tools through CapabilityRegistry.registerAll(), and write verification_result.json plus verification_observation.log.
  8. Confirm the exploit output includes Verification mode: Integration-Test followed by [DEFECT-CONFIRMED-WITH-LIMITATIONS].
  9. Confirm verification_result.json shows out-of-scope mutations for all three cases and that verification_observation.log lists the newly created or modified files under the outside directory.
  10. Run the control case from the same repository root:
    python3 control-direct-outside.py
  11. Confirm the control output includes [CONTROL-BLOCKED] and that control_result.json shows direct outside paths were denied.

Log of Evidence

Exploit run:

Verification mode: Integration-Test
[DEFECT-CONFIRMED-WITH-LIMITATIONS]

Key exploit evidence from verification_result.json:

{
  "create": {
    "stdout": "Successfully created /tmp/.../workspace/jump-create (24 bytes)",
    "outside_exists": true,
    "outside_content": "CANARY_CREATE_2026_06_17"
  },
  "write": {
    "stdout": "Successfully wrote 23 bytes to /tmp/.../workspace/jump-write",
    "outside_content": "CANARY_WRITE_2026_06_17"
  },
  "edit": {
    "stdout": "Edited /tmp/.../workspace/jump-edit: replaced 1 line(s) with 1 line(s)",
    "outside_content": "CANARY_EDIT_NEW_2026_06_17"
  }
}

Independent outside-directory observation from verification_observation.log:

total 20
-rw-r--r-- 1 root root   24 ... created-outside.txt
-rw-r--r-- 1 root root   26 ... edited-outside.txt
-rw-r--r-- 1 root root   23 ... written-outside.txt

Control run:

Verification mode: Integration-Test
[CONTROL-BLOCKED]

Key control evidence from control_result.json:

{
  "create": {
    "stdout": "Error: Permission denied for write access to /tmp/.../outside/direct-create.txt...",
    "exists": false
  },
  "write": {
    "stdout": "Error: Permission denied for write access to /tmp/.../outside/direct-write.txt...",
    "content": "ORIGINAL_WRITE_2026_06_17"
  },
  "edit": {
    "stdout": "Error: Permission denied for write access to /tmp/.../outside/direct-edit.txt...",
    "content": "CANARY_EDIT_OLD_2026_06_17"
  }
}

Impact

This is a path-traversal / scope-bypass vulnerability in Mercury's filesystem write tools. Any Mercury deployment that relies on folder-level filesystem scopes to constrain agent writes can have that boundary bypassed through an approved symlink alias. An attacker who can influence Mercury tool calls can create new files outside the approved workspace, overwrite existing files outside the approved workspace, or surgically edit existing files outside the approved workspace, subject only to the Mercury process's underlying OS-level file permissions. That can lead to source-code tampering, configuration corruption, secret replacement, persistence, or other local integrity compromise.

Affected products

  • Ecosystem: npm
  • Package name: @cosmicstack/mercury-agent
  • Affected versions: <= 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:H

Weaknesses

  • CWE: CWE-59: Improper Link Resolution Before File Access ('Link Following')

Occurrences

Permalink Description
registerAll(): void {
const manifest = this.permissions.getManifest();
if (manifest.capabilities.filesystem.enabled) {
this.tools.read_file = createReadFileTool(this.permissions, () => this.getCwd());
this.tools.write_file = createWriteFileTool(this.permissions, () => this.getCwd());
this.tools.create_file = createCreateFileTool(this.permissions, () => this.getCwd());
this.tools.list_dir = createListDirTool(this.permissions, () => this.getCwd());
this.tools.delete_file = createDeleteFileTool(this.permissions, () => this.getCwd());
this.tools.edit_file = createEditFileTool(this.permissions, () => this.getCwd());
CapabilityRegistry.registerAll() exposes create_file, write_file, and edit_file through Mercury's normal filesystem capability surface.
async checkFsAccess(path: string, mode: 'read' | 'write'): Promise<{ allowed: boolean; reason?: string }> {
if (mode === 'read' && this.elevatedCommands.has('fs_read')) {
return { allowed: true };
}
if (mode === 'write' && this.elevatedCommands.has('fs_write')) {
return { allowed: true };
}
const fs = this.manifest.capabilities.filesystem;
if (!fs.enabled) {
return { allowed: false, reason: 'Filesystem capability is disabled' };
}
const resolved = resolve(path);
const scope = this.findScope(resolved);
const tempScope = this.findTempScope(resolved);
// Read access: allow if any scope covers it (reads are safe in any mode)
if (mode === 'read') {
if (scope && scope.read) return { allowed: true };
if (tempScope && tempScope.read) return { allowed: true };
}
// Write access: in auto-approve-all mode, allow if scope covers it
if (mode === 'write' && this.autoApproveAll) {
if (scope && scope.write) return { allowed: true };
if (tempScope && tempScope.write) return { allowed: true };
}
// Write access in ask-me mode: ALWAYS prompt the user, even if scope exists
if (mode === 'write' && !this.autoApproveAll && this.askHandler && this.currentChannelType !== 'internal') {
const scopeAllows = (scope && scope.write) || (tempScope && tempScope.write);
if (scopeAllows) {
// Scope allows it, but user wants to confirm — prompt with file path
const result = await this.askHandler(`Write to file: ${resolved}`);
if (result === 'yes') return { allowed: true };
if (result === 'always') {
// User chose "always" — switch to auto-approve for the rest of this session
this.autoApproveAll = true;
return { allowed: true };
}
return { allowed: false, reason: `User denied write to ${path}` };
}
// No scope covers it — request scope expansion
return this.requestScopeExternal(path, mode);
}
checkFsAccess() authorizes write access based on the lexically resolved pathname and, in ask mode, prompts for Write to file: <resolved> without validating the final canonical target.
private findScope(resolvedPath: string): FileScope | undefined {
const scopes = this.manifest.capabilities.filesystem.scopes;
for (const scope of scopes) {
const scopeResolved = resolve(scope.path.replace(/^~/, homedir()));
if (resolvedPath === scopeResolved || resolvedPath.startsWith(scopeResolved + sep)) {
return scope;
}
}
return undefined;
findScope() treats a path as allowed when the resolved string equals or is prefixed by an approved scope path, with no realpath or symlink-target enforcement.
const resolved = isAbsolute(path) ? resolve(path) : resolve(getCwd(), path);
const check = await permissions.checkFsAccess(resolved, 'write');
if (!check.allowed) {
const parentDir = resolve(resolved, '..');
return `Error: Permission denied for write access to ${resolved}. Use the approve_scope tool with path="${parentDir}" and mode="write" to request access from the user.`;
}
if (existsSync(resolved)) {
return `Error: File already exists: ${resolved}. Use write_file to modify existing files.`;
}
try {
const dir = dirname(resolved);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
writeFileSync(resolved, content, 'utf-8');
return `Successfully created ${resolved} (${content.length} bytes)`;
create_file approves the alias path and then calls writeFileSync(resolved, ...), allowing an in-scope symlink to create an out-of-scope file.
const resolved = isAbsolute(path) ? resolve(path) : resolve(getCwd(), path);
const check = await permissions.checkFsAccess(resolved, 'write');
if (!check.allowed) {
const parentDir = resolve(resolved, '..');
return `Error: Permission denied for write access to ${resolved}. Use the approve_scope tool with path="${parentDir}" and mode="write" to request access from the user.`;
}
if (!existsSync(resolved)) {
return `Error: File not found: ${resolved}. Use create_file to create new files.`;
}
try {
writeFileSync(resolved, content, 'utf-8');
return `Successfully wrote ${content.length} bytes to ${resolved}`;
write_file performs the same lexical approval and then overwrites the symlink target with writeFileSync(resolved, ...).
const resolved = isAbsolute(path) ? resolve(path) : resolve(getCwd(), path);
const fsCheck = await permissions.checkFsAccess(resolved, 'write');
if (!fsCheck.allowed) {
const parentDir = resolve(resolved, '..');
return `Error: Permission denied for write access to ${resolved}. Use the approve_scope tool with path="${parentDir}" and mode="write" to request access from the user.`;
}
try {
const content = readFileSync(resolved, 'utf-8');
const count = content.split(old_string).length - 1;
if (count === 0) {
return `Error: old_string not found in ${path}. Make sure the text matches exactly, including whitespace and indentation.`;
}
if (count > 1) {
return `Error: old_string found ${count} times in ${path}. Provide more surrounding context to make the match unique.`;
}
const newContent = content.replace(old_string, new_string);
writeFileSync(resolved, newContent, 'utf-8');
const linesAdded = new_string.split('\n').length;
const linesRemoved = old_string.split('\n').length;
return `Edited ${path}: replaced ${linesRemoved} line(s) with ${linesAdded} line(s)`;
edit_file first reads and then writes through the approved alias path, so an in-scope symlink can both disclose and modify an out-of-scope file during edit operations.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions