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