/ src / utils / permissions / permissionsLoader.ts
permissionsLoader.ts
  1  import { readFileSync } from '../fileRead.js'
  2  import { getFsImplementation, safeResolvePath } from '../fsOperations.js'
  3  import { safeParseJSON } from '../json.js'
  4  import { logError } from '../log.js'
  5  import {
  6    type EditableSettingSource,
  7    getEnabledSettingSources,
  8    type SettingSource,
  9  } from '../settings/constants.js'
 10  import {
 11    getSettingsFilePathForSource,
 12    getSettingsForSource,
 13    updateSettingsForSource,
 14  } from '../settings/settings.js'
 15  import type { SettingsJson } from '../settings/types.js'
 16  import type {
 17    PermissionBehavior,
 18    PermissionRule,
 19    PermissionRuleSource,
 20    PermissionRuleValue,
 21  } from './PermissionRule.js'
 22  import {
 23    permissionRuleValueFromString,
 24    permissionRuleValueToString,
 25  } from './permissionRuleParser.js'
 26  
 27  /**
 28   * Returns true if allowManagedPermissionRulesOnly is enabled in managed settings (policySettings).
 29   * When enabled, only permission rules from managed settings are respected.
 30   */
 31  export function shouldAllowManagedPermissionRulesOnly(): boolean {
 32    return (
 33      getSettingsForSource('policySettings')?.allowManagedPermissionRulesOnly ===
 34      true
 35    )
 36  }
 37  
 38  /**
 39   * Returns true if "always allow" options should be shown in permission prompts.
 40   * When allowManagedPermissionRulesOnly is enabled, these options are hidden.
 41   */
 42  export function shouldShowAlwaysAllowOptions(): boolean {
 43    return !shouldAllowManagedPermissionRulesOnly()
 44  }
 45  
 46  const SUPPORTED_RULE_BEHAVIORS = [
 47    'allow',
 48    'deny',
 49    'ask',
 50  ] as const satisfies PermissionBehavior[]
 51  
 52  /**
 53   * Lenient version of getSettingsForSource that doesn't fail on ANY validation errors.
 54   * Simply parses the JSON and returns it as-is without schema validation.
 55   *
 56   * Used when loading settings to append new rules (avoids losing existing rules
 57   * due to validation failures in unrelated fields like hooks).
 58   *
 59   * FOR EDITING ONLY - do not use this for reading settings for execution.
 60   */
 61  function getSettingsForSourceLenient_FOR_EDITING_ONLY_NOT_FOR_READING(
 62    source: SettingSource,
 63  ): SettingsJson | null {
 64    const filePath = getSettingsFilePathForSource(source)
 65    if (!filePath) {
 66      return null
 67    }
 68  
 69    try {
 70      const { resolvedPath } = safeResolvePath(getFsImplementation(), filePath)
 71      const content = readFileSync(resolvedPath)
 72      if (content.trim() === '') {
 73        return {}
 74      }
 75  
 76      const data = safeParseJSON(content, false)
 77      // Return raw parsed JSON without validation to preserve all existing settings
 78      // This is safe because we're only using this for reading/appending, not for execution
 79      return data && typeof data === 'object' ? (data as SettingsJson) : null
 80    } catch {
 81      return null
 82    }
 83  }
 84  
 85  /**
 86   * Converts permissions JSON to an array of PermissionRule objects
 87   * @param data The parsed permissions data
 88   * @param source The source of these rules
 89   * @returns Array of PermissionRule objects
 90   */
 91  function settingsJsonToRules(
 92    data: SettingsJson | null,
 93    source: PermissionRuleSource,
 94  ): PermissionRule[] {
 95    if (!data || !data.permissions) {
 96      return []
 97    }
 98  
 99    const { permissions } = data
100    const rules: PermissionRule[] = []
101    for (const behavior of SUPPORTED_RULE_BEHAVIORS) {
102      const behaviorArray = permissions[behavior]
103      if (behaviorArray) {
104        for (const ruleString of behaviorArray) {
105          rules.push({
106            source,
107            ruleBehavior: behavior,
108            ruleValue: permissionRuleValueFromString(ruleString),
109          })
110        }
111      }
112    }
113    return rules
114  }
115  
116  /**
117   * Loads all permission rules from all relevant sources (managed and project settings)
118   * @returns Array of all permission rules
119   */
120  export function loadAllPermissionRulesFromDisk(): PermissionRule[] {
121    // If allowManagedPermissionRulesOnly is set, only use managed permission rules
122    if (shouldAllowManagedPermissionRulesOnly()) {
123      return getPermissionRulesForSource('policySettings')
124    }
125  
126    // Otherwise, load from all enabled sources (backwards compatible)
127    const rules: PermissionRule[] = []
128  
129    for (const source of getEnabledSettingSources()) {
130      rules.push(...getPermissionRulesForSource(source))
131    }
132    return rules
133  }
134  
135  /**
136   * Loads permission rules from a specific source
137   * @param source The source to load from
138   * @returns Array of permission rules from that source
139   */
140  export function getPermissionRulesForSource(
141    source: SettingSource,
142  ): PermissionRule[] {
143    const settingsData = getSettingsForSource(source)
144    return settingsJsonToRules(settingsData, source)
145  }
146  
147  export type PermissionRuleFromEditableSettings = PermissionRule & {
148    source: EditableSettingSource
149  }
150  
151  // Editable sources that can be modified (excludes policySettings and flagSettings)
152  const EDITABLE_SOURCES: EditableSettingSource[] = [
153    'userSettings',
154    'projectSettings',
155    'localSettings',
156  ]
157  
158  /**
159   * Deletes a rule from the project permissions file
160   * @param rule The rule to delete
161   * @returns Promise resolving to a boolean indicating success
162   */
163  export function deletePermissionRuleFromSettings(
164    rule: PermissionRuleFromEditableSettings,
165  ): boolean {
166    // Runtime check to ensure source is actually editable
167    if (!EDITABLE_SOURCES.includes(rule.source as EditableSettingSource)) {
168      return false
169    }
170  
171    const ruleString = permissionRuleValueToString(rule.ruleValue)
172    const settingsData = getSettingsForSource(rule.source)
173  
174    // If there's no settings data or permissions, nothing to do
175    if (!settingsData || !settingsData.permissions) {
176      return false
177    }
178  
179    const behaviorArray = settingsData.permissions[rule.ruleBehavior]
180    if (!behaviorArray) {
181      return false
182    }
183  
184    // Normalize raw settings entries via roundtrip parse→serialize so legacy
185    // names (e.g. "KillShell") match their canonical form ("TaskStop").
186    const normalizeEntry = (raw: string): string =>
187      permissionRuleValueToString(permissionRuleValueFromString(raw))
188  
189    if (!behaviorArray.some(raw => normalizeEntry(raw) === ruleString)) {
190      return false
191    }
192  
193    try {
194      // Keep a copy of the original permissions data to preserve unrecognized keys
195      const updatedSettingsData = {
196        ...settingsData,
197        permissions: {
198          ...settingsData.permissions,
199          [rule.ruleBehavior]: behaviorArray.filter(
200            raw => normalizeEntry(raw) !== ruleString,
201          ),
202        },
203      }
204  
205      const { error } = updateSettingsForSource(rule.source, updatedSettingsData)
206      if (error) {
207        // Error already logged inside updateSettingsForSource
208        return false
209      }
210  
211      return true
212    } catch (error) {
213      logError(error)
214      return false
215    }
216  }
217  
218  function getEmptyPermissionSettingsJson(): SettingsJson {
219    return {
220      permissions: {},
221    }
222  }
223  
224  /**
225   * Adds rules to the project permissions file
226   * @param ruleValues The rule values to add
227   * @returns Promise resolving to a boolean indicating success
228   */
229  export function addPermissionRulesToSettings(
230    {
231      ruleValues,
232      ruleBehavior,
233    }: {
234      ruleValues: PermissionRuleValue[]
235      ruleBehavior: PermissionBehavior
236    },
237    source: EditableSettingSource,
238  ): boolean {
239    // When allowManagedPermissionRulesOnly is enabled, don't persist new permission rules
240    if (shouldAllowManagedPermissionRulesOnly()) {
241      return false
242    }
243  
244    if (ruleValues.length < 1) {
245      // No rules to add
246      return true
247    }
248  
249    const ruleStrings = ruleValues.map(permissionRuleValueToString)
250    // First try the normal settings loader which validates the schema
251    // If validation fails, fall back to lenient loading to preserve existing rules
252    // even if some fields (like hooks) have validation errors
253    const settingsData =
254      getSettingsForSource(source) ||
255      getSettingsForSourceLenient_FOR_EDITING_ONLY_NOT_FOR_READING(source) ||
256      getEmptyPermissionSettingsJson()
257  
258    try {
259      // Ensure permissions object exists
260      const existingPermissions = settingsData.permissions || {}
261      const existingRules = existingPermissions[ruleBehavior] || []
262  
263      // Filter out duplicates - normalize existing entries via roundtrip
264      // parse→serialize so legacy names match their canonical form.
265      const existingRulesSet = new Set(
266        existingRules.map(raw =>
267          permissionRuleValueToString(permissionRuleValueFromString(raw)),
268        ),
269      )
270      const newRules = ruleStrings.filter(rule => !existingRulesSet.has(rule))
271  
272      // If no new rules to add, return success
273      if (newRules.length === 0) {
274        return true
275      }
276  
277      // Keep a copy of the original settings data to preserve unrecognized keys
278      const updatedSettingsData = {
279        ...settingsData,
280        permissions: {
281          ...existingPermissions,
282          [ruleBehavior]: [...existingRules, ...newRules],
283        },
284      }
285      const result = updateSettingsForSource(source, updatedSettingsData)
286  
287      if (result.error) {
288        throw result.error
289      }
290  
291      return true
292    } catch (error) {
293      logError(error)
294      return false
295    }
296  }