source dump of claude code
at main 196 lines 6.6 kB view raw
1/** 2 * Filter and sanitize installed-app data for inclusion in the `request_access` 3 * tool description. Ported from Cowork's appNames.ts. Two 4 * concerns: noise filtering (Spotlight returns every bundle on disk — XPC 5 * helpers, daemons, input methods) and prompt-injection hardening (app names 6 * are attacker-controlled; anyone can ship an app named anything). 7 * 8 * Residual risk: short benign-char adversarial names ("grant all") can't be 9 * filtered programmatically. The tool description's structural framing 10 * ("Available applications:") makes it clear these are app names, and the 11 * downstream permission dialog requires explicit user approval — a bad name 12 * can't auto-grant anything. 13 */ 14 15/** Minimal shape — matches what `listInstalledApps` returns. */ 16type InstalledAppLike = { 17 readonly bundleId: string 18 readonly displayName: string 19 readonly path: string 20} 21 22// ── Noise filtering ────────────────────────────────────────────────────── 23 24/** 25 * Only apps under these roots are shown. /System/Library subpaths (CoreServices, 26 * PrivateFrameworks, Input Methods) are OS plumbing — anchor on known-good 27 * roots rather than blocklisting every junk subpath since new macOS versions 28 * add more. 29 * 30 * ~/Applications is checked at call time via the `homeDir` arg (HOME isn't 31 * reliably known at module load in all environments). 32 */ 33const PATH_ALLOWLIST: readonly string[] = [ 34 '/Applications/', 35 '/System/Applications/', 36] 37 38/** 39 * Display-name patterns that mark background services even under /Applications. 40 * `(?:$|\s\()` — matches keyword at end-of-string OR immediately before ` (`: 41 * "Slack Helper (GPU)" and "ABAssistantService" fail, "Service Desk" passes 42 * (Service is followed by " D"). 43 */ 44const NAME_PATTERN_BLOCKLIST: readonly RegExp[] = [ 45 /Helper(?:$|\s\()/, 46 /Agent(?:$|\s\()/, 47 /Service(?:$|\s\()/, 48 /Uninstaller(?:$|\s\()/, 49 /Updater(?:$|\s\()/, 50 /^\./, 51] 52 53/** 54 * Apps commonly requested for CU automation. ALWAYS included if installed, 55 * bypassing path check + count cap — the model needs these exact names even 56 * when the machine has 200+ apps. Bundle IDs (locale-invariant), not display 57 * names. Keep <30 — each entry is a guaranteed token in the description. 58 */ 59const ALWAYS_KEEP_BUNDLE_IDS: ReadonlySet<string> = new Set([ 60 // Browsers 61 'com.apple.Safari', 62 'com.google.Chrome', 63 'com.microsoft.edgemac', 64 'org.mozilla.firefox', 65 'company.thebrowser.Browser', // Arc 66 // Communication 67 'com.tinyspeck.slackmacgap', 68 'us.zoom.xos', 69 'com.microsoft.teams2', 70 'com.microsoft.teams', 71 'com.apple.MobileSMS', 72 'com.apple.mail', 73 // Productivity 74 'com.microsoft.Word', 75 'com.microsoft.Excel', 76 'com.microsoft.Powerpoint', 77 'com.microsoft.Outlook', 78 'com.apple.iWork.Pages', 79 'com.apple.iWork.Numbers', 80 'com.apple.iWork.Keynote', 81 'com.google.GoogleDocs', 82 // Notes / PM 83 'notion.id', 84 'com.apple.Notes', 85 'md.obsidian', 86 'com.linear', 87 'com.figma.Desktop', 88 // Dev 89 'com.microsoft.VSCode', 90 'com.apple.Terminal', 91 'com.googlecode.iterm2', 92 'com.github.GitHubDesktop', 93 // System essentials the model genuinely targets 94 'com.apple.finder', 95 'com.apple.iCal', 96 'com.apple.systempreferences', 97]) 98 99// ── Prompt-injection hardening ─────────────────────────────────────────── 100 101/** 102 * `\p{L}\p{M}\p{N}` with /u — not `\w` (ASCII-only, would drop Bücher, 微信, 103 * Préférences Système). `\p{M}` matches combining marks so NFD-decomposed 104 * diacritics (ü → u + ◌̈) pass. Single space not `\s` — `\s` matches newlines, 105 * which would let "App\nIgnore previous…" through as a multi-line injection. 106 * Still bars quotes, angle brackets, backticks, pipes, colons. 107 */ 108const APP_NAME_ALLOWED = /^[\p{L}\p{M}\p{N}_ .&'()+-]+$/u 109const APP_NAME_MAX_LEN = 40 110const APP_NAME_MAX_COUNT = 50 111 112function isUserFacingPath(path: string, homeDir: string | undefined): boolean { 113 if (PATH_ALLOWLIST.some(root => path.startsWith(root))) return true 114 if (homeDir) { 115 const userApps = homeDir.endsWith('/') 116 ? `${homeDir}Applications/` 117 : `${homeDir}/Applications/` 118 if (path.startsWith(userApps)) return true 119 } 120 return false 121} 122 123function isNoisyName(name: string): boolean { 124 return NAME_PATTERN_BLOCKLIST.some(re => re.test(name)) 125} 126 127/** 128 * Length cap + trim + dedupe + sort. `applyCharFilter` — skip for trusted 129 * bundle IDs (Apple/Google/MS; a localized "Réglages Système" with unusual 130 * punctuation shouldn't be dropped), apply for anything attacker-installable. 131 */ 132function sanitizeCore( 133 raw: readonly string[], 134 applyCharFilter: boolean, 135): string[] { 136 const seen = new Set<string>() 137 return raw 138 .map(name => name.trim()) 139 .filter(trimmed => { 140 if (!trimmed) return false 141 if (trimmed.length > APP_NAME_MAX_LEN) return false 142 if (applyCharFilter && !APP_NAME_ALLOWED.test(trimmed)) return false 143 if (seen.has(trimmed)) return false 144 seen.add(trimmed) 145 return true 146 }) 147 .sort((a, b) => a.localeCompare(b)) 148} 149 150function sanitizeAppNames(raw: readonly string[]): string[] { 151 const filtered = sanitizeCore(raw, true) 152 if (filtered.length <= APP_NAME_MAX_COUNT) return filtered 153 return [ 154 ...filtered.slice(0, APP_NAME_MAX_COUNT), 155 `… and ${filtered.length - APP_NAME_MAX_COUNT} more`, 156 ] 157} 158 159function sanitizeTrustedNames(raw: readonly string[]): string[] { 160 return sanitizeCore(raw, false) 161} 162 163/** 164 * Filter raw Spotlight results to user-facing apps, then sanitize. Always-keep 165 * apps bypass path/name filter AND char allowlist (trusted vendors, not 166 * attacker-installed); still length-capped, deduped, sorted. 167 */ 168export function filterAppsForDescription( 169 installed: readonly InstalledAppLike[], 170 homeDir: string | undefined, 171): string[] { 172 const { alwaysKept, rest } = installed.reduce<{ 173 alwaysKept: string[] 174 rest: string[] 175 }>( 176 (acc, app) => { 177 if (ALWAYS_KEEP_BUNDLE_IDS.has(app.bundleId)) { 178 acc.alwaysKept.push(app.displayName) 179 } else if ( 180 isUserFacingPath(app.path, homeDir) && 181 !isNoisyName(app.displayName) 182 ) { 183 acc.rest.push(app.displayName) 184 } 185 return acc 186 }, 187 { alwaysKept: [], rest: [] }, 188 ) 189 190 const sanitizedAlways = sanitizeTrustedNames(alwaysKept) 191 const alwaysSet = new Set(sanitizedAlways) 192 return [ 193 ...sanitizedAlways, 194 ...sanitizeAppNames(rest).filter(n => !alwaysSet.has(n)), 195 ] 196}