/ utils / computerUse / appNames.ts
appNames.ts
  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. */
 16  type 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   */
 33  const 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   */
 44  const 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   */
 59  const 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   */
108  const APP_NAME_ALLOWED = /^[\p{L}\p{M}\p{N}_ .&'()+-]+$/u
109  const APP_NAME_MAX_LEN = 40
110  const APP_NAME_MAX_COUNT = 50
111  
112  function 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  
123  function 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   */
132  function 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  
150  function 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  
159  function 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   */
168  export 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  }