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
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ enum AppleScriptRunner {
/// Builds a script from a template ID and parameters, then executes it.
static func executeTemplate(templateId: String, bundleId: String, parameters: [String: Any]) throws -> [String: Any] {
let script = try buildTemplateScript(templateId: templateId, bundleId: bundleId, parameters: parameters)
return try execute(script: script)
let wrappedScript = JsonEscape.wrapScript(script)
let rawResult = try execute(script: wrappedScript)
return JsonEscape.reserialize(rawResult)
}

/// Converts an NSAppleEventDescriptor to a Swift dictionary representation.
Expand Down Expand Up @@ -66,7 +68,8 @@ enum AppleScriptRunner {

/// Builds an AppleScript string from a template identifier and parameters (public for dryRun).
static func buildScript(templateId: String, bundleId: String, parameters: [String: Any]) throws -> String {
return try buildTemplateScript(templateId: templateId, bundleId: bundleId, parameters: parameters)
let script = try buildTemplateScript(templateId: templateId, bundleId: bundleId, parameters: parameters)
return JsonEscape.wrapScript(script)
}

/// Builds an AppleScript string from a template identifier and parameters.
Expand Down
14 changes: 7 additions & 7 deletions packages/executor-swift/Sources/Executor/CalendarTemplates.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ enum CalendarTemplates {
set output to "["
repeat with i from 1 to count of calList
set c to item i of calList
set output to output & "{\\"id\\":\\"" & calId of c & "\\",\\"name\\":\\"" & calName of c & "\\",\\"type\\":\\"calendar\\"}"
set output to output & "{\\"id\\":\\"" & my jsonEsc(calId of c) & "\\",\\"name\\":\\"" & my jsonEsc(calName of c) & "\\",\\"type\\":\\"calendar\\"}"
if i < (count of calList) then set output to output & ","
end repeat
set output to output & "]"
Expand Down Expand Up @@ -70,7 +70,7 @@ enum CalendarTemplates {
set eSummary to summary of e
set eStart to start date of e as «class isot» as string
set eEnd to end date of e as «class isot» as string
set output to output & "{\\"id\\":\\"" & eId & "\\",\\"name\\":\\"" & eSummary & "\\",\\"type\\":\\"event\\",\\"properties\\":{\\"startDate\\":\\"" & eStart & "\\",\\"endDate\\":\\"" & eEnd & "\\"}}"
set output to output & "{\\"id\\":\\"" & my jsonEsc(eId) & "\\",\\"name\\":\\"" & my jsonEsc(eSummary) & "\\",\\"type\\":\\"event\\",\\"properties\\":{\\"startDate\\":\\"" & my jsonEsc(eStart) & "\\",\\"endDate\\":\\"" & my jsonEsc(eEnd) & "\\"}}"
if i < resultCount then set output to output & ","
end repeat
set output to output & "]}"
Expand Down Expand Up @@ -101,7 +101,7 @@ enum CalendarTemplates {
set eLoc to location of e
set eDesc to description of e
set eAllDay to allday event of e
return "{\\"id\\":\\"" & eId & "\\",\\"name\\":\\"" & eSummary & "\\",\\"type\\":\\"event\\",\\"properties\\":{\\"startDate\\":\\"" & eStart & "\\",\\"endDate\\":\\"" & eEnd & "\\",\\"location\\":\\"" & eLoc & "\\",\\"description\\":\\"" & eDesc & "\\",\\"allDay\\":" & eAllDay & "}}"
return "{\\"id\\":\\"" & my jsonEsc(eId) & "\\",\\"name\\":\\"" & my jsonEsc(eSummary) & "\\",\\"type\\":\\"event\\",\\"properties\\":{\\"startDate\\":\\"" & my jsonEsc(eStart) & "\\",\\"endDate\\":\\"" & my jsonEsc(eEnd) & "\\",\\"location\\":\\"" & my jsonEsc(eLoc) & "\\",\\"description\\":\\"" & my jsonEsc(eDesc) & "\\",\\"allDay\\":" & eAllDay & "}}"
end tell
"""
}
Expand All @@ -128,7 +128,7 @@ enum CalendarTemplates {
set eId to uid of e
set eSummary to summary of e
set eStart to start date of e as «class isot» as string
set output to output & "{\\"id\\":\\"" & eId & "\\",\\"name\\":\\"" & eSummary & "\\",\\"type\\":\\"event\\",\\"properties\\":{\\"startDate\\":\\"" & eStart & "\\"}}"
set output to output & "{\\"id\\":\\"" & my jsonEsc(eId) & "\\",\\"name\\":\\"" & my jsonEsc(eSummary) & "\\",\\"type\\":\\"event\\",\\"properties\\":{\\"startDate\\":\\"" & my jsonEsc(eStart) & "\\"}}"
if i < resultCount then set output to output & ","
end repeat
set output to output & "]"
Expand Down Expand Up @@ -161,7 +161,7 @@ enum CalendarTemplates {
set endDate to date "\(esc(endDate))"
set newEvent to make new event at end of events of targetCalendar with properties {summary:"\(esc(title))", start date:startDate, end date:endDate, location:"\(location)", description:"\(notes)", allday event:\(allDay)}
set eId to uid of newEvent
return "{\\"id\\":\\"" & eId & "\\",\\"name\\":\\"\(esc(title))\\",\\"type\\":\\"event\\"}"
return "{\\"id\\":\\"" & my jsonEsc(eId) & "\\",\\"name\\":\\"\(esc(title))\\",\\"type\\":\\"event\\"}"
end tell
"""
}
Expand Down Expand Up @@ -203,7 +203,7 @@ enum CalendarTemplates {
if (count of flatEvents) is 0 then error "Event not found: \(esc(eventId))"
set e to item 1 of flatEvents
\(setStatements.joined(separator: "\n "))
return "{\\"id\\":\\"" & (uid of e) & "\\",\\"name\\":\\"" & (summary of e) & "\\",\\"type\\":\\"event\\"}"
return "{\\"id\\":\\"" & my jsonEsc(uid of e) & "\\",\\"name\\":\\"" & my jsonEsc(summary of e) & "\\",\\"type\\":\\"event\\"}"
end tell
"""
}
Expand All @@ -225,7 +225,7 @@ enum CalendarTemplates {
set e to item 1 of flatEvents
set eName to summary of e
delete e
return "{\\"deleted\\":true,\\"name\\":\\"" & eName & "\\"}"
return "{\\"deleted\\":true,\\"name\\":\\"" & my jsonEsc(eName) & "\\"}"
end tell
"""
}
Expand Down
14 changes: 7 additions & 7 deletions packages/executor-swift/Sources/Executor/ContactsTemplates.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ enum ContactsTemplates {
set output to "["
repeat with i from 1 to count of groupList
set g to item i of groupList
set output to output & "{\\"id\\":\\"" & groupId of g & "\\",\\"name\\":\\"" & groupName of g & "\\",\\"type\\":\\"group\\",\\"itemCount\\":" & (personCount of g as text) & "}"
set output to output & "{\\"id\\":\\"" & my jsonEsc(groupId of g) & "\\",\\"name\\":\\"" & my jsonEsc(groupName of g) & "\\",\\"type\\":\\"group\\",\\"itemCount\\":" & (personCount of g as text) & "}"
if i < (count of groupList) then set output to output & ","
end repeat
set output to output & "]"
Expand Down Expand Up @@ -73,7 +73,7 @@ enum ContactsTemplates {
set pFirst to first name of p
set pLast to last name of p
set pName to pFirst & " " & pLast
set output to output & "{\\"id\\":\\"" & pId & "\\",\\"name\\":\\"" & pName & "\\",\\"type\\":\\"person\\",\\"properties\\":{\\"firstName\\":\\"" & pFirst & "\\",\\"lastName\\":\\"" & pLast & "\\"}}"
set output to output & "{\\"id\\":\\"" & my jsonEsc(pId) & "\\",\\"name\\":\\"" & my jsonEsc(pName) & "\\",\\"type\\":\\"person\\",\\"properties\\":{\\"firstName\\":\\"" & my jsonEsc(pFirst) & "\\",\\"lastName\\":\\"" & my jsonEsc(pLast) & "\\"}}"
if i < endIdx then set output to output & ","
end repeat
end if
Expand Down Expand Up @@ -106,7 +106,7 @@ enum ContactsTemplates {
if phoneList is not "" then set phoneList to phoneList & ", "
set phoneList to phoneList & value of ph
end repeat
return "{\\"id\\":\\"" & pId & "\\",\\"name\\":\\"" & pFirst & " " & pLast & "\\",\\"type\\":\\"person\\",\\"properties\\":{\\"firstName\\":\\"" & pFirst & "\\",\\"lastName\\":\\"" & pLast & "\\",\\"organization\\":\\"" & pOrg & "\\",\\"jobTitle\\":\\"" & pTitle & "\\",\\"note\\":\\"" & pNote & "\\",\\"emails\\":\\"" & emailList & "\\",\\"phones\\":\\"" & phoneList & "\\"}}"
return "{\\"id\\":\\"" & my jsonEsc(pId) & "\\",\\"name\\":\\"" & my jsonEsc(pFirst) & " " & my jsonEsc(pLast) & "\\",\\"type\\":\\"person\\",\\"properties\\":{\\"firstName\\":\\"" & my jsonEsc(pFirst) & "\\",\\"lastName\\":\\"" & my jsonEsc(pLast) & "\\",\\"organization\\":\\"" & my jsonEsc(pOrg) & "\\",\\"jobTitle\\":\\"" & my jsonEsc(pTitle) & "\\",\\"note\\":\\"" & my jsonEsc(pNote) & "\\",\\"emails\\":\\"" & my jsonEsc(emailList) & "\\",\\"phones\\":\\"" & my jsonEsc(phoneList) & "\\"}}"
end tell
"""
}
Expand All @@ -128,7 +128,7 @@ enum ContactsTemplates {
set pId to id of p
set pFirst to first name of p
set pLast to last name of p
set output to output & "{\\"id\\":\\"" & pId & "\\",\\"name\\":\\"" & pFirst & " " & pLast & "\\",\\"type\\":\\"person\\"}"
set output to output & "{\\"id\\":\\"" & my jsonEsc(pId) & "\\",\\"name\\":\\"" & my jsonEsc(pFirst) & " " & my jsonEsc(pLast) & "\\",\\"type\\":\\"person\\"}"
if i < resultCount then set output to output & ","
end repeat
set output to output & "]"
Expand Down Expand Up @@ -170,7 +170,7 @@ enum ContactsTemplates {
\(extraLines.joined(separator: "\n "))
save
set pId to id of newPerson
return "{\\"id\\":\\"" & pId & "\\",\\"name\\":\\"\(esc(firstName)) \(lastName)\\",\\"type\\":\\"person\\"}"
return "{\\"id\\":\\"" & my jsonEsc(pId) & "\\",\\"name\\":\\"\(esc(firstName)) \(lastName)\\",\\"type\\":\\"person\\"}"
end tell
"""
}
Expand Down Expand Up @@ -205,7 +205,7 @@ enum ContactsTemplates {
set p to person id "\(esc(personId))"
\(setStatements.joined(separator: "\n "))
save
return "{\\"id\\":\\"" & (id of p) & "\\",\\"name\\":\\"" & (first name of p) & " " & (last name of p) & "\\",\\"type\\":\\"person\\"}"
return "{\\"id\\":\\"" & my jsonEsc(id of p) & "\\",\\"name\\":\\"" & my jsonEsc(first name of p) & " " & my jsonEsc(last name of p) & "\\",\\"type\\":\\"person\\"}"
end tell
"""
}
Expand All @@ -220,7 +220,7 @@ enum ContactsTemplates {
set pName to first name of p & " " & last name of p
delete p
save
return "{\\"deleted\\":true,\\"name\\":\\"" & pName & "\\"}"
return "{\\"deleted\\":true,\\"name\\":\\"" & my jsonEsc(pName) & "\\"}"
end tell
"""
}
Expand Down
14 changes: 7 additions & 7 deletions packages/executor-swift/Sources/Executor/FinderTemplates.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ enum FinderTemplates {
set output to "["
repeat with i from 1 to count of folderList
set f to item i of folderList
set output to output & "{\\"id\\":\\"" & fPath of f & "\\",\\"name\\":\\"" & fName of f & "\\",\\"type\\":\\"folder\\"}"
set output to output & "{\\"id\\":\\"" & my jsonEsc(fPath of f) & "\\",\\"name\\":\\"" & my jsonEsc(fName of f) & "\\",\\"type\\":\\"folder\\"}"
if i < (count of folderList) then set output to output & ","
end repeat
set output to output & "]"
Expand Down Expand Up @@ -74,7 +74,7 @@ enum FinderTemplates {
set fKind to kind of f
set fSize to size of f
set fDate to modification date of f as «class isot» as string
set output to output & "{\\"id\\":\\"" & fPath & "\\",\\"name\\":\\"" & fName & "\\",\\"type\\":\\"file\\",\\"modifiedAt\\":\\"" & fDate & "\\",\\"properties\\":{\\"kind\\":\\"" & fKind & "\\",\\"size\\":" & fSize & "}}"
set output to output & "{\\"id\\":\\"" & my jsonEsc(fPath) & "\\",\\"name\\":\\"" & my jsonEsc(fName) & "\\",\\"type\\":\\"file\\",\\"modifiedAt\\":\\"" & my jsonEsc(fDate) & "\\",\\"properties\\":{\\"kind\\":\\"" & my jsonEsc(fKind) & "\\",\\"size\\":" & fSize & "}}"
if i < endIdx then set output to output & ","
end repeat
end if
Expand All @@ -97,7 +97,7 @@ enum FinderTemplates {
set fSize to size of fi
set fCreated to creation date of fi as «class isot» as string
set fModified to modification date of fi as «class isot» as string
return "{\\"id\\":\\"" & POSIX path of f & "\\",\\"name\\":\\"" & fName & "\\",\\"type\\":\\"file\\",\\"createdAt\\":\\"" & fCreated & "\\",\\"modifiedAt\\":\\"" & fModified & "\\",\\"properties\\":{\\"kind\\":\\"" & fKind & "\\",\\"size\\":" & fSize & "}}"
return "{\\"id\\":\\"" & my jsonEsc(POSIX path of f) & "\\",\\"name\\":\\"" & my jsonEsc(fName) & "\\",\\"type\\":\\"file\\",\\"createdAt\\":\\"" & my jsonEsc(fCreated) & "\\",\\"modifiedAt\\":\\"" & my jsonEsc(fModified) & "\\",\\"properties\\":{\\"kind\\":\\"" & my jsonEsc(fKind) & "\\",\\"size\\":" & fSize & "}}"
end tell
"""
}
Expand All @@ -120,7 +120,7 @@ enum FinderTemplates {
set f to item i of matchingItems
set fName to name of f
set fPath to POSIX path of (f as alias)
set output to output & "{\\"id\\":\\"" & fPath & "\\",\\"name\\":\\"" & fName & "\\",\\"type\\":\\"file\\"}"
set output to output & "{\\"id\\":\\"" & my jsonEsc(fPath) & "\\",\\"name\\":\\"" & my jsonEsc(fName) & "\\",\\"type\\":\\"file\\"}"
if i < resultCount then set output to output & ","
end repeat
set output to output & "]"
Expand All @@ -141,7 +141,7 @@ enum FinderTemplates {
set parentFolder to POSIX file "\(esc(parentPath))" as alias
set newFolder to make new folder at folder parentFolder with properties {name:"\(esc(name))"}
set fPath to POSIX path of (newFolder as alias)
return "{\\"id\\":\\"" & fPath & "\\",\\"name\\":\\"\(esc(name))\\",\\"type\\":\\"folder\\"}"
return "{\\"id\\":\\"" & my jsonEsc(fPath) & "\\",\\"name\\":\\"\(esc(name))\\",\\"type\\":\\"folder\\"}"
end tell
"""
}
Expand Down Expand Up @@ -181,7 +181,7 @@ enum FinderTemplates {
set sourceItem to POSIX file "\(esc(sourcePath))" as alias
set dupItem to duplicate sourceItem\(destClause)
set dupPath to POSIX path of (dupItem as alias)
return "{\\"duplicated\\":true,\\"path\\":\\"" & dupPath & "\\"}"
return "{\\"duplicated\\":true,\\"path\\":\\"" & my jsonEsc(dupPath) & "\\"}"
end tell
"""
}
Expand All @@ -195,7 +195,7 @@ enum FinderTemplates {
set targetItem to POSIX file "\(esc(path))" as alias
set itemName to name of targetItem
delete targetItem
return "{\\"deleted\\":true,\\"name\\":\\"" & itemName & "\\"}"
return "{\\"deleted\\":true,\\"name\\":\\"" & my jsonEsc(itemName) & "\\"}"
end tell
"""
}
Expand Down
69 changes: 69 additions & 0 deletions packages/executor-swift/Sources/Executor/JsonEscape.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import Foundation

/// Shared AppleScript escape handlers and Swift-side JSON safety net.
enum JsonEscape {

// MARK: - AppleScript Handlers

/// AppleScript handlers that escape string values for safe JSON embedding.
/// Appended to every template script by AppleScriptRunner.
///
/// `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")
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.
set s to my replaceText(s, linefeed, "\\n")
set s to my replaceText(s, tab, "\\t")
Comment on lines +11 to +21
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.
return s
end jsonEsc

on replaceText(theString, old, new)
set AppleScript's text item delimiters to old
set theItems to every text item of theString
set AppleScript's text item delimiters to new
set theString to theItems as string
set AppleScript's text item delimiters to ""
return theString
end replaceText
"""#

// MARK: - Script Wrapping

/// Appends the jsonEsc handlers to a template script.
static func wrapScript(_ script: String) -> String {
script + handlers
}

// MARK: - Swift Safety Net

/// Attempts to parse the executor result's "value" as JSON and re-serialize it.
/// If the value is valid JSON, returns a dict with the parsed object under "value".
/// If not JSON (or parse fails), returns the original dict unchanged.
static func reserialize(_ result: [String: Any]) -> [String: Any] {
guard let stringValue = result["value"] as? String,
!stringValue.isEmpty,
let firstChar = stringValue.first,
(firstChar == "{" || firstChar == "[") else {
return result
}

guard let data = stringValue.data(using: .utf8),
let parsed = try? JSONSerialization.jsonObject(with: data, options: []) else {
// JSON parse failed — return raw string; the MCP client will see the error
return result
}

// Re-serialize to guarantee proper escaping
if let reData = try? JSONSerialization.data(withJSONObject: parsed, options: [.sortedKeys]),
let reString = String(data: reData, encoding: .utf8) {
return ["value": reString]
}

return result
}
}
Loading
Loading