PermissionUpdate.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389
  1. import { posix } from 'path'
  2. import type { ToolPermissionContext } from '../../Tool.js'
  3. // Types extracted to src/types/permissions.ts to break import cycles
  4. import type {
  5. AdditionalWorkingDirectory,
  6. WorkingDirectorySource,
  7. } from '../../types/permissions.js'
  8. import { logForDebugging } from '../debug.js'
  9. import type { EditableSettingSource } from '../settings/constants.js'
  10. import {
  11. getSettingsForSource,
  12. updateSettingsForSource,
  13. } from '../settings/settings.js'
  14. import { jsonStringify } from '../slowOperations.js'
  15. import { toPosixPath } from './filesystem.js'
  16. import type { PermissionRuleValue } from './PermissionRule.js'
  17. import type {
  18. PermissionUpdate,
  19. PermissionUpdateDestination,
  20. } from './PermissionUpdateSchema.js'
  21. import {
  22. permissionRuleValueFromString,
  23. permissionRuleValueToString,
  24. } from './permissionRuleParser.js'
  25. import { addPermissionRulesToSettings } from './permissionsLoader.js'
  26. // Re-export for backwards compatibility
  27. export type { AdditionalWorkingDirectory, WorkingDirectorySource }
  28. export function extractRules(
  29. updates: PermissionUpdate[] | undefined,
  30. ): PermissionRuleValue[] {
  31. if (!updates) return []
  32. return updates.flatMap(update => {
  33. switch (update.type) {
  34. case 'addRules':
  35. return update.rules
  36. default:
  37. return []
  38. }
  39. })
  40. }
  41. export function hasRules(updates: PermissionUpdate[] | undefined): boolean {
  42. return extractRules(updates).length > 0
  43. }
  44. /**
  45. * Applies a single permission update to the context and returns the updated context
  46. * @param context The current permission context
  47. * @param update The permission update to apply
  48. * @returns The updated permission context
  49. */
  50. export function applyPermissionUpdate(
  51. context: ToolPermissionContext,
  52. update: PermissionUpdate,
  53. ): ToolPermissionContext {
  54. switch (update.type) {
  55. case 'setMode':
  56. logForDebugging(
  57. `Applying permission update: Setting mode to '${update.mode}'`,
  58. )
  59. return {
  60. ...context,
  61. mode: update.mode,
  62. }
  63. case 'addRules': {
  64. const ruleStrings = update.rules.map(rule =>
  65. permissionRuleValueToString(rule),
  66. )
  67. logForDebugging(
  68. `Applying permission update: Adding ${update.rules.length} ${update.behavior} rule(s) to destination '${update.destination}': ${jsonStringify(ruleStrings)}`,
  69. )
  70. // Determine which collection to update based on behavior
  71. const ruleKind =
  72. update.behavior === 'allow'
  73. ? 'alwaysAllowRules'
  74. : update.behavior === 'deny'
  75. ? 'alwaysDenyRules'
  76. : 'alwaysAskRules'
  77. return {
  78. ...context,
  79. [ruleKind]: {
  80. ...context[ruleKind],
  81. [update.destination]: [
  82. ...(context[ruleKind][update.destination] || []),
  83. ...ruleStrings,
  84. ],
  85. },
  86. }
  87. }
  88. case 'replaceRules': {
  89. const ruleStrings = update.rules.map(rule =>
  90. permissionRuleValueToString(rule),
  91. )
  92. logForDebugging(
  93. `Replacing all ${update.behavior} rules for destination '${update.destination}' with ${update.rules.length} rule(s): ${jsonStringify(ruleStrings)}`,
  94. )
  95. // Determine which collection to update based on behavior
  96. const ruleKind =
  97. update.behavior === 'allow'
  98. ? 'alwaysAllowRules'
  99. : update.behavior === 'deny'
  100. ? 'alwaysDenyRules'
  101. : 'alwaysAskRules'
  102. return {
  103. ...context,
  104. [ruleKind]: {
  105. ...context[ruleKind],
  106. [update.destination]: ruleStrings, // Replace all rules for this source
  107. },
  108. }
  109. }
  110. case 'addDirectories': {
  111. logForDebugging(
  112. `Applying permission update: Adding ${update.directories.length} director${update.directories.length === 1 ? 'y' : 'ies'} with destination '${update.destination}': ${jsonStringify(update.directories)}`,
  113. )
  114. const newAdditionalDirs = new Map(context.additionalWorkingDirectories)
  115. for (const directory of update.directories) {
  116. newAdditionalDirs.set(directory, {
  117. path: directory,
  118. source: update.destination,
  119. })
  120. }
  121. return {
  122. ...context,
  123. additionalWorkingDirectories: newAdditionalDirs,
  124. }
  125. }
  126. case 'removeRules': {
  127. const ruleStrings = update.rules.map(rule =>
  128. permissionRuleValueToString(rule),
  129. )
  130. logForDebugging(
  131. `Applying permission update: Removing ${update.rules.length} ${update.behavior} rule(s) from source '${update.destination}': ${jsonStringify(ruleStrings)}`,
  132. )
  133. // Determine which collection to update based on behavior
  134. const ruleKind =
  135. update.behavior === 'allow'
  136. ? 'alwaysAllowRules'
  137. : update.behavior === 'deny'
  138. ? 'alwaysDenyRules'
  139. : 'alwaysAskRules'
  140. // Filter out the rules to be removed
  141. const existingRules = context[ruleKind][update.destination] || []
  142. const rulesToRemove = new Set(ruleStrings)
  143. const filteredRules = existingRules.filter(
  144. rule => !rulesToRemove.has(rule),
  145. )
  146. return {
  147. ...context,
  148. [ruleKind]: {
  149. ...context[ruleKind],
  150. [update.destination]: filteredRules,
  151. },
  152. }
  153. }
  154. case 'removeDirectories': {
  155. logForDebugging(
  156. `Applying permission update: Removing ${update.directories.length} director${update.directories.length === 1 ? 'y' : 'ies'}: ${jsonStringify(update.directories)}`,
  157. )
  158. const newAdditionalDirs = new Map(context.additionalWorkingDirectories)
  159. for (const directory of update.directories) {
  160. newAdditionalDirs.delete(directory)
  161. }
  162. return {
  163. ...context,
  164. additionalWorkingDirectories: newAdditionalDirs,
  165. }
  166. }
  167. default:
  168. return context
  169. }
  170. }
  171. /**
  172. * Applies multiple permission updates to the context and returns the updated context
  173. * @param context The current permission context
  174. * @param updates The permission updates to apply
  175. * @returns The updated permission context
  176. */
  177. export function applyPermissionUpdates(
  178. context: ToolPermissionContext,
  179. updates: PermissionUpdate[],
  180. ): ToolPermissionContext {
  181. let updatedContext = context
  182. for (const update of updates) {
  183. updatedContext = applyPermissionUpdate(updatedContext, update)
  184. }
  185. return updatedContext
  186. }
  187. export function supportsPersistence(
  188. destination: PermissionUpdateDestination,
  189. ): destination is EditableSettingSource {
  190. return (
  191. destination === 'localSettings' ||
  192. destination === 'userSettings' ||
  193. destination === 'projectSettings'
  194. )
  195. }
  196. /**
  197. * Persists a permission update to the appropriate settings source
  198. * @param update The permission update to persist
  199. */
  200. export function persistPermissionUpdate(update: PermissionUpdate): void {
  201. if (!supportsPersistence(update.destination)) return
  202. logForDebugging(
  203. `Persisting permission update: ${update.type} to source '${update.destination}'`,
  204. )
  205. switch (update.type) {
  206. case 'addRules': {
  207. logForDebugging(
  208. `Persisting ${update.rules.length} ${update.behavior} rule(s) to ${update.destination}`,
  209. )
  210. addPermissionRulesToSettings(
  211. {
  212. ruleValues: update.rules,
  213. ruleBehavior: update.behavior,
  214. },
  215. update.destination,
  216. )
  217. break
  218. }
  219. case 'addDirectories': {
  220. logForDebugging(
  221. `Persisting ${update.directories.length} director${update.directories.length === 1 ? 'y' : 'ies'} to ${update.destination}`,
  222. )
  223. const existingSettings = getSettingsForSource(update.destination)
  224. const existingDirs =
  225. existingSettings?.permissions?.additionalDirectories || []
  226. // Add new directories, avoiding duplicates
  227. const dirsToAdd = update.directories.filter(
  228. dir => !existingDirs.includes(dir),
  229. )
  230. if (dirsToAdd.length > 0) {
  231. const updatedDirs = [...existingDirs, ...dirsToAdd]
  232. updateSettingsForSource(update.destination, {
  233. permissions: {
  234. additionalDirectories: updatedDirs,
  235. },
  236. })
  237. }
  238. break
  239. }
  240. case 'removeRules': {
  241. // Handle rule removal
  242. logForDebugging(
  243. `Removing ${update.rules.length} ${update.behavior} rule(s) from ${update.destination}`,
  244. )
  245. const existingSettings = getSettingsForSource(update.destination)
  246. const existingPermissions = existingSettings?.permissions || {}
  247. const existingRules = existingPermissions[update.behavior] || []
  248. // Convert rules to normalized strings for comparison
  249. // Normalize via parse→serialize roundtrip so "Bash(*)" and "Bash" match
  250. const rulesToRemove = new Set(
  251. update.rules.map(permissionRuleValueToString),
  252. )
  253. const filteredRules = existingRules.filter(rule => {
  254. const normalized = permissionRuleValueToString(
  255. permissionRuleValueFromString(rule),
  256. )
  257. return !rulesToRemove.has(normalized)
  258. })
  259. updateSettingsForSource(update.destination, {
  260. permissions: {
  261. [update.behavior]: filteredRules,
  262. },
  263. })
  264. break
  265. }
  266. case 'removeDirectories': {
  267. logForDebugging(
  268. `Removing ${update.directories.length} director${update.directories.length === 1 ? 'y' : 'ies'} from ${update.destination}`,
  269. )
  270. const existingSettings = getSettingsForSource(update.destination)
  271. const existingDirs =
  272. existingSettings?.permissions?.additionalDirectories || []
  273. // Remove specified directories
  274. const dirsToRemove = new Set(update.directories)
  275. const filteredDirs = existingDirs.filter(dir => !dirsToRemove.has(dir))
  276. updateSettingsForSource(update.destination, {
  277. permissions: {
  278. additionalDirectories: filteredDirs,
  279. },
  280. })
  281. break
  282. }
  283. case 'setMode': {
  284. logForDebugging(
  285. `Persisting mode '${update.mode}' to ${update.destination}`,
  286. )
  287. updateSettingsForSource(update.destination, {
  288. permissions: {
  289. defaultMode: update.mode,
  290. },
  291. })
  292. break
  293. }
  294. case 'replaceRules': {
  295. logForDebugging(
  296. `Replacing all ${update.behavior} rules in ${update.destination} with ${update.rules.length} rule(s)`,
  297. )
  298. const ruleStrings = update.rules.map(permissionRuleValueToString)
  299. updateSettingsForSource(update.destination, {
  300. permissions: {
  301. [update.behavior]: ruleStrings,
  302. },
  303. })
  304. break
  305. }
  306. }
  307. }
  308. /**
  309. * Persists multiple permission updates to the appropriate settings sources
  310. * Only persists updates with persistable sources
  311. * @param updates The permission updates to persist
  312. */
  313. export function persistPermissionUpdates(updates: PermissionUpdate[]): void {
  314. for (const update of updates) {
  315. persistPermissionUpdate(update)
  316. }
  317. }
  318. /**
  319. * Creates a Read rule suggestion for a directory.
  320. * @param dirPath The directory path to create a rule for
  321. * @param destination The destination for the permission rule (defaults to 'session')
  322. * @returns A PermissionUpdate for a Read rule, or undefined for the root directory
  323. */
  324. export function createReadRuleSuggestion(
  325. dirPath: string,
  326. destination: PermissionUpdateDestination = 'session',
  327. ): PermissionUpdate | undefined {
  328. // Convert to POSIX format for pattern matching (handles Windows internally)
  329. const pathForPattern = toPosixPath(dirPath)
  330. // Root directory is too broad to be a reasonable permission target
  331. if (pathForPattern === '/') {
  332. return undefined
  333. }
  334. // For absolute paths, prepend an extra / to create //path/** pattern
  335. const ruleContent = posix.isAbsolute(pathForPattern)
  336. ? `/${pathForPattern}/**`
  337. : `${pathForPattern}/**`
  338. return {
  339. type: 'addRules',
  340. rules: [
  341. {
  342. toolName: 'Read',
  343. ruleContent,
  344. },
  345. ],
  346. behavior: 'allow',
  347. destination,
  348. }
  349. }