390 lines
12 KiB
TypeScript
390 lines
12 KiB
TypeScript
import { posix } from 'path'
|
|
import type { ToolPermissionContext } from '../../Tool.js'
|
|
// Types extracted to src/types/permissions.ts to break import cycles
|
|
import type {
|
|
AdditionalWorkingDirectory,
|
|
WorkingDirectorySource,
|
|
} from '../../types/permissions.js'
|
|
import { logForDebugging } from '../debug.js'
|
|
import type { EditableSettingSource } from '../settings/constants.js'
|
|
import {
|
|
getSettingsForSource,
|
|
updateSettingsForSource,
|
|
} from '../settings/settings.js'
|
|
import { jsonStringify } from '../slowOperations.js'
|
|
import { toPosixPath } from './filesystem.js'
|
|
import type { PermissionRuleValue } from './PermissionRule.js'
|
|
import type {
|
|
PermissionUpdate,
|
|
PermissionUpdateDestination,
|
|
} from './PermissionUpdateSchema.js'
|
|
import {
|
|
permissionRuleValueFromString,
|
|
permissionRuleValueToString,
|
|
} from './permissionRuleParser.js'
|
|
import { addPermissionRulesToSettings } from './permissionsLoader.js'
|
|
|
|
// Re-export for backwards compatibility
|
|
export type { AdditionalWorkingDirectory, WorkingDirectorySource }
|
|
|
|
export function extractRules(
|
|
updates: PermissionUpdate[] | undefined,
|
|
): PermissionRuleValue[] {
|
|
if (!updates) return []
|
|
|
|
return updates.flatMap(update => {
|
|
switch (update.type) {
|
|
case 'addRules':
|
|
return update.rules
|
|
default:
|
|
return []
|
|
}
|
|
})
|
|
}
|
|
|
|
export function hasRules(updates: PermissionUpdate[] | undefined): boolean {
|
|
return extractRules(updates).length > 0
|
|
}
|
|
|
|
/**
|
|
* Applies a single permission update to the context and returns the updated context
|
|
* @param context The current permission context
|
|
* @param update The permission update to apply
|
|
* @returns The updated permission context
|
|
*/
|
|
export function applyPermissionUpdate(
|
|
context: ToolPermissionContext,
|
|
update: PermissionUpdate,
|
|
): ToolPermissionContext {
|
|
switch (update.type) {
|
|
case 'setMode':
|
|
logForDebugging(
|
|
`Applying permission update: Setting mode to '${update.mode}'`,
|
|
)
|
|
return {
|
|
...context,
|
|
mode: update.mode,
|
|
}
|
|
|
|
case 'addRules': {
|
|
const ruleStrings = update.rules.map(rule =>
|
|
permissionRuleValueToString(rule),
|
|
)
|
|
logForDebugging(
|
|
`Applying permission update: Adding ${update.rules.length} ${update.behavior} rule(s) to destination '${update.destination}': ${jsonStringify(ruleStrings)}`,
|
|
)
|
|
|
|
// Determine which collection to update based on behavior
|
|
const ruleKind =
|
|
update.behavior === 'allow'
|
|
? 'alwaysAllowRules'
|
|
: update.behavior === 'deny'
|
|
? 'alwaysDenyRules'
|
|
: 'alwaysAskRules'
|
|
|
|
return {
|
|
...context,
|
|
[ruleKind]: {
|
|
...context[ruleKind],
|
|
[update.destination]: [
|
|
...(context[ruleKind][update.destination] || []),
|
|
...ruleStrings,
|
|
],
|
|
},
|
|
}
|
|
}
|
|
|
|
case 'replaceRules': {
|
|
const ruleStrings = update.rules.map(rule =>
|
|
permissionRuleValueToString(rule),
|
|
)
|
|
logForDebugging(
|
|
`Replacing all ${update.behavior} rules for destination '${update.destination}' with ${update.rules.length} rule(s): ${jsonStringify(ruleStrings)}`,
|
|
)
|
|
|
|
// Determine which collection to update based on behavior
|
|
const ruleKind =
|
|
update.behavior === 'allow'
|
|
? 'alwaysAllowRules'
|
|
: update.behavior === 'deny'
|
|
? 'alwaysDenyRules'
|
|
: 'alwaysAskRules'
|
|
|
|
return {
|
|
...context,
|
|
[ruleKind]: {
|
|
...context[ruleKind],
|
|
[update.destination]: ruleStrings, // Replace all rules for this source
|
|
},
|
|
}
|
|
}
|
|
|
|
case 'addDirectories': {
|
|
logForDebugging(
|
|
`Applying permission update: Adding ${update.directories.length} director${update.directories.length === 1 ? 'y' : 'ies'} with destination '${update.destination}': ${jsonStringify(update.directories)}`,
|
|
)
|
|
const newAdditionalDirs = new Map(context.additionalWorkingDirectories)
|
|
for (const directory of update.directories) {
|
|
newAdditionalDirs.set(directory, {
|
|
path: directory,
|
|
source: update.destination,
|
|
})
|
|
}
|
|
return {
|
|
...context,
|
|
additionalWorkingDirectories: newAdditionalDirs,
|
|
}
|
|
}
|
|
|
|
case 'removeRules': {
|
|
const ruleStrings = update.rules.map(rule =>
|
|
permissionRuleValueToString(rule),
|
|
)
|
|
logForDebugging(
|
|
`Applying permission update: Removing ${update.rules.length} ${update.behavior} rule(s) from source '${update.destination}': ${jsonStringify(ruleStrings)}`,
|
|
)
|
|
|
|
// Determine which collection to update based on behavior
|
|
const ruleKind =
|
|
update.behavior === 'allow'
|
|
? 'alwaysAllowRules'
|
|
: update.behavior === 'deny'
|
|
? 'alwaysDenyRules'
|
|
: 'alwaysAskRules'
|
|
|
|
// Filter out the rules to be removed
|
|
const existingRules = context[ruleKind][update.destination] || []
|
|
const rulesToRemove = new Set(ruleStrings)
|
|
const filteredRules = existingRules.filter(
|
|
rule => !rulesToRemove.has(rule),
|
|
)
|
|
|
|
return {
|
|
...context,
|
|
[ruleKind]: {
|
|
...context[ruleKind],
|
|
[update.destination]: filteredRules,
|
|
},
|
|
}
|
|
}
|
|
|
|
case 'removeDirectories': {
|
|
logForDebugging(
|
|
`Applying permission update: Removing ${update.directories.length} director${update.directories.length === 1 ? 'y' : 'ies'}: ${jsonStringify(update.directories)}`,
|
|
)
|
|
const newAdditionalDirs = new Map(context.additionalWorkingDirectories)
|
|
for (const directory of update.directories) {
|
|
newAdditionalDirs.delete(directory)
|
|
}
|
|
return {
|
|
...context,
|
|
additionalWorkingDirectories: newAdditionalDirs,
|
|
}
|
|
}
|
|
|
|
default:
|
|
return context
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Applies multiple permission updates to the context and returns the updated context
|
|
* @param context The current permission context
|
|
* @param updates The permission updates to apply
|
|
* @returns The updated permission context
|
|
*/
|
|
export function applyPermissionUpdates(
|
|
context: ToolPermissionContext,
|
|
updates: PermissionUpdate[],
|
|
): ToolPermissionContext {
|
|
let updatedContext = context
|
|
for (const update of updates) {
|
|
updatedContext = applyPermissionUpdate(updatedContext, update)
|
|
}
|
|
|
|
return updatedContext
|
|
}
|
|
|
|
export function supportsPersistence(
|
|
destination: PermissionUpdateDestination,
|
|
): destination is EditableSettingSource {
|
|
return (
|
|
destination === 'localSettings' ||
|
|
destination === 'userSettings' ||
|
|
destination === 'projectSettings'
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Persists a permission update to the appropriate settings source
|
|
* @param update The permission update to persist
|
|
*/
|
|
export function persistPermissionUpdate(update: PermissionUpdate): void {
|
|
if (!supportsPersistence(update.destination)) return
|
|
|
|
logForDebugging(
|
|
`Persisting permission update: ${update.type} to source '${update.destination}'`,
|
|
)
|
|
|
|
switch (update.type) {
|
|
case 'addRules': {
|
|
logForDebugging(
|
|
`Persisting ${update.rules.length} ${update.behavior} rule(s) to ${update.destination}`,
|
|
)
|
|
addPermissionRulesToSettings(
|
|
{
|
|
ruleValues: update.rules,
|
|
ruleBehavior: update.behavior,
|
|
},
|
|
update.destination,
|
|
)
|
|
break
|
|
}
|
|
|
|
case 'addDirectories': {
|
|
logForDebugging(
|
|
`Persisting ${update.directories.length} director${update.directories.length === 1 ? 'y' : 'ies'} to ${update.destination}`,
|
|
)
|
|
const existingSettings = getSettingsForSource(update.destination)
|
|
const existingDirs =
|
|
existingSettings?.permissions?.additionalDirectories || []
|
|
|
|
// Add new directories, avoiding duplicates
|
|
const dirsToAdd = update.directories.filter(
|
|
dir => !existingDirs.includes(dir),
|
|
)
|
|
|
|
if (dirsToAdd.length > 0) {
|
|
const updatedDirs = [...existingDirs, ...dirsToAdd]
|
|
updateSettingsForSource(update.destination, {
|
|
permissions: {
|
|
additionalDirectories: updatedDirs,
|
|
},
|
|
})
|
|
}
|
|
break
|
|
}
|
|
|
|
case 'removeRules': {
|
|
// Handle rule removal
|
|
logForDebugging(
|
|
`Removing ${update.rules.length} ${update.behavior} rule(s) from ${update.destination}`,
|
|
)
|
|
const existingSettings = getSettingsForSource(update.destination)
|
|
const existingPermissions = existingSettings?.permissions || {}
|
|
const existingRules = existingPermissions[update.behavior] || []
|
|
|
|
// Convert rules to normalized strings for comparison
|
|
// Normalize via parse→serialize roundtrip so "Bash(*)" and "Bash" match
|
|
const rulesToRemove = new Set(
|
|
update.rules.map(permissionRuleValueToString),
|
|
)
|
|
const filteredRules = existingRules.filter(rule => {
|
|
const normalized = permissionRuleValueToString(
|
|
permissionRuleValueFromString(rule),
|
|
)
|
|
return !rulesToRemove.has(normalized)
|
|
})
|
|
|
|
updateSettingsForSource(update.destination, {
|
|
permissions: {
|
|
[update.behavior]: filteredRules,
|
|
},
|
|
})
|
|
break
|
|
}
|
|
|
|
case 'removeDirectories': {
|
|
logForDebugging(
|
|
`Removing ${update.directories.length} director${update.directories.length === 1 ? 'y' : 'ies'} from ${update.destination}`,
|
|
)
|
|
const existingSettings = getSettingsForSource(update.destination)
|
|
const existingDirs =
|
|
existingSettings?.permissions?.additionalDirectories || []
|
|
|
|
// Remove specified directories
|
|
const dirsToRemove = new Set(update.directories)
|
|
const filteredDirs = existingDirs.filter(dir => !dirsToRemove.has(dir))
|
|
|
|
updateSettingsForSource(update.destination, {
|
|
permissions: {
|
|
additionalDirectories: filteredDirs,
|
|
},
|
|
})
|
|
break
|
|
}
|
|
|
|
case 'setMode': {
|
|
logForDebugging(
|
|
`Persisting mode '${update.mode}' to ${update.destination}`,
|
|
)
|
|
updateSettingsForSource(update.destination, {
|
|
permissions: {
|
|
defaultMode: update.mode,
|
|
},
|
|
})
|
|
break
|
|
}
|
|
|
|
case 'replaceRules': {
|
|
logForDebugging(
|
|
`Replacing all ${update.behavior} rules in ${update.destination} with ${update.rules.length} rule(s)`,
|
|
)
|
|
const ruleStrings = update.rules.map(permissionRuleValueToString)
|
|
updateSettingsForSource(update.destination, {
|
|
permissions: {
|
|
[update.behavior]: ruleStrings,
|
|
},
|
|
})
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Persists multiple permission updates to the appropriate settings sources
|
|
* Only persists updates with persistable sources
|
|
* @param updates The permission updates to persist
|
|
*/
|
|
export function persistPermissionUpdates(updates: PermissionUpdate[]): void {
|
|
for (const update of updates) {
|
|
persistPermissionUpdate(update)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Creates a Read rule suggestion for a directory.
|
|
* @param dirPath The directory path to create a rule for
|
|
* @param destination The destination for the permission rule (defaults to 'session')
|
|
* @returns A PermissionUpdate for a Read rule, or undefined for the root directory
|
|
*/
|
|
export function createReadRuleSuggestion(
|
|
dirPath: string,
|
|
destination: PermissionUpdateDestination = 'session',
|
|
): PermissionUpdate | undefined {
|
|
// Convert to POSIX format for pattern matching (handles Windows internally)
|
|
const pathForPattern = toPosixPath(dirPath)
|
|
|
|
// Root directory is too broad to be a reasonable permission target
|
|
if (pathForPattern === '/') {
|
|
return undefined
|
|
}
|
|
|
|
// For absolute paths, prepend an extra / to create //path/** pattern
|
|
const ruleContent = posix.isAbsolute(pathForPattern)
|
|
? `/${pathForPattern}/**`
|
|
: `${pathForPattern}/**`
|
|
|
|
return {
|
|
type: 'addRules',
|
|
rules: [
|
|
{
|
|
toolName: 'Read',
|
|
ruleContent,
|
|
},
|
|
],
|
|
behavior: 'allow',
|
|
destination,
|
|
}
|
|
}
|