diff --git a/.env.example b/.env.example index 76f8e29..755847b 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/actions.py b/actions.py index ac433d2..f06f5ae 100644 --- a/actions.py +++ b/actions.py @@ -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. @@ -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" @@ -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( @@ -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''' @@ -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 diff --git a/mail_access.py b/mail_access.py index e68ebfb..03a88fe 100644 --- a/mail_access.py +++ b/mail_access.py @@ -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 "" @@ -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 diff --git a/qa.py b/qa.py index a32dd45..eb4d934 100644 --- a/qa.py +++ b/qa.py @@ -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 @@ -15,6 +16,8 @@ MAX_RETRIES = 3 +_SKIP_PERMISSIONS = os.getenv("JARVIS_SKIP_PERMISSIONS", "").lower() in ("1", "true", "yes") + @dataclass class QAResult: @@ -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, @@ -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, diff --git a/server.py b/server.py index acabce2..41dc14a 100644 --- a/server.py +++ b/server.py @@ -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 @@ -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" @@ -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 ''' @@ -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, @@ -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 @@ -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"] @@ -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( @@ -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." @@ -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( diff --git a/tests/test_applescript_escape.py b/tests/test_applescript_escape.py new file mode 100644 index 0000000..3529107 --- /dev/null +++ b/tests/test_applescript_escape.py @@ -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("") == "" diff --git a/work_mode.py b/work_mode.py index 09747b7..185831a 100644 --- a/work_mode.py +++ b/work_mode.py @@ -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" @@ -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: