Skip to content

[Security] Mercury auto-approves side-effectful find -exec commands as safe reads #110

Description

@YLChen-007

Advisory Details

Title: Mercury auto-approves side-effectful find -exec commands as safe reads

Description:

Mercury's Ask Me shell approval boundary treats find * as read-only. Because find supports side-effectful actions such as -exec, an authenticated Mercury user can wrap a write or arbitrary shell action in find -exec ... and execute it without the expected approval prompt.

Summary

Mercury's shell approval layer can be bypassed by any authenticated user who can reach the normal run_command tool path. The root cause is that PermissionManager.checkShellCommand() classifies any find * command as a safe read, even when the command includes -exec sh -c .... That misclassification suppresses the approval prompt and lets the payload execute with Mercury's process privileges.

Details

The vulnerable logic is in src/capabilities/permissions.ts. Mercury keeps a hard-coded safe-read allowlist and includes find * in it:

private static readonly SAFE_READ_PATTERNS = [
  'ls *', 'cat *', 'pwd', 'which *', 'echo *', 'head *', 'tail *', 'wc *',
  'find *', 'grep *', 'rg *', 'ps *', 'df *', 'du *', 'uname *',
  'dir *', 'type *', 'cd *', 'where *', 'tree *', 'findstr *',
  'tasklist *', 'systeminfo *',
  'git status *', 'git diff *', 'git log *', 'git branch *',
];

Later in the same function, Mercury auto-approves any shell segment that matches one of those patterns:

const allSegmentsSafeRead = segments.length > 0 && segments.every((segment) =>
  PermissionManager.SAFE_READ_PATTERNS.some((p) => this.matchPattern(segment, p))
);
if (allSegmentsSafeRead) {
  logger.info({ cmd: trimmed, segments: segments.length }, 'Shell command auto-approved (safe read-only)');
  return { allowed: true, needsApproval: false };
}

That assumption is unsafe for find. find is not a read-only primitive; it supports -exec, -execdir, -ok, -okdir, -delete, and other actions that can write files or run arbitrary subprocesses. The user-controlled command string then flows through src/capabilities/shell/run-command.ts, where run_command forwards the original string to executeCommand(), and the helper invokes it with shell parsing enabled:

const child = spawn(command, [], {
  cwd,
  shell: true,
  stdio: ['pipe', 'pipe', 'pipe'],
});

I verified this against the latest GitHub release as of July 7, 2026: v1.1.13, published on June 18, 2026. The release commit 0de89558cb52d8635170639e6bef0fc9a37eb88e still contains the vulnerable find * rule and the same shell sink. The same unsafe pattern is also present in older upstream tags, but v1.1.13 is the highest affected GitHub release.

The proof uses Mercury's real localhost web stack and the public /api/chat/send plus /api/chat/events flow. The harness only replaces the external LLM provider with a local OpenAI-compatible stub so the tool call is deterministic; it does not patch Mercury, bypass PermissionManager, or call private internals. In the vulnerable scenario, the stub requests:

find . -maxdepth 0 -exec sh -c 'printf GHSA-8mf7-vv8w-hjr2-vuln-canary > canary.txt' ';'

Mercury emits no approval prompt, logs Shell command auto-approved (safe read-only), and writes the canary file. In the control scenario, a direct shell write:

sh -c 'printf GHSA-8mf7-vv8w-hjr2-control-canary > canary.txt'

does trigger a real web permission_request, and the harness denies it. No control canary is created.

PoC

Prerequisites

  • A checkout of https://github.com/cosmicstack-labs/mercury-agent
  • Dependencies installed with npm install
  • A fresh build from the affected code with npm run build
  • Python 3 available to run the harness
  • No external provider credentials are required; the harness starts a local OpenAI-compatible stub

Reproduction Steps

  1. Download the auxiliary harness from: mercury_web_harness.py

  2. Download the vulnerable testcase from: verification_test.py

  3. Download the control testcase from: control-direct-shell.py

  4. Place the three files in the same directory inside the Mercury repository so the testcases can import mercury_web_harness.py.

  5. Build the project from the repository root:

    npm install
    npm run build
  6. Run the vulnerable scenario:

    python3 verification_test.py
  7. Observe that the script reports zero permission prompts and writes canary.txt in the temporary workspace. The saved vulnerable artifacts are under vuln-home/, vuln.json, and vuln-mercury.log.

  8. Run the control scenario:

    python3 control-direct-shell.py
  9. Observe that the control run reports one real permission_request, denies it through /api/chat/permission/:id, and does not create canary.txt.

Log of Evidence

$ python3 verification_test.py
Verification Mode: Integration-Test
Permission prompts observed: 0
Canary file exists: True
Canary file content: 'GHSA-8mf7-vv8w-hjr2-vuln-canary'

$ rg -n "auto-approved|Executing shell command" vuln-mercury.log
48: {"cmd":"find . -maxdepth 0 -exec sh -c 'printf GHSA-8mf7-vv8w-hjr2-vuln-canary > canary.txt' ';'","segments":1,"msg":"Shell command auto-approved (safe read-only)"}
49: {"cmd":"find . -maxdepth 0 -exec sh -c 'printf GHSA-8mf7-vv8w-hjr2-vuln-canary > canary.txt' ';'","msg":"Executing shell command"}

$ python3 control-direct-shell.py
Verification Mode: Integration-Test
Permission prompts observed: 1
Canary file exists: False
Canary file content: None

$ jq '.permission_requests[0].data.prompt' control.json
"Run command: sh -c 'printf GHSA-8mf7-vv8w-hjr2-control-canary > canary.txt'"

Impact

This is an approval-bypass vulnerability that reaches Mercury's shell execution capability. Any authenticated Mercury user who can influence run_command can turn an approval-gated shell write into an auto-approved shell action by wrapping it in find -exec. In practice this allows arbitrary host command execution with Mercury's process privileges, including writing files, altering workspace state, reading secrets reachable to the process, and chaining to broader post-exploitation steps.

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:H/I:H/A:H

Weaknesses

  • CWE: CWE-863: Incorrect Authorization

Occurrences

Permalink Description
private static readonly SAFE_READ_PATTERNS = [
'ls *', 'cat *', 'pwd', 'which *', 'echo *', 'head *', 'tail *', 'wc *',
'find *', 'grep *', 'rg *', 'ps *', 'df *', 'du *', 'uname *',
SAFE_READ_PATTERNS explicitly treats find * as a read-only command family even though find supports side-effectful actions such as -exec and -delete.
// In ask-me mode: only auto-approve when EVERY segment is a safe read.
// Matching the full trimmed string would let `cat foo; rm -rf ~` slip
// through because `cat *` matches the entire concatenation.
const allSegmentsSafeRead = segments.length > 0 && segments.every((segment) =>
PermissionManager.SAFE_READ_PATTERNS.some((p) => this.matchPattern(segment, p))
);
if (allSegmentsSafeRead) {
logger.info({ cmd: trimmed, segments: segments.length }, 'Shell command auto-approved (safe read-only)');
return { allowed: true, needsApproval: false };
In Ask Me mode, every segment matching a safe-read pattern is auto-approved and skips the user approval path; this is the exact gate that misclassifies find -exec ... as safe.
execute: async ({ command, timeout }) => {
const check = await permissions.checkShellCommand(command);
if (!check.allowed) {
return `Error: ${check.reason}`;
}
const cwd = getCwd();
const timeoutMs = (timeout ?? 120) * 1000;
try {
logger.info({ cmd: command, cwd, timeoutMs }, 'Executing shell command');
const result = await executeCommand(command, cwd, timeoutMs);
The public run_command tool forwards the same attacker-controlled command string to executeCommand() once checkShellCommand() returns allowed.
const child = spawn(command, [], {
cwd,
shell: true,
stdio: ['pipe', 'pipe', 'pipe'],
executeCommand() invokes spawn(command, [], { shell: true }), so the misclassified find -exec sh -c ... payload reaches a real shell execution sink with Mercury's process privileges.

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