返回 CodeWhale
app-script-policy.mjs
根目录 / crates / tui / plugins / computer-use / src / app-script-policy.mjs
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
210 lines Plain Text