| 1 | // app_script policy: what a script may do before it reaches osascript. |
| 2 | // |
| 3 | // app_script is the programmatic interface into apps with a scripting |
| 4 | // dictionary, not a shell. By default this module refuses the ways a script |
| 5 | // escapes into one (`do shell script`, JXA `doShellScript`, the Objective-C |
| 6 | // bridge and NSTask, script loading/eval, raw Apple event codes) and extracts |
| 7 | // every application the script names, so the per-app consent ledger gates |
| 8 | // `tell application "X"` — System Events and the processes it drives included — |
| 9 | // exactly as it gates clicks. A target the text cannot name statically (a |
| 10 | // computed application, a computed JXA member) is refused rather than guessed. |
| 11 | // |
| 12 | // This is a lexical gate, not a sandbox: it is defense in depth under the |
| 13 | // host's exact-script approval, which is the real floor. It fails closed — |
| 14 | // anything it cannot read confidently is refused with a reason the model can |
| 15 | // act on. |
| 16 | // |
| 17 | // Operators choose the mode with CODEWHALE_CU_APP_SCRIPT: |
| 18 | // (unset) | "apps" — the default described above |
| 19 | // "off" — refuse every app_script call |
| 20 | // "unrestricted" — skip the lexical refusals (the ledger still gates the |
| 21 | // apps a script names). A human decision in the host's |
| 22 | // MCP config; nothing a model can set from a tool call. |
| 23 | |
| 24 | const MODES = new Set(["apps", "off", "unrestricted"]); |
| 25 | |
| 26 | export function appScriptMode(env = process.env) { |
| 27 | const raw = String(env.CODEWHALE_CU_APP_SCRIPT ?? "").trim().toLowerCase(); |
| 28 | if (!raw) return "apps"; |
| 29 | // An unknown value is a misconfiguration; fail closed rather than open. |
| 30 | return MODES.has(raw) ? raw : "off"; |
| 31 | } |
| 32 | |
| 33 | const refuse = (reason) => ({ refused: reason, targets: [] }); |
| 34 | |
| 35 | /** Remove string literals so structure checks cannot be fooled by quoted text. */ |
| 36 | function stripStrings(src, quotes) { |
| 37 | let out = ""; |
| 38 | for (let i = 0; i < src.length; i++) { |
| 39 | const q = src[i]; |
| 40 | if (!quotes.includes(q)) { out += q; continue; } |
| 41 | out += q + q; |
| 42 | for (i++; i < src.length && src[i] !== q; i++) if (src[i] === "\\") i++; |
| 43 | } |
| 44 | return out; |
| 45 | } |
| 46 | |
| 47 | function refFor(value, { bundle = false } = {}) { |
| 48 | const s = String(value).trim(); |
| 49 | if (!s) return null; |
| 50 | if (bundle || (/^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+$/.test(s) && !/\.app$/i.test(s))) return { bundle_id: s }; |
| 51 | return { name: s.replace(/\.app$/i, "") }; |
| 52 | } |
| 53 | |
| 54 | // ---- AppleScript ---- |
| 55 | const AS_DENY = [ |
| 56 | [/\bdo\s+shell\s+script\b/i, "`do shell script` runs a shell"], |
| 57 | [/\b(run|load|store)\s+script\b/i, "`run/load/store script` executes code the policy cannot read"], |
| 58 | [/\buse\s+framework\b/i, "AppleScriptObjC (`use framework`) reaches Cocoa directly"], |
| 59 | [/\bcurrent\s+application\s*'s\b/i, "AppleScriptObjC (`current application's`) reaches Cocoa directly"], |
| 60 | [/\bNS(Task|UserUnixTask|UserScriptTask|AppleScript|Workspace)\b/i, "Cocoa process and script classes are not app scripting"], |
| 61 | [/\bcall\s+method\b/i, "`call method` reaches Objective-C"], |
| 62 | [/«/, "raw Apple event codes («event …») bypass the dictionary the policy reads"], |
| 63 | [/\bosascript\b/i, "nested osascript is refused"], |
| 64 | [/\bdo\s+script\b/i, "`do script` runs a shell command in a terminal"], |
| 65 | // System Events keystrokes and coordinate clicks land on whatever app is |
| 66 | // frontmost, whichever process the script names; use the type/key/click |
| 67 | // tools, which carry the per-app gates. |
| 68 | [/\b(keystroke|key\s+code)\b/i, "System Events keystrokes go to the frontmost app, not the named one — use the type or key tool"], |
| 69 | [/\bclick\s+at\b/i, "coordinate clicks through System Events go to whatever is on screen — use the click tool"], |
| 70 | // StandardAdditions run in osascript itself, whatever app a script names: |
| 71 | // file I/O, opening URLs, mounting volumes and reading the environment are |
| 72 | // not app scripting. |
| 73 | [/\bopen\s+for\s+access\b/i, "StandardAdditions file access (`open for access`) is not app scripting"], |
| 74 | [/\b(read|write)\b[^\n]*\b(POSIX\s+file|file|alias)\b/i, "StandardAdditions file reads and writes are not app scripting"], |
| 75 | [/\bopen\s+location\b/i, "`open location` opens a URL outside any consented app"], |
| 76 | [/\bmount\s+volume\b/i, "`mount volume` is not app scripting"], |
| 77 | [/\bsystem\s+attribute\b/i, "`system attribute` reads the environment"], |
| 78 | // A consented app must not become a launcher for another app or a |
| 79 | // runnable file (Finder, `launch`, `reopen`). |
| 80 | [/\b(open|launch|reopen)\b[^\n]*\b(POSIX\s+file|file|alias|application\s+file|disk\s+item)\b/i, "opening files or applications through another app is refused — use open_application, which asks for consent"], |
| 81 | ]; |
| 82 | |
| 83 | function checkAppleScript(script) { |
| 84 | // Join ¬ continuations so a phrase split across lines is still one phrase. |
| 85 | const src = script.replace(/¬[ \t]*\r?\n/g, " "); |
| 86 | const targets = []; |
| 87 | const refused = (reason) => ({ refused: reason, targets }); |
| 88 | // application "X", app "X", application id "com.x", plus System Events' |
| 89 | // `process "X"` / `application process "X"` GUI-scripting targets. |
| 90 | const literal = /\b(?:application|app)\s+(id\s+)?"((?:[^"\\]|\\.)*)"/gi; |
| 91 | for (const m of src.matchAll(literal)) { const ref = refFor(m[2], { bundle: !!m[1] }); if (ref) targets.push(ref); } |
| 92 | for (const m of src.matchAll(/\b(?:application\s+)?process\s+"((?:[^"\\]|\\.)*)"/gi)) { const ref = refFor(m[1]); if (ref) targets.push(ref); } |
| 93 | for (const [re, why] of AS_DENY) if (re.test(src)) return refused(why); |
| 94 | // Any other use of `application`/`app` must be a form the policy knows: |
| 95 | // `current application`, `application "X"`, `application id "X"`, |
| 96 | // `application process "X"`, or `application file`/`application support` |
| 97 | // inside strings (already stripped). A computed target is refused. |
| 98 | const bare = stripStrings(src, ['"']); |
| 99 | for (const m of bare.matchAll(/\b(application|app)\b(\s*(?:id\s*)?)(.?)/gi)) { |
| 100 | const before = bare.slice(Math.max(0, m.index - 20), m.index); |
| 101 | if (/\bcurrent\s+$/i.test(before)) continue; |
| 102 | if (m[3] === '"') continue; |
| 103 | const rest = bare.slice(m.index + m[1].length); |
| 104 | if (/^\s*process(es)?\b/i.test(rest)) continue; |
| 105 | if (/^\s*support\b/i.test(rest)) continue; // path to application support |
| 106 | return refused("the script names an application the policy cannot read statically — name it as a literal: tell application \"Name\""); |
| 107 | } |
| 108 | // A System Events process reached by index or predicate (process 1, first |
| 109 | // process whose frontmost is true) is an app the ledger never saw. Only a |
| 110 | // literal name, or listing names, is allowed. |
| 111 | for (const m of bare.matchAll(/\bprocess(es)?\b/gi)) { |
| 112 | const rest = bare.slice(m.index + m[0].length); |
| 113 | const before = bare.slice(Math.max(0, m.index - 40), m.index); |
| 114 | if (!m[1] && /^\s*""/.test(rest)) continue; |
| 115 | if (/\bname\s+of\s+(every\s+)?(application\s+)?$/i.test(before)) continue; |
| 116 | return refused("System Events processes must be named with a literal (process \"Name\") so the app can be consented"); |
| 117 | } |
| 118 | return { refused: null, targets }; |
| 119 | } |
| 120 | |
| 121 | // ---- JXA ---- |
| 122 | // Checked against the script with string literals removed: text inside a |
| 123 | // string cannot run unless something evaluates it or indexes by it, and both |
| 124 | // of those are refused below. |
| 125 | const JXA_DENY = [ |
| 126 | [/doShellScript/i, "`doShellScript` runs a shell"], |
| 127 | [/\bdoScript\b/, "`doScript` runs a shell command in a terminal"], |
| 128 | [/\.\s*(keystroke|keyCode)\s*\(/, "System Events keystrokes go to the frontmost app, not the named one — use the type or key tool"], |
| 129 | [/\.\s*click\s*\(\s*\{/, "coordinate clicks through System Events go to whatever is on screen — use the click tool"], |
| 130 | [/\bObjC\b/, "the Objective-C bridge (ObjC) reaches Cocoa directly"], |
| 131 | [/\$\s*[.([]/, "the Objective-C bridge ($) reaches Cocoa directly"], |
| 132 | [/\bNS(Task|UserUnixTask|UserScriptTask|AppleScript|Workspace)\b/, "Cocoa process and script classes are not app scripting"], |
| 133 | [/includeStandardAdditions/, "StandardAdditions exposes doShellScript; use the app's own dictionary"], |
| 134 | [/\b(eval|Function|Library|Ref|require|importScripts|constructor|prototype|__proto__|Reflect|Proxy)\b/, "dynamic code loading, evaluation and reflection are refused"], |
| 135 | [/\bObject\s*\.\s*(getOwnProperty\w*|defineProperty|defineProperties|entries|values|assign|getPrototypeOf|setPrototypeOf)\b/, "reflection over objects is refused"], |
| 136 | [/\bosascript\b/i, "nested osascript is refused"], |
| 137 | [/\.\s*(open|launch|reopen)\s*\(/, "opening files or applications through another app is refused — use open_application, which asks for consent"], |
| 138 | [/\bPath\s*\(/, "file paths (Path(…)) are not app scripting"], |
| 139 | [/\.\s*(openLocation|mountVolume|systemAttribute|openForAccess|read|write)\s*\(/, "StandardAdditions calls are not app scripting"], |
| 140 | ]; |
| 141 | |
| 142 | function checkJxa(script) { |
| 143 | const code = stripStrings(script, ['"', "'", "`"]); |
| 144 | const targets = []; |
| 145 | const literal = /\bApplication\s*\(\s*(["'])((?:(?!\1)[^\\]|\\.)*)\1\s*\)/g; |
| 146 | for (const m of script.matchAll(literal)) { const ref = refFor(m[2]); if (ref) targets.push(ref); } |
| 147 | const byName = /\b(?:applicationProcesses|processes)\s*\.\s*byName\s*\(\s*(["'])((?:(?!\1)[^\\]|\\.)*)\1\s*\)/g; |
| 148 | for (const m of script.matchAll(byName)) { const ref = refFor(m[2]); if (ref) targets.push(ref); } |
| 149 | const refused = (reason) => ({ refused: reason, targets }); |
| 150 | if (/doShellScript/i.test(script)) return refused("`doShellScript` runs a shell"); |
| 151 | if (/\\u|\\x/.test(script)) return refused("escape sequences are refused so names cannot be spelled around the policy"); |
| 152 | for (const [re, why] of JXA_DENY) if (re.test(code)) return refused(why); |
| 153 | // Computed member access could spell doShellScript at runtime; only numeric |
| 154 | // indexes are allowed. Collections take .at(i) and .byName("x") instead. |
| 155 | for (const m of code.matchAll(/[\w$)\]]\s*\[([^\]]*)\]/g)) { |
| 156 | if (!/^\s*\d+\s*$/.test(m[1])) return refused("computed member access (x[expr]) is refused — use .at(i), .byName(\"Name\") or a literal property"); |
| 157 | } |
| 158 | // Computed keys in object literals and destructuring patterns ({[k]: v}). |
| 159 | if (/[{,]\s*\[/.test(code)) return refused("computed keys ({[expr]: …}) and nested array literals are refused"); |
| 160 | // System Events processes: a literal .byName("X"), or listing names. |
| 161 | for (const m of code.matchAll(/\b(applicationProcesses|processes)\b/g)) { |
| 162 | const rest = code.slice(m.index + m[0].length); |
| 163 | if (/^\s*\.\s*byName\s*\(\s*(""|'')\s*\)/.test(rest)) continue; |
| 164 | if (/^\s*\.\s*name\s*\(\s*\)/.test(rest)) continue; |
| 165 | return refused("System Events processes must be named with .byName(\"Name\") so the app can be consented"); |
| 166 | } |
| 167 | // Application must be called with one literal, or be .currentApplication(). |
| 168 | for (const m of code.matchAll(/\bApplication\b/g)) { |
| 169 | const rest = code.slice(m.index + "Application".length); |
| 170 | if (/^\s*\.\s*currentApplication\s*\(\s*\)/.test(rest)) continue; |
| 171 | if (/^\s*\(\s*``/.test(rest)) return refused("Application(`…`) may interpolate — name the app with a plain string"); |
| 172 | if (/^\s*\(\s*(""|'')\s*\)/.test(rest)) continue; |
| 173 | return refused("the script names an application the policy cannot read statically — use Application(\"Name\")"); |
| 174 | } |
| 175 | return { refused: null, targets }; |
| 176 | } |
| 177 | |
| 178 | // Apps whose scripting dictionary is itself a shell or a script runner. |
| 179 | // Driving them through app_script is arbitrary command execution by another |
| 180 | // name, so they are refused as targets in the default mode. |
| 181 | const SHELL_HOSTS = new Set([ |
| 182 | "terminal", "iterm", "iterm2", "warp", "alacritty", "kitty", "ghostty", "wezterm", "hyper", "tabby", |
| 183 | "script editor", "automator", "shortcuts", "shortcuts events", "osascript", |
| 184 | "com.apple.terminal", "com.googlecode.iterm2", "dev.warp.warp-stable", "org.alacritty", "net.kovidgoyal.kitty", |
| 185 | "com.mitchellh.ghostty", "com.github.wez.wezterm", "co.zeit.hyper", "com.apple.scripteditor2", "com.apple.automator", |
| 186 | "com.apple.shortcuts", "com.apple.shortcuts.events", |
| 187 | ]); |
| 188 | const shellHost = (ref) => SHELL_HOSTS.has(String(ref.bundle_id ?? ref.name ?? "").trim().toLowerCase()); |
| 189 | |
| 190 | /** |
| 191 | * Check one app_script call. Returns {refused: string|null, targets: ref[]} |
| 192 | * where each ref is {name} or {bundle_id} for the consent ledger. |
| 193 | */ |
| 194 | /** The consent identity of a script that names no application: osascript itself. */ |
| 195 | export const OSASCRIPT_TARGET = Object.freeze({ bundle_id: "com.apple.osascript" }); |
| 196 | |
| 197 | export function checkAppScript(script, language = "applescript", env = process.env) { |
| 198 | const mode = appScriptMode(env); |
| 199 | if (mode === "off") return refuse("app_script is turned off on this computer (CODEWHALE_CU_APP_SCRIPT=off)"); |
| 200 | const checked = language === "javascript" ? checkJxa(String(script)) : checkAppleScript(String(script)); |
| 201 | // A script that names no app still runs, as osascript: it is consented to |
| 202 | // under that identity, in every mode. |
| 203 | if (!checked.targets.length) checked.targets.push({ ...OSASCRIPT_TARGET }); |
| 204 | if (mode === "unrestricted") return { refused: null, targets: checked.targets }; |
| 205 | if (checked.refused) return checked; |
| 206 | const host = checked.targets.find(shellHost); |
| 207 | if (host) return { refused: `${host.name ?? host.bundle_id} runs shell commands or scripts — app_script does not drive it`, targets: checked.targets }; |
| 208 | return checked; |
| 209 | } |
| 210 |