Skip to content
Merged
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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,7 @@ FISH_API_KEY=your-fish-audio-api-key-here
# Optional: Specific Apple Calendar accounts to read (comma-separated emails)
# If not set, JARVIS reads ALL calendars from Apple Calendar
# CALENDAR_ACCOUNTS=you@gmail.com,work@company.com

# Optional: Allow claude CLI to skip permission prompts (use with caution)
# Only enable in trusted, isolated environments where you accept the risk.
# JARVIS_SKIP_PERMISSIONS=true
24 changes: 16 additions & 8 deletions actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@

DESKTOP_PATH = Path.home() / "Desktop"

_SKIP_PERMISSIONS = os.getenv("JARVIS_SKIP_PERMISSIONS", "").lower() in ("1", "true", "yes")


async def _mark_terminal_as_jarvis(revert_after: float = 5.0):
"""Temporarily set the front Terminal window to Ocean theme, then revert.
Expand Down Expand Up @@ -80,10 +82,15 @@ async def _revert_terminal_theme(profile_name: str):
pass


def applescript_escape(s: str) -> str:
"""Escape a string for safe embedding in an AppleScript double-quoted string."""
return s.replace("\\", "\\\\").replace('"', '\\"').replace("\r", "").replace("\n", " ")


async def open_terminal(command: str = "") -> dict:
"""Open Terminal.app and optionally run a command. Marks it blue for JARVIS."""
if command:
escaped = command.replace('"', '\\"')
escaped = applescript_escape(command)
script = (
'tell application "Terminal"\n'
" activate\n"
Expand Down Expand Up @@ -158,18 +165,18 @@ async def open_claude_in_project(project_dir: str, prompt: str) -> dict:
"""Open Terminal, cd to project dir, run Claude Code interactively.

Writes the prompt to CLAUDE.md (which claude reads automatically on startup)
then launches claude in interactive mode with --dangerously-skip-permissions.
then launches claude in interactive mode.
No prompt escaping needed — CLAUDE.md handles context delivery.
"""
# Write prompt to CLAUDE.md — claude reads this automatically
claude_md = Path(project_dir) / "CLAUDE.md"
claude_md.write_text(f"# Task\n\n{prompt}\n\nBuild this completely. If web app, make index.html work standalone.\n")

# Launch claude interactive — it reads CLAUDE.md on its own
skip_flag = " --dangerously-skip-permissions" if _SKIP_PERMISSIONS else ""
escaped_dir = applescript_escape(project_dir)
script = (
'tell application "Terminal"\n'
" activate\n"
f' do script "cd {project_dir} && claude --dangerously-skip-permissions"\n'
f' do script "cd {escaped_dir} && claude{skip_flag}"\n'
"end tell"
)
proc = await asyncio.create_subprocess_exec(
Expand Down Expand Up @@ -197,8 +204,8 @@ async def prompt_existing_terminal(project_name: str, prompt: str) -> dict:
Uses System Events keystroke to type into an active Claude Code session
rather than `do script` which would open a new shell.
"""
escaped_name = project_name.replace('"', '\\"')
escaped_prompt = prompt.replace("\\", "\\\\").replace('"', '\\"')
escaped_name = applescript_escape(project_name)
escaped_prompt = applescript_escape(prompt)

# Single atomic script: find window, focus it, type into it
script = f'''
Expand Down Expand Up @@ -345,7 +352,8 @@ async def execute_action(intent: dict, projects: list = None) -> dict:
target = intent.get("target", "")

if action == "open_terminal":
result = await open_terminal("claude --dangerously-skip-permissions")
claude_cmd = "claude --dangerously-skip-permissions" if _SKIP_PERMISSIONS else "claude"
result = await open_terminal(claude_cmd)
result["project_dir"] = None
return result

Expand Down
4 changes: 2 additions & 2 deletions mail_access.py
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,7 @@ async def search_mail(query: str, count: int = 10) -> list[dict]:
Uses AppleScript filtering on subject. For broader search,
we check both subject and sender.
"""
escaped = query.replace('"', '\\"').replace("\\", "\\\\")
escaped = query.replace("\\", "\\\\").replace('"', '\\"')
script = f"""
tell application "Mail"
set output to ""
Expand Down Expand Up @@ -309,7 +309,7 @@ async def read_message(subject_match: str) -> dict | None:

Returns {"sender", "subject", "date", "content"} or None.
"""
escaped = subject_match.replace('"', '\\"').replace("\\", "\\\\")
escaped = subject_match.replace("\\", "\\\\").replace('"', '\\"')
script = f"""
tell application "Mail"
set allMsgs to messages of inbox
Expand Down
17 changes: 11 additions & 6 deletions qa.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import asyncio
import json
import logging
import os
from dataclasses import dataclass, asdict
from datetime import datetime
from typing import Optional
Expand All @@ -15,6 +16,8 @@

MAX_RETRIES = 3

_SKIP_PERMISSIONS = os.getenv("JARVIS_SKIP_PERMISSIONS", "").lower() in ("1", "true", "yes")


@dataclass
class QAResult:
Expand Down Expand Up @@ -45,10 +48,11 @@ async def verify(self, task_prompt: str, task_result: str, working_dir: str = ".
)

try:
cmd = ["claude", "-p", "--output-format", "text"]
if _SKIP_PERMISSIONS:
cmd.append("--dangerously-skip-permissions")
process = await asyncio.create_subprocess_exec(
"claude", "-p",
"--output-format", "text",
"--dangerously-skip-permissions",
*cmd,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
Expand Down Expand Up @@ -133,10 +137,11 @@ async def auto_retry(
)

try:
cmd = ["claude", "-p", "--output-format", "text"]
if _SKIP_PERMISSIONS:
cmd.append("--dangerously-skip-permissions")
process = await asyncio.create_subprocess_exec(
"claude", "-p",
"--output-format", "text",
"--dangerously-skip-permissions",
*cmd,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
Expand Down
28 changes: 20 additions & 8 deletions server.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
from fastapi.responses import JSONResponse
from pydantic import BaseModel

from actions import execute_action, monitor_build, open_terminal, open_browser, open_claude_in_project, _generate_project_name, prompt_existing_terminal
from actions import execute_action, monitor_build, open_terminal, open_browser, open_claude_in_project, _generate_project_name, prompt_existing_terminal, applescript_escape
from work_mode import WorkSession, is_casual_question
from screen import get_active_windows, take_screenshot, describe_screen, format_windows_for_context
from calendar_access import get_todays_events, get_upcoming_events, get_next_event, format_events_for_context, format_schedule_summary, refresh_cache as refresh_calendar_cache
Expand All @@ -66,6 +66,7 @@
FISH_API_URL = "https://api.fish.audio/v1/tts"
USER_NAME = os.getenv("USER_NAME", "sir")
PROJECT_DIR = os.path.dirname(os.path.abspath(__file__))
_SKIP_PERMISSIONS = os.getenv("JARVIS_SKIP_PERMISSIONS", "").lower() in ("1", "true", "yes")

DESKTOP_PATH = Path.home() / "Desktop"

Expand Down Expand Up @@ -392,10 +393,12 @@ async def _run_task(self, task: ClaudeTask):
prompt_file.write_text(task.prompt)

# Open Terminal.app with claude running in the project directory
skip_flag = " --dangerously-skip-permissions" if _SKIP_PERMISSIONS else ""
escaped_work_dir = applescript_escape(work_dir)
applescript = f'''
tell application "Terminal"
activate
set newTab to do script "cd {work_dir} && cat .jarvis_prompt.md | claude -p --dangerously-skip-permissions | tee .jarvis_output.txt; echo '\\n--- JARVIS TASK COMPLETE ---'"
set newTab to do script "cd {escaped_work_dir} && cat .jarvis_prompt.md | claude -p{skip_flag} | tee .jarvis_output.txt; echo '\\n--- JARVIS TASK COMPLETE ---'"
end tell
'''

Expand Down Expand Up @@ -786,8 +789,11 @@ async def _execute_research(target: str, ws=None):

log.info(f"Research started via claude -p in {path}")

cmd = ["claude", "-p", "--output-format", "text"]
if _SKIP_PERMISSIONS:
cmd.append("--dangerously-skip-permissions")
process = await asyncio.create_subprocess_exec(
"claude", "-p", "--output-format", "text", "--dangerously-skip-permissions",
*cmd,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
Expand Down Expand Up @@ -844,7 +850,7 @@ async def _execute_research(target: str, ws=None):

async def _focus_terminal_window(project_name: str):
"""Bring a Terminal window matching the project name to front."""
escaped = project_name.replace('"', '\\"')
escaped = applescript_escape(project_name)
script = f'''
tell application "Terminal"
repeat with w in windows
Expand Down Expand Up @@ -1521,7 +1527,8 @@ def detect_action_fast(text: str) -> dict | None:
# -- Action Handlers -------------------------------------------------------

async def handle_open_terminal() -> str:
result = await open_terminal("claude --dangerously-skip-permissions")
claude_cmd = "claude --dangerously-skip-permissions" if _SKIP_PERMISSIONS else "claude"
result = await open_terminal(claude_cmd)
return result["confirmation"]


Expand All @@ -1539,10 +1546,12 @@ async def handle_build(target: str) -> str:
prompt_file = Path(path) / ".jarvis_prompt.txt"
prompt_file.write_text(target)

skip_flag = " --dangerously-skip-permissions" if _SKIP_PERMISSIONS else ""
escaped_path = applescript_escape(path)
script = (
'tell application "Terminal"\n'
" activate\n"
f' do script "cd {path} && cat .jarvis_prompt.txt | claude -p --dangerously-skip-permissions"\n'
f' do script "cd {escaped_path} && cat .jarvis_prompt.txt | claude -p{skip_flag}"\n'
"end tell"
)
await asyncio.create_subprocess_exec(
Expand Down Expand Up @@ -1575,7 +1584,8 @@ async def handle_show_recent() -> str:
return f"Opened {html_files[0].name} from {last['name']}, sir."

# Fall back to opening the folder in Finder
script = f'tell application "Finder"\nactivate\nopen POSIX file "{last["path"]}"\nend tell'
escaped_last_path = applescript_escape(last["path"])
script = f'tell application "Finder"\nactivate\nopen POSIX file "{escaped_last_path}"\nend tell'
await asyncio.create_subprocess_exec("osascript", "-e", script, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
return f"Opened the {last['name']} folder in Finder, sir."

Expand Down Expand Up @@ -2524,10 +2534,12 @@ async def api_fix_self():
jarvis_dir = str(Path(__file__).parent)
# The work_session is per-WebSocket, so we set a flag that the handler picks up
# For now, also open Terminal so user can see
skip_flag = " --dangerously-skip-permissions" if _SKIP_PERMISSIONS else ""
escaped_jarvis_dir = applescript_escape(jarvis_dir)
script = (
'tell application "Terminal"\n'
' activate\n'
f' do script "cd {jarvis_dir} && claude --dangerously-skip-permissions"\n'
f' do script "cd {escaped_jarvis_dir} && claude{skip_flag}"\n'
'end tell'
)
await asyncio.create_subprocess_exec(
Expand Down
50 changes: 50 additions & 0 deletions tests/test_applescript_escape.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"""Unit tests for applescript_escape() — guards against AppleScript injection."""

import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent.parent))

from actions import applescript_escape


def test_plain_string_unchanged():
assert applescript_escape("hello") == "hello"


def test_double_quote_escaped():
assert applescript_escape('say "hi"') == 'say \\"hi\\"'


def test_backslash_escaped():
assert applescript_escape("path\\file") == "path\\\\file"


def test_backslash_escaped_before_quote():
# The order matters: a literal \" must become \\\" not \\\\\"
assert applescript_escape('foo\\"bar') == 'foo\\\\\\"bar'


def test_newline_collapsed_to_space():
assert applescript_escape("line1\nline2") == "line1 line2"


def test_carriage_return_stripped():
assert applescript_escape("line1\r\nline2") == "line1 line2"


def test_injection_payload_neutralized():
# An attacker trying to break out of a do script "..." context.
# Every literal quote in the output must be backslash-escaped so it
# cannot terminate the host AppleScript string.
payload = '"; do shell script "rm -rf ~"; --'
escaped = applescript_escape(payload)
for idx, ch in enumerate(escaped):
if ch == '"':
assert idx > 0 and escaped[idx - 1] == "\\", (
f"unescaped quote at index {idx} in {escaped!r}"
)


def test_empty_string():
assert applescript_escape("") == ""
11 changes: 6 additions & 5 deletions work_mode.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,14 @@
import asyncio
import json
import logging
import os
import shutil
from pathlib import Path

log = logging.getLogger("jarvis.work_mode")

_SKIP_PERMISSIONS = os.getenv("JARVIS_SKIP_PERMISSIONS", "").lower() in ("1", "true", "yes")

SESSION_FILE = Path(__file__).parent / "data" / "active_session.json"


Expand Down Expand Up @@ -65,11 +68,9 @@ async def send(self, user_text: str) -> str:
if not claude_path:
return "Claude CLI not found on this system."

cmd = [
claude_path, "-p",
"--output-format", "text",
"--dangerously-skip-permissions",
]
cmd = [claude_path, "-p", "--output-format", "text"]
if _SKIP_PERMISSIONS:
cmd.append("--dangerously-skip-permissions")

# Use --continue for subsequent messages to maintain context
if self._message_count > 0:
Expand Down