google / google/artemis

Shell command injection in ScriptNotifier via unescaped {message}/{title} template substitution (ARTEMIS_NOTIFY_CMD)

Open Beginner friendly
#99 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
Python
Stars
5.9k
Forks
516
Avg merge
22m
Merged PRs (30d)
5

Description

## Summary
`ScriptNotifier.notify()` in `mcp_server/notifiers/script.py` builds a shell command by doing plain
string `.replace()` of `{title}`, `{message}`, `{conversation_id}`, `{event_type}`, and `{trace_id}`
placeholders into the user-configured `ARTEMIS_NOTIFY_CMD` / `MCP_NOTIFY_COMMAND` template, then runs
the result with `subprocess.run(cmd, shell=True, ...)`. None of the substituted values are shell-escaped
(e.g. with `shlex.quote`), so any of them can break out of the template — including out of the quoted
placeholders the README itself recommends — and inject arbitrary shell commands.

`ScriptNotifier` is registered by default in `CompositeNotifier` (`mcp_server/notifiers/composite.py`)
and fires automatically whenever either env var is set, which is exactly the setup documented in
`mcp_server/README.md`:

```
Set ARTEMIS_NOTIFY_CMD="my-script --title '{title}' --message '{message}' --trace-id '{trace_id}'"
in your environment to execute any custom command or script upon event completion.
```

The `message` value is attacker-reachable content, not a fixed string: it is built in
`mcp_server/background/task_runner.py` from the free-text `task_desc` (the `Goal:` line — the literal
string passed to the `mobile_run_task` MCP tool by whatever AI IDE/agent is driving Artemis) and from the
JSON-dumped `result`/`explanation` the agent produces after autonomously reading and reasoning over the
on-screen content of the app under test. Neither of those is trusted, sanitized, or shell-safe input.

## Impact
Arbitrary command execution on the machine running the Artemis MCP server, at the privilege level of that
process, for any user who has configured the documented `ARTEMIS_NOTIFY_CMD` IDE-notification hook. Because
`message` incorporates text an AI agent read back from a live automated task (task goal + result/explanation
derived from on-device UI content), this is exploitable not just by a careless task description but via
indirect injection — content displayed inside the app being automated can end up echoed into `result`/
`explanation` and from there into the shell command.

## Steps to Reproduce
1. Configure the documented hook exactly as the README shows:
```
export ARTEMIS_NOTIFY_CMD="my-script --title '{title}' --message '{message}' --trace-id '{trace_id}'"
```
2. Run any task whose goal or resulting explanation contains a single quote followed by shell
metacharacters, e.g. a task description/result containing:
```
'; touch /tmp/PWNED; echo '
```
3. When the task completes/fails, `notify()` → `ScriptNotifier.notify()` formats and executes the
command. The single quote in `message` closes the quoted `{message}` segment early, and the
trailing `; touch /tmp/PWNED; echo` runs as a separate shell command.

Minimal standalone repro of the vulnerable substitution logic (mirrors `script.py:70-85` exactly):

```python
import subprocess

cmd_template = "echo '{title}' '{message}' '{trace_id}'"
title = "Artemis Task Completed"
message = "Goal: check the balance shown on screen: '; touch /tmp/PWNED_BY_TASK_RESULT; echo '"
trace_id = "abc123"

cmd = (
cmd_template.replace("{title}", str(title))
.replace("{message}", str(message))
.replace("{trace_id}", str(trace_id))
)
subprocess.run(cmd, shell=True)
```

Running this creates `/tmp/PWNED_BY_TASK_RESULT` — confirmed locally, the injected command executes.

## Root Cause
`mcp_server/notifiers/script.py`, `ScriptNotifier.notify()`:

```python
cmd = (
cmd_template.replace("{title}", str(formatted_title))
.replace("{message}", str(message))
.replace("{conversation_id}", str(conversation_id))
.replace("{event_type}", str(event_type))
.replace("{trace_id}", str(trace_id))
)
subprocess.run(cmd, shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10)
```

Values are interpolated into a `shell=True` command string with no escaping. Wrapping the placeholders
in single quotes in the template (as the README recommends) does not help, since the substituted value
can itself contain a single quote and terminate that quoting early.

## Suggested Fix
Shell-escape every substituted value with `shlex.quote()` before interpolating it into the template, so
the documented `'{title}'` / `'{message}'` style templates keep working but embedded quotes/metacharacters
in task or result content can no longer break out:

```python
import shlex
...
cmd = (
cmd_template.replace("{title}", shlex.quote(str(formatted_title)))
.replace("{message}", shlex.quote(str(message)))
.replace("{conversation_id}", shlex.quote(str(conversation_id)))
.replace("{event_type}", shlex.quote(str(event_type)))
.replace("{trace_id}", shlex.quote(str(trace_id)))
)
```

Verified locally that this change causes the same repro above to print the malicious payload as inert
text instead of executing it, while leaving normal notifications unaffected. A minimal patch is attached
(`fix.patch`).

## Environment
- Reproduced against `google/artemis` @ `371aa6df56880643da57b30da936e9812fb0ec66` (main, 2026-09-11)
- `mcp_server/notifiers/script.py`, `mcp_server/notifiers/composite.py`, `mcp_server/background/task_runner.py`

Contributor guide

Open the contributing guide

Research direction

Start in mcp_server/notifiers/script.py at ScriptNotifier.notify() and inspect how the ARTEMIS_NOTIFY_CMD or MCP_NOTIFY_COMMAND template reaches subprocess.run. Use the standalone reproduction in the issue to check behavior with shell metacharacters, then verify that the documented notification template still works and injected content is treated as inert text.

Written by the indexing model from the issue text.

Assessment

Tech stack
python, shell
Domain
backend, security
Issue type
Bug
Difficulty
2/5
Estimated time
1-3 hours
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
75/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.