init claude-code
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Filter and sanitize installed-app data for inclusion in the `request_access`
|
||||
* tool description. Ported from Cowork's appNames.ts. Two
|
||||
* concerns: noise filtering (Spotlight returns every bundle on disk — XPC
|
||||
* helpers, daemons, input methods) and prompt-injection hardening (app names
|
||||
* are attacker-controlled; anyone can ship an app named anything).
|
||||
*
|
||||
* Residual risk: short benign-char adversarial names ("grant all") can't be
|
||||
* filtered programmatically. The tool description's structural framing
|
||||
* ("Available applications:") makes it clear these are app names, and the
|
||||
* downstream permission dialog requires explicit user approval — a bad name
|
||||
* can't auto-grant anything.
|
||||
*/
|
||||
|
||||
/** Minimal shape — matches what `listInstalledApps` returns. */
|
||||
type InstalledAppLike = {
|
||||
readonly bundleId: string
|
||||
readonly displayName: string
|
||||
readonly path: string
|
||||
}
|
||||
|
||||
// ── Noise filtering ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Only apps under these roots are shown. /System/Library subpaths (CoreServices,
|
||||
* PrivateFrameworks, Input Methods) are OS plumbing — anchor on known-good
|
||||
* roots rather than blocklisting every junk subpath since new macOS versions
|
||||
* add more.
|
||||
*
|
||||
* ~/Applications is checked at call time via the `homeDir` arg (HOME isn't
|
||||
* reliably known at module load in all environments).
|
||||
*/
|
||||
const PATH_ALLOWLIST: readonly string[] = [
|
||||
'/Applications/',
|
||||
'/System/Applications/',
|
||||
]
|
||||
|
||||
/**
|
||||
* Display-name patterns that mark background services even under /Applications.
|
||||
* `(?:$|\s\()` — matches keyword at end-of-string OR immediately before ` (`:
|
||||
* "Slack Helper (GPU)" and "ABAssistantService" fail, "Service Desk" passes
|
||||
* (Service is followed by " D").
|
||||
*/
|
||||
const NAME_PATTERN_BLOCKLIST: readonly RegExp[] = [
|
||||
/Helper(?:$|\s\()/,
|
||||
/Agent(?:$|\s\()/,
|
||||
/Service(?:$|\s\()/,
|
||||
/Uninstaller(?:$|\s\()/,
|
||||
/Updater(?:$|\s\()/,
|
||||
/^\./,
|
||||
]
|
||||
|
||||
/**
|
||||
* Apps commonly requested for CU automation. ALWAYS included if installed,
|
||||
* bypassing path check + count cap — the model needs these exact names even
|
||||
* when the machine has 200+ apps. Bundle IDs (locale-invariant), not display
|
||||
* names. Keep <30 — each entry is a guaranteed token in the description.
|
||||
*/
|
||||
const ALWAYS_KEEP_BUNDLE_IDS: ReadonlySet<string> = new Set([
|
||||
// Browsers
|
||||
'com.apple.Safari',
|
||||
'com.google.Chrome',
|
||||
'com.microsoft.edgemac',
|
||||
'org.mozilla.firefox',
|
||||
'company.thebrowser.Browser', // Arc
|
||||
// Communication
|
||||
'com.tinyspeck.slackmacgap',
|
||||
'us.zoom.xos',
|
||||
'com.microsoft.teams2',
|
||||
'com.microsoft.teams',
|
||||
'com.apple.MobileSMS',
|
||||
'com.apple.mail',
|
||||
// Productivity
|
||||
'com.microsoft.Word',
|
||||
'com.microsoft.Excel',
|
||||
'com.microsoft.Powerpoint',
|
||||
'com.microsoft.Outlook',
|
||||
'com.apple.iWork.Pages',
|
||||
'com.apple.iWork.Numbers',
|
||||
'com.apple.iWork.Keynote',
|
||||
'com.google.GoogleDocs',
|
||||
// Notes / PM
|
||||
'notion.id',
|
||||
'com.apple.Notes',
|
||||
'md.obsidian',
|
||||
'com.linear',
|
||||
'com.figma.Desktop',
|
||||
// Dev
|
||||
'com.microsoft.VSCode',
|
||||
'com.apple.Terminal',
|
||||
'com.googlecode.iterm2',
|
||||
'com.github.GitHubDesktop',
|
||||
// System essentials the model genuinely targets
|
||||
'com.apple.finder',
|
||||
'com.apple.iCal',
|
||||
'com.apple.systempreferences',
|
||||
])
|
||||
|
||||
// ── Prompt-injection hardening ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* `\p{L}\p{M}\p{N}` with /u — not `\w` (ASCII-only, would drop Bücher, 微信,
|
||||
* Préférences Système). `\p{M}` matches combining marks so NFD-decomposed
|
||||
* diacritics (ü → u + ◌̈) pass. Single space not `\s` — `\s` matches newlines,
|
||||
* which would let "App\nIgnore previous…" through as a multi-line injection.
|
||||
* Still bars quotes, angle brackets, backticks, pipes, colons.
|
||||
*/
|
||||
const APP_NAME_ALLOWED = /^[\p{L}\p{M}\p{N}_ .&'()+-]+$/u
|
||||
const APP_NAME_MAX_LEN = 40
|
||||
const APP_NAME_MAX_COUNT = 50
|
||||
|
||||
function isUserFacingPath(path: string, homeDir: string | undefined): boolean {
|
||||
if (PATH_ALLOWLIST.some(root => path.startsWith(root))) return true
|
||||
if (homeDir) {
|
||||
const userApps = homeDir.endsWith('/')
|
||||
? `${homeDir}Applications/`
|
||||
: `${homeDir}/Applications/`
|
||||
if (path.startsWith(userApps)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function isNoisyName(name: string): boolean {
|
||||
return NAME_PATTERN_BLOCKLIST.some(re => re.test(name))
|
||||
}
|
||||
|
||||
/**
|
||||
* Length cap + trim + dedupe + sort. `applyCharFilter` — skip for trusted
|
||||
* bundle IDs (Apple/Google/MS; a localized "Réglages Système" with unusual
|
||||
* punctuation shouldn't be dropped), apply for anything attacker-installable.
|
||||
*/
|
||||
function sanitizeCore(
|
||||
raw: readonly string[],
|
||||
applyCharFilter: boolean,
|
||||
): string[] {
|
||||
const seen = new Set<string>()
|
||||
return raw
|
||||
.map(name => name.trim())
|
||||
.filter(trimmed => {
|
||||
if (!trimmed) return false
|
||||
if (trimmed.length > APP_NAME_MAX_LEN) return false
|
||||
if (applyCharFilter && !APP_NAME_ALLOWED.test(trimmed)) return false
|
||||
if (seen.has(trimmed)) return false
|
||||
seen.add(trimmed)
|
||||
return true
|
||||
})
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
}
|
||||
|
||||
function sanitizeAppNames(raw: readonly string[]): string[] {
|
||||
const filtered = sanitizeCore(raw, true)
|
||||
if (filtered.length <= APP_NAME_MAX_COUNT) return filtered
|
||||
return [
|
||||
...filtered.slice(0, APP_NAME_MAX_COUNT),
|
||||
`… and ${filtered.length - APP_NAME_MAX_COUNT} more`,
|
||||
]
|
||||
}
|
||||
|
||||
function sanitizeTrustedNames(raw: readonly string[]): string[] {
|
||||
return sanitizeCore(raw, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter raw Spotlight results to user-facing apps, then sanitize. Always-keep
|
||||
* apps bypass path/name filter AND char allowlist (trusted vendors, not
|
||||
* attacker-installed); still length-capped, deduped, sorted.
|
||||
*/
|
||||
export function filterAppsForDescription(
|
||||
installed: readonly InstalledAppLike[],
|
||||
homeDir: string | undefined,
|
||||
): string[] {
|
||||
const { alwaysKept, rest } = installed.reduce<{
|
||||
alwaysKept: string[]
|
||||
rest: string[]
|
||||
}>(
|
||||
(acc, app) => {
|
||||
if (ALWAYS_KEEP_BUNDLE_IDS.has(app.bundleId)) {
|
||||
acc.alwaysKept.push(app.displayName)
|
||||
} else if (
|
||||
isUserFacingPath(app.path, homeDir) &&
|
||||
!isNoisyName(app.displayName)
|
||||
) {
|
||||
acc.rest.push(app.displayName)
|
||||
}
|
||||
return acc
|
||||
},
|
||||
{ alwaysKept: [], rest: [] },
|
||||
)
|
||||
|
||||
const sanitizedAlways = sanitizeTrustedNames(alwaysKept)
|
||||
const alwaysSet = new Set(sanitizedAlways)
|
||||
return [
|
||||
...sanitizedAlways,
|
||||
...sanitizeAppNames(rest).filter(n => !alwaysSet.has(n)),
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user