Skip to content

Harden AppleScript JSON output with runtime escaping#11

Merged
frouaix merged 1 commit intomainfrom
issue-2-harden-json
Feb 22, 2026
Merged

Harden AppleScript JSON output with runtime escaping#11
frouaix merged 1 commit intomainfrom
issue-2-harden-json

Conversation

@frouaix
Copy link
Copy Markdown
Owner

@frouaix frouaix commented Feb 22, 2026

Summary

Fixes #2 — Harden AppleScript JSON output.

Problem

All 10 template files build JSON via AppleScript string concatenation. Values read from apps at runtime (note titles, event summaries, contact names, etc.) were concatenated directly into JSON strings without escaping. If a value contains ", \, newlines, or tabs, the resulting JSON is malformed.

Solution: Two-layer defense

Layer 1 — AppleScript jsonEsc handler (prevention)

  • New shared AppleScript handlers (jsonEsc + replaceText) that escape \, ", CR, LF, and tab
  • Auto-appended to every template script by AppleScriptRunner
  • All 10 template files updated: runtime values wrapped with my jsonEsc(...)

Layer 2 — Swift JSON re-parse (safety net)

  • JsonEscape.reserialize() parses the returned string as JSON and re-serializes it
  • Catches any remaining edge cases and guarantees valid JSON output

What stays the same

  • The esc() Swift function — still needed for escaping user parameters in AppleScript source text
  • Template structure — scripts still build JSON strings, just with proper escaping

Testing

  • 9 new Swift unit tests for JsonEscape logic (handlers, wrapScript, reserialize)
  • All 154 existing TS tests pass
  • Swift build clean

Files changed

  • New: JsonEscape.swift — shared handlers + Swift safety net
  • Modified: AppleScriptRunner.swift — auto-append + re-parse
  • Modified: All 10 *Templates.swift — wrap runtime values
  • Modified: ExecutorTests.swift — unit tests

Add jsonEsc AppleScript handler that escapes \, ", CR, LF, and tab
in values read from apps at runtime before JSON string concatenation.

- New JsonEscape.swift: shared AppleScript handlers + Swift reserialize()
- AppleScriptRunner: auto-appends handlers, adds JSON re-parse safety net
- All 10 template files: wrap runtime values with my jsonEsc(...)
- Add 9 Swift unit tests for JsonEscape logic
- Existing esc() for user input parameters remains unchanged

Closes #2

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings February 22, 2026 02:53
@frouaix frouaix merged commit 457db11 into main Feb 22, 2026
5 checks passed
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR implements a two-layer defense against JSON injection vulnerabilities in AppleScript templates by adding runtime escaping for values retrieved from macOS applications.

Changes:

  • Adds JsonEscape.swift with AppleScript handlers (jsonEsc, replaceText) that escape special characters at runtime
  • Modifies AppleScriptRunner.swift to automatically append handlers to all scripts and re-serialize results as a safety net
  • Updates all 10 template files to wrap runtime values with my jsonEsc() for proper JSON embedding

Reviewed changes

Copilot reviewed 13 out of 13 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
JsonEscape.swift New module with AppleScript escape handlers and Swift reserialization safety net
AppleScriptRunner.swift Integrates handler injection and result validation into execution pipeline
CalendarTemplates.swift Wraps runtime event data with jsonEsc for safe JSON output
ContactsTemplates.swift Wraps runtime contact data with jsonEsc for safe JSON output
FinderTemplates.swift Wraps runtime file system data with jsonEsc for safe JSON output
MailTemplates.swift Wraps runtime email data with jsonEsc for safe JSON output
MessagesTemplates.swift Wraps runtime message data with jsonEsc for safe JSON output
MusicTemplates.swift Wraps runtime music data with jsonEsc for safe JSON output
NotesTemplates.swift Wraps runtime note data with jsonEsc for safe JSON output
PhotosTemplates.swift Wraps runtime photo data with jsonEsc for safe JSON output
RemindersTemplates.swift Wraps runtime reminder data with jsonEsc for safe JSON output
SafariTemplates.swift Wraps runtime browser data with jsonEsc for safe JSON output
ExecutorTests.swift Adds 9 unit tests for JsonEscape handlers, wrapScript, and reserialize functions
Comments suppressed due to low confidence (2)

packages/executor-swift/Sources/Executor/JsonEscape.swift:52

  • The reserialize function only attempts to re-parse JSON when the value starts with { or [. This means valid JSON strings (values wrapped in quotes like "hello") and JSON primitives (like true, false, null, or bare numbers like 42) won't be re-serialized. While these cases are less common in the current templates (which build objects/arrays), this creates an inconsistent safety net that might miss edge cases. Consider handling all valid JSON types, or document this limitation explicitly.
        guard let stringValue = result["value"] as? String,
              !stringValue.isEmpty,
              let firstChar = stringValue.first,
              (firstChar == "{" || firstChar == "[") else {
            return result

packages/executor-swift/Tests/ExecutorTests/ExecutorTests.swift:20

  • Consider adding a test that verifies the actual AppleScript jsonEsc handler works correctly by testing it in an end-to-end scenario. Currently, tests only verify the Swift-side reserialize function. While the TS integration tests likely cover this, a Swift-level test would provide faster feedback during development. For example, you could test wrapScript + execute with a simple script that returns escaped values.
    func testWrapScriptAppendsHandlers() {
        let script = "tell application \"Finder\"\nend tell"
        let wrapped = JsonEscape.wrapScript(script)
        XCTAssertTrue(wrapped.hasPrefix(script))
        XCTAssertTrue(wrapped.contains("on jsonEsc("))
    }

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

set s to s as text
set s to my replaceText(s, "\\", "\\\\")
set s to my replaceText(s, "\"", "\\" & quote)
set s to my replaceText(s, return, "\\n")
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The jsonEsc handler escapes carriage return (CR/return) as \n on line 19, but JSON spec requires it to be escaped as \r. This creates incorrect JSON output for strings containing CR characters. Change line 19 to: set s to my replaceText(s, return, "\\r")

Copilot uses AI. Check for mistakes.
Comment on lines +11 to +21
/// `jsonEsc(s)` escapes: \ → \\, " → \", CR → \n, LF → \n, tab → \t
/// `replaceText(theString, old, new)` helper for text item delimiter-based replacement.
static let handlers: String = #"""

on jsonEsc(s)
set s to s as text
set s to my replaceText(s, "\\", "\\\\")
set s to my replaceText(s, "\"", "\\" & quote)
set s to my replaceText(s, return, "\\n")
set s to my replaceText(s, linefeed, "\\n")
set s to my replaceText(s, tab, "\\t")
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The jsonEsc handler is missing escapes for other JSON control characters including form feed (\f), backspace (\b), and other control characters (U+0000-U+0008, U+000B, U+000E-U+001F). While these are less common in typical application data, they can appear and should be handled. Consider adding: form feed → \f, backspace → \b, and potentially a catch-all for other control characters using Unicode hex escapes (\uXXXX).

Suggested change
/// `jsonEsc(s)` escapes: \ → \\, " → \", CR → \n, LF → \n, tab → \t
/// `replaceText(theString, old, new)` helper for text item delimiter-based replacement.
static let handlers: String = #"""
on jsonEsc(s)
set s to s as text
set s to my replaceText(s, "\\", "\\\\")
set s to my replaceText(s, "\"", "\\" & quote)
set s to my replaceText(s, return, "\\n")
set s to my replaceText(s, linefeed, "\\n")
set s to my replaceText(s, tab, "\\t")
/// `jsonEsc(s)` escapes: \ → \\, " → \", CR → \n, LF → \n, tab → \t, backspace → \b, form feed → \f,
/// and other control characters U+0000–U+0008, U+000B, U+000E–U+001F to \u00XX.
/// `replaceText(theString, old, new)` helper for text item delimiter-based replacement.
static let handlers: String = #"""
on jsonEsc(s)
set s to s as text
-- Basic JSON escapes
set s to my replaceText(s, "\\", "\\\\")
set s to my replaceText(s, "\"", "\\" & quote)
set s to my replaceText(s, return, "\\n")
set s to my replaceText(s, linefeed, "\\n")
set s to my replaceText(s, tab, "\\t")
-- Additional JSON control characters
set s to my replaceText(s, character id 8, "\\b") -- backspace
set s to my replaceText(s, character id 12, "\\f") -- form feed
-- Remaining control chars (U+0000–U+0007, U+000B, U+000E–U+001F) as \u00XX
set s to my replaceText(s, character id 0, "\\u0000")
set s to my replaceText(s, character id 1, "\\u0001")
set s to my replaceText(s, character id 2, "\\u0002")
set s to my replaceText(s, character id 3, "\\u0003")
set s to my replaceText(s, character id 4, "\\u0004")
set s to my replaceText(s, character id 5, "\\u0005")
set s to my replaceText(s, character id 6, "\\u0006")
set s to my replaceText(s, character id 7, "\\u0007")
set s to my replaceText(s, character id 11, "\\u000B")
set s to my replaceText(s, character id 14, "\\u000E")
set s to my replaceText(s, character id 15, "\\u000F")
set s to my replaceText(s, character id 16, "\\u0010")
set s to my replaceText(s, character id 17, "\\u0011")
set s to my replaceText(s, character id 18, "\\u0012")
set s to my replaceText(s, character id 19, "\\u0013")
set s to my replaceText(s, character id 20, "\\u0014")
set s to my replaceText(s, character id 21, "\\u0015")
set s to my replaceText(s, character id 22, "\\u0016")
set s to my replaceText(s, character id 23, "\\u0017")
set s to my replaceText(s, character id 24, "\\u0018")
set s to my replaceText(s, character id 25, "\\u0019")
set s to my replaceText(s, character id 26, "\\u001A")
set s to my replaceText(s, character id 27, "\\u001B")
set s to my replaceText(s, character id 28, "\\u001C")
set s to my replaceText(s, character id 29, "\\u001D")
set s to my replaceText(s, character id 30, "\\u001E")
set s to my replaceText(s, character id 31, "\\u001F")

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Harden AppleScript JSON output

2 participants