| |
| |
| |
| |
| |
| |
| |
|
|
| import { homedir } from 'os' |
| import { isAbsolute, resolve } from 'path' |
| import type { ToolPermissionContext } from '../../Tool.js' |
| import type { PermissionRule } from '../../types/permissions.js' |
| import { getCwd } from '../../utils/cwd.js' |
| import { |
| getFsImplementation, |
| safeResolvePath, |
| } from '../../utils/fsOperations.js' |
| import { containsPathTraversal, getDirectoryForPath } from '../../utils/path.js' |
| import { |
| allWorkingDirectories, |
| checkEditableInternalPath, |
| checkPathSafetyForAutoEdit, |
| checkReadableInternalPath, |
| matchingRuleForInput, |
| pathInAllowedWorkingPath, |
| } from '../../utils/permissions/filesystem.js' |
| import type { PermissionResult } from '../../utils/permissions/PermissionResult.js' |
| import { createReadRuleSuggestion } from '../../utils/permissions/PermissionUpdate.js' |
| import type { PermissionUpdate } from '../../utils/permissions/PermissionUpdateSchema.js' |
| import { |
| isDangerousRemovalPath, |
| isPathInSandboxWriteAllowlist, |
| } from '../../utils/permissions/pathValidation.js' |
| import { getPlatform } from '../../utils/platform.js' |
| import type { |
| ParsedCommandElement, |
| ParsedPowerShellCommand, |
| } from '../../utils/powershell/parser.js' |
| import { |
| isNullRedirectionTarget, |
| isPowerShellParameter, |
| } from '../../utils/powershell/parser.js' |
| import { COMMON_SWITCHES, COMMON_VALUE_PARAMS } from './commonParameters.js' |
| import { resolveToCanonical } from './readOnlyValidation.js' |
|
|
| const MAX_DIRS_TO_LIST = 5 |
| |
| |
| |
| const GLOB_PATTERN_REGEX = /[*?[\]]/ |
|
|
| type FileOperationType = 'read' | 'write' | 'create' |
|
|
| type PathCheckResult = { |
| allowed: boolean |
| decisionReason?: import('../../utils/permissions/PermissionResult.js').PermissionDecisionReason |
| } |
|
|
| type ResolvedPathCheckResult = PathCheckResult & { |
| resolvedPath: string |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| type CmdletPathConfig = { |
| operationType: FileOperationType |
| |
| pathParams: string[] |
| |
| knownSwitches: string[] |
| |
| knownValueParams: string[] |
| |
| |
| |
| |
| |
| |
| |
| |
| leafOnlyPathParams?: string[] |
| |
| |
| |
| |
| |
| |
| |
| |
| positionalSkip?: number |
| |
| |
| |
| |
| |
| |
| |
| optionalWrite?: boolean |
| } |
|
|
| const CMDLET_PATH_CONFIG: Record<string, CmdletPathConfig> = { |
| |
| 'set-content': { |
| operationType: 'write', |
| |
| |
| |
| pathParams: ['-path', '-literalpath', '-pspath', '-lp'], |
| knownSwitches: [ |
| '-passthru', |
| '-force', |
| '-whatif', |
| '-confirm', |
| '-usetransaction', |
| '-nonewline', |
| '-asbytestream', |
| ], |
| knownValueParams: [ |
| '-value', |
| '-filter', |
| '-include', |
| '-exclude', |
| '-credential', |
| '-encoding', |
| '-stream', |
| ], |
| }, |
| 'add-content': { |
| operationType: 'write', |
| pathParams: ['-path', '-literalpath', '-pspath', '-lp'], |
| knownSwitches: [ |
| '-passthru', |
| '-force', |
| '-whatif', |
| '-confirm', |
| '-usetransaction', |
| '-nonewline', |
| '-asbytestream', |
| ], |
| knownValueParams: [ |
| '-value', |
| '-filter', |
| '-include', |
| '-exclude', |
| '-credential', |
| '-encoding', |
| '-stream', |
| ], |
| }, |
| 'remove-item': { |
| operationType: 'write', |
| pathParams: ['-path', '-literalpath', '-pspath', '-lp'], |
| knownSwitches: [ |
| '-recurse', |
| '-force', |
| '-whatif', |
| '-confirm', |
| '-usetransaction', |
| ], |
| knownValueParams: [ |
| '-filter', |
| '-include', |
| '-exclude', |
| '-credential', |
| '-stream', |
| ], |
| }, |
| 'clear-content': { |
| operationType: 'write', |
| pathParams: ['-path', '-literalpath', '-pspath', '-lp'], |
| knownSwitches: ['-force', '-whatif', '-confirm', '-usetransaction'], |
| knownValueParams: [ |
| '-filter', |
| '-include', |
| '-exclude', |
| '-credential', |
| '-stream', |
| ], |
| }, |
| |
| |
| |
| |
| 'out-file': { |
| operationType: 'write', |
| |
| |
| |
| |
| pathParams: ['-filepath', '-path', '-literalpath', '-pspath', '-lp'], |
| knownSwitches: [ |
| '-append', |
| '-force', |
| '-noclobber', |
| '-nonewline', |
| '-whatif', |
| '-confirm', |
| ], |
| knownValueParams: ['-inputobject', '-encoding', '-width'], |
| }, |
| 'tee-object': { |
| operationType: 'write', |
| |
| pathParams: ['-filepath', '-path', '-literalpath', '-pspath', '-lp'], |
| knownSwitches: ['-append'], |
| knownValueParams: ['-inputobject', '-variable', '-encoding'], |
| }, |
| 'export-csv': { |
| operationType: 'write', |
| pathParams: ['-path', '-literalpath', '-pspath', '-lp'], |
| knownSwitches: [ |
| '-append', |
| '-force', |
| '-noclobber', |
| '-notypeinformation', |
| '-includetypeinformation', |
| '-useculture', |
| '-noheader', |
| '-whatif', |
| '-confirm', |
| ], |
| knownValueParams: [ |
| '-inputobject', |
| '-delimiter', |
| '-encoding', |
| '-quotefields', |
| '-usequotes', |
| ], |
| }, |
| 'export-clixml': { |
| operationType: 'write', |
| pathParams: ['-path', '-literalpath', '-pspath', '-lp'], |
| knownSwitches: ['-force', '-noclobber', '-whatif', '-confirm'], |
| knownValueParams: ['-inputobject', '-depth', '-encoding'], |
| }, |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 'new-item': { |
| operationType: 'write', |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| pathParams: ['-path', '-literalpath', '-pspath', '-lp'], |
| leafOnlyPathParams: ['-name'], |
| knownSwitches: ['-force', '-whatif', '-confirm', '-usetransaction'], |
| knownValueParams: ['-itemtype', '-value', '-credential', '-type'], |
| }, |
| 'copy-item': { |
| operationType: 'write', |
| |
| |
| pathParams: ['-path', '-literalpath', '-pspath', '-lp', '-destination'], |
| knownSwitches: [ |
| '-container', |
| '-force', |
| '-passthru', |
| '-recurse', |
| '-whatif', |
| '-confirm', |
| '-usetransaction', |
| ], |
| knownValueParams: [ |
| '-filter', |
| '-include', |
| '-exclude', |
| '-credential', |
| '-fromsession', |
| '-tosession', |
| ], |
| }, |
| 'move-item': { |
| operationType: 'write', |
| pathParams: ['-path', '-literalpath', '-pspath', '-lp', '-destination'], |
| knownSwitches: [ |
| '-force', |
| '-passthru', |
| '-whatif', |
| '-confirm', |
| '-usetransaction', |
| ], |
| knownValueParams: ['-filter', '-include', '-exclude', '-credential'], |
| }, |
| |
| |
| |
| |
| |
| 'rename-item': { |
| operationType: 'write', |
| |
| |
| |
| |
| pathParams: ['-path', '-literalpath', '-pspath', '-lp'], |
| knownSwitches: [ |
| '-force', |
| '-passthru', |
| '-whatif', |
| '-confirm', |
| '-usetransaction', |
| ], |
| knownValueParams: [ |
| '-newname', |
| '-credential', |
| '-filter', |
| '-include', |
| '-exclude', |
| ], |
| }, |
| 'set-item': { |
| operationType: 'write', |
| |
| |
| |
| |
| |
| |
| pathParams: ['-path', '-literalpath', '-pspath', '-lp'], |
| knownSwitches: [ |
| '-force', |
| '-passthru', |
| '-whatif', |
| '-confirm', |
| '-usetransaction', |
| ], |
| knownValueParams: [ |
| '-value', |
| '-credential', |
| '-filter', |
| '-include', |
| '-exclude', |
| ], |
| }, |
| |
| 'get-content': { |
| operationType: 'read', |
| pathParams: ['-path', '-literalpath', '-pspath', '-lp'], |
| knownSwitches: [ |
| '-force', |
| '-usetransaction', |
| '-wait', |
| '-raw', |
| '-asbytestream', |
| ], |
| knownValueParams: [ |
| '-readcount', |
| '-totalcount', |
| '-tail', |
| '-first', |
| '-head', |
| '-last', |
| '-filter', |
| '-include', |
| '-exclude', |
| '-credential', |
| '-delimiter', |
| '-encoding', |
| '-stream', |
| ], |
| }, |
| 'get-childitem': { |
| operationType: 'read', |
| pathParams: ['-path', '-literalpath', '-pspath', '-lp'], |
| knownSwitches: [ |
| '-recurse', |
| '-force', |
| '-name', |
| '-usetransaction', |
| '-followsymlink', |
| '-directory', |
| '-file', |
| '-hidden', |
| '-readonly', |
| '-system', |
| ], |
| knownValueParams: [ |
| '-filter', |
| '-include', |
| '-exclude', |
| '-depth', |
| '-attributes', |
| '-credential', |
| ], |
| }, |
| 'get-item': { |
| operationType: 'read', |
| pathParams: ['-path', '-literalpath', '-pspath', '-lp'], |
| knownSwitches: ['-force', '-usetransaction'], |
| knownValueParams: [ |
| '-filter', |
| '-include', |
| '-exclude', |
| '-credential', |
| '-stream', |
| ], |
| }, |
| 'get-itemproperty': { |
| operationType: 'read', |
| pathParams: ['-path', '-literalpath', '-pspath', '-lp'], |
| knownSwitches: ['-usetransaction'], |
| knownValueParams: [ |
| '-name', |
| '-filter', |
| '-include', |
| '-exclude', |
| '-credential', |
| ], |
| }, |
| 'get-itempropertyvalue': { |
| operationType: 'read', |
| pathParams: ['-path', '-literalpath', '-pspath', '-lp'], |
| knownSwitches: ['-usetransaction'], |
| knownValueParams: [ |
| '-name', |
| '-filter', |
| '-include', |
| '-exclude', |
| '-credential', |
| ], |
| }, |
| 'get-filehash': { |
| operationType: 'read', |
| pathParams: ['-path', '-literalpath', '-pspath', '-lp'], |
| knownSwitches: [], |
| knownValueParams: ['-algorithm', '-inputstream'], |
| }, |
| 'get-acl': { |
| operationType: 'read', |
| pathParams: ['-path', '-literalpath', '-pspath', '-lp'], |
| knownSwitches: ['-audit', '-allcentralaccesspolicies', '-usetransaction'], |
| knownValueParams: ['-inputobject', '-filter', '-include', '-exclude'], |
| }, |
| 'format-hex': { |
| operationType: 'read', |
| pathParams: ['-path', '-literalpath', '-pspath', '-lp'], |
| knownSwitches: ['-raw'], |
| knownValueParams: [ |
| '-inputobject', |
| '-encoding', |
| '-count', |
| '-offset', |
| ], |
| }, |
| 'test-path': { |
| operationType: 'read', |
| pathParams: ['-path', '-literalpath', '-pspath', '-lp'], |
| knownSwitches: ['-isvalid', '-usetransaction'], |
| knownValueParams: [ |
| '-filter', |
| '-include', |
| '-exclude', |
| '-pathtype', |
| '-credential', |
| '-olderthan', |
| '-newerthan', |
| ], |
| }, |
| 'resolve-path': { |
| operationType: 'read', |
| pathParams: ['-path', '-literalpath', '-pspath', '-lp'], |
| knownSwitches: ['-relative', '-usetransaction', '-force'], |
| knownValueParams: ['-credential', '-relativebasepath'], |
| }, |
| 'convert-path': { |
| operationType: 'read', |
| pathParams: ['-path', '-literalpath', '-pspath', '-lp'], |
| knownSwitches: ['-usetransaction'], |
| knownValueParams: [], |
| }, |
| 'select-string': { |
| operationType: 'read', |
| pathParams: ['-path', '-literalpath', '-pspath', '-lp'], |
| knownSwitches: [ |
| '-simplematch', |
| '-casesensitive', |
| '-quiet', |
| '-list', |
| '-notmatch', |
| '-allmatches', |
| '-noemphasis', |
| '-raw', |
| ], |
| knownValueParams: [ |
| '-inputobject', |
| '-pattern', |
| '-include', |
| '-exclude', |
| '-encoding', |
| '-context', |
| '-culture', |
| ], |
| }, |
| 'set-location': { |
| operationType: 'read', |
| pathParams: ['-path', '-literalpath', '-pspath', '-lp'], |
| knownSwitches: ['-passthru', '-usetransaction'], |
| knownValueParams: ['-stackname'], |
| }, |
| 'push-location': { |
| operationType: 'read', |
| pathParams: ['-path', '-literalpath', '-pspath', '-lp'], |
| knownSwitches: ['-passthru', '-usetransaction'], |
| knownValueParams: ['-stackname'], |
| }, |
| 'pop-location': { |
| operationType: 'read', |
| |
| |
| pathParams: [], |
| knownSwitches: ['-passthru', '-usetransaction'], |
| knownValueParams: ['-stackname'], |
| }, |
| 'select-xml': { |
| operationType: 'read', |
| pathParams: ['-path', '-literalpath', '-pspath', '-lp'], |
| knownSwitches: [], |
| knownValueParams: ['-xml', '-content', '-xpath', '-namespace'], |
| }, |
| 'get-winevent': { |
| operationType: 'read', |
| |
| pathParams: ['-path'], |
| knownSwitches: ['-force', '-oldest'], |
| knownValueParams: [ |
| '-listlog', |
| '-logname', |
| '-listprovider', |
| '-providername', |
| '-maxevents', |
| '-computername', |
| '-credential', |
| '-filterxpath', |
| '-filterxml', |
| '-filterhashtable', |
| ], |
| }, |
| |
| |
| 'invoke-webrequest': { |
| operationType: 'write', |
| |
| |
| |
| |
| |
| |
| |
| pathParams: ['-outfile', '-infile'], |
| positionalSkip: 1, |
| optionalWrite: true, |
| knownSwitches: [ |
| '-allowinsecureredirect', |
| '-allowunencryptedauthentication', |
| '-disablekeepalive', |
| '-nobodyprogress', |
| '-passthru', |
| '-preservefileauthorizationmetadata', |
| '-resume', |
| '-skipcertificatecheck', |
| '-skipheadervalidation', |
| '-skiphttperrorcheck', |
| '-usebasicparsing', |
| '-usedefaultcredentials', |
| ], |
| knownValueParams: [ |
| '-uri', |
| '-method', |
| '-body', |
| '-contenttype', |
| '-headers', |
| '-maximumredirection', |
| '-maximumretrycount', |
| '-proxy', |
| '-proxycredential', |
| '-retryintervalsec', |
| '-sessionvariable', |
| '-timeoutsec', |
| '-token', |
| '-transferencoding', |
| '-useragent', |
| '-websession', |
| '-credential', |
| '-authentication', |
| '-certificate', |
| '-certificatethumbprint', |
| '-form', |
| '-httpversion', |
| ], |
| }, |
| 'invoke-restmethod': { |
| operationType: 'write', |
| |
| |
| pathParams: ['-outfile', '-infile'], |
| positionalSkip: 1, |
| optionalWrite: true, |
| knownSwitches: [ |
| '-allowinsecureredirect', |
| '-allowunencryptedauthentication', |
| '-disablekeepalive', |
| '-followrellink', |
| '-nobodyprogress', |
| '-passthru', |
| '-preservefileauthorizationmetadata', |
| '-resume', |
| '-skipcertificatecheck', |
| '-skipheadervalidation', |
| '-skiphttperrorcheck', |
| '-usebasicparsing', |
| '-usedefaultcredentials', |
| ], |
| knownValueParams: [ |
| '-uri', |
| '-method', |
| '-body', |
| '-contenttype', |
| '-headers', |
| '-maximumfollowrellink', |
| '-maximumredirection', |
| '-maximumretrycount', |
| '-proxy', |
| '-proxycredential', |
| '-responseheaderstvariable', |
| '-retryintervalsec', |
| '-sessionvariable', |
| '-statuscodevariable', |
| '-timeoutsec', |
| '-token', |
| '-transferencoding', |
| '-useragent', |
| '-websession', |
| '-credential', |
| '-authentication', |
| '-certificate', |
| '-certificatethumbprint', |
| '-form', |
| '-httpversion', |
| ], |
| }, |
| 'expand-archive': { |
| operationType: 'write', |
| pathParams: ['-path', '-literalpath', '-pspath', '-lp', '-destinationpath'], |
| knownSwitches: ['-force', '-passthru', '-whatif', '-confirm'], |
| knownValueParams: [], |
| }, |
| 'compress-archive': { |
| operationType: 'write', |
| pathParams: ['-path', '-literalpath', '-pspath', '-lp', '-destinationpath'], |
| knownSwitches: ['-force', '-update', '-passthru', '-whatif', '-confirm'], |
| knownValueParams: ['-compressionlevel'], |
| }, |
| |
| |
| |
| |
| |
| 'set-itemproperty': { |
| operationType: 'write', |
| pathParams: ['-path', '-literalpath', '-pspath', '-lp'], |
| knownSwitches: [ |
| '-passthru', |
| '-force', |
| '-whatif', |
| '-confirm', |
| '-usetransaction', |
| ], |
| knownValueParams: [ |
| '-name', |
| '-value', |
| '-type', |
| '-filter', |
| '-include', |
| '-exclude', |
| '-credential', |
| '-inputobject', |
| ], |
| }, |
| 'new-itemproperty': { |
| operationType: 'write', |
| pathParams: ['-path', '-literalpath', '-pspath', '-lp'], |
| knownSwitches: ['-force', '-whatif', '-confirm', '-usetransaction'], |
| knownValueParams: [ |
| '-name', |
| '-value', |
| '-propertytype', |
| '-type', |
| '-filter', |
| '-include', |
| '-exclude', |
| '-credential', |
| ], |
| }, |
| 'remove-itemproperty': { |
| operationType: 'write', |
| pathParams: ['-path', '-literalpath', '-pspath', '-lp'], |
| knownSwitches: ['-force', '-whatif', '-confirm', '-usetransaction'], |
| knownValueParams: [ |
| '-name', |
| '-filter', |
| '-include', |
| '-exclude', |
| '-credential', |
| ], |
| }, |
| 'clear-item': { |
| operationType: 'write', |
| pathParams: ['-path', '-literalpath', '-pspath', '-lp'], |
| knownSwitches: ['-force', '-whatif', '-confirm', '-usetransaction'], |
| knownValueParams: ['-filter', '-include', '-exclude', '-credential'], |
| }, |
| 'export-alias': { |
| operationType: 'write', |
| pathParams: ['-path', '-literalpath', '-pspath', '-lp'], |
| knownSwitches: [ |
| '-append', |
| '-force', |
| '-noclobber', |
| '-passthru', |
| '-whatif', |
| '-confirm', |
| ], |
| knownValueParams: ['-name', '-description', '-scope', '-as'], |
| }, |
| } |
|
|
| |
| |
| |
| |
| |
| function matchesParam(paramLower: string, paramList: string[]): boolean { |
| for (const p of paramList) { |
| if ( |
| p === paramLower || |
| (paramLower.length > 1 && p.startsWith(paramLower)) |
| ) { |
| return true |
| } |
| } |
| return false |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| function hasComplexColonValue(rawValue: string): boolean { |
| return ( |
| rawValue.includes(',') || |
| rawValue.startsWith('(') || |
| rawValue.startsWith('[') || |
| rawValue.includes('`') || |
| rawValue.includes('@(') || |
| rawValue.startsWith('@{') || |
| rawValue.includes('$') |
| ) |
| } |
|
|
| function formatDirectoryList(directories: string[]): string { |
| const dirCount = directories.length |
| if (dirCount <= MAX_DIRS_TO_LIST) { |
| return directories.map(dir => `'${dir}'`).join(', ') |
| } |
| const firstDirs = directories |
| .slice(0, MAX_DIRS_TO_LIST) |
| .map(dir => `'${dir}'`) |
| .join(', ') |
| return `${firstDirs}, and ${dirCount - MAX_DIRS_TO_LIST} more` |
| } |
|
|
| |
| |
| |
| function expandTilde(filePath: string): string { |
| if ( |
| filePath === '~' || |
| filePath.startsWith('~/') || |
| filePath.startsWith('~\\') |
| ) { |
| return homedir() + filePath.slice(1) |
| } |
| return filePath |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function isDangerousRemovalRawPath(filePath: string): boolean { |
| const expanded = expandTilde(filePath.replace(/^['"]|['"]$/g, '')).replace( |
| /\\/g, |
| '/', |
| ) |
| return isDangerousRemovalPath(expanded) |
| } |
|
|
| export function dangerousRemovalDeny(path: string): PermissionResult { |
| return { |
| behavior: 'deny', |
| message: `Remove-Item on system path '${path}' is blocked. This path is protected from removal.`, |
| decisionReason: { |
| type: 'other', |
| reason: 'Removal targets a protected system path', |
| }, |
| } |
| } |
|
|
| |
| |
| |
| |
| function isPathAllowed( |
| resolvedPath: string, |
| context: ToolPermissionContext, |
| operationType: FileOperationType, |
| precomputedPathsToCheck?: readonly string[], |
| ): PathCheckResult { |
| const permissionType = operationType === 'read' ? 'read' : 'edit' |
|
|
| |
| const denyRule = matchingRuleForInput( |
| resolvedPath, |
| context, |
| permissionType, |
| 'deny', |
| ) |
| if (denyRule !== null) { |
| return { |
| allowed: false, |
| decisionReason: { type: 'rule', rule: denyRule }, |
| } |
| } |
|
|
| |
| |
| |
| |
| if (operationType !== 'read') { |
| const internalEditResult = checkEditableInternalPath(resolvedPath, {}) |
| if (internalEditResult.behavior === 'allow') { |
| return { |
| allowed: true, |
| decisionReason: internalEditResult.decisionReason, |
| } |
| } |
| } |
|
|
| |
| if (operationType !== 'read') { |
| const safetyCheck = checkPathSafetyForAutoEdit( |
| resolvedPath, |
| precomputedPathsToCheck, |
| ) |
| if (!safetyCheck.safe) { |
| return { |
| allowed: false, |
| decisionReason: { |
| type: 'safetyCheck', |
| reason: safetyCheck.message, |
| classifierApprovable: safetyCheck.classifierApprovable, |
| }, |
| } |
| } |
| } |
|
|
| |
| const isInWorkingDir = pathInAllowedWorkingPath( |
| resolvedPath, |
| context, |
| precomputedPathsToCheck, |
| ) |
| if (isInWorkingDir) { |
| if (operationType === 'read' || context.mode === 'acceptEdits') { |
| return { allowed: true } |
| } |
| } |
|
|
| |
| if (operationType === 'read') { |
| const internalReadResult = checkReadableInternalPath(resolvedPath, {}) |
| if (internalReadResult.behavior === 'allow') { |
| return { |
| allowed: true, |
| decisionReason: internalReadResult.decisionReason, |
| } |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| if ( |
| operationType !== 'read' && |
| !isInWorkingDir && |
| isPathInSandboxWriteAllowlist(resolvedPath) |
| ) { |
| return { |
| allowed: true, |
| decisionReason: { |
| type: 'other', |
| reason: 'Path is in sandbox write allowlist', |
| }, |
| } |
| } |
|
|
| |
| const allowRule = matchingRuleForInput( |
| resolvedPath, |
| context, |
| permissionType, |
| 'allow', |
| ) |
| if (allowRule !== null) { |
| return { |
| allowed: true, |
| decisionReason: { type: 'rule', rule: allowRule }, |
| } |
| } |
|
|
| |
| return { allowed: false } |
| } |
|
|
| |
| |
| |
| |
| |
| function checkDenyRuleForGuessedPath( |
| strippedPath: string, |
| cwd: string, |
| toolPermissionContext: ToolPermissionContext, |
| operationType: FileOperationType, |
| ): { resolvedPath: string; rule: PermissionRule } | null { |
| |
| |
| if (!strippedPath || strippedPath.includes('\0')) return null |
| |
| |
| const tildeExpanded = expandTilde(strippedPath) |
| const abs = isAbsolute(tildeExpanded) |
| ? tildeExpanded |
| : resolve(cwd, tildeExpanded) |
| const { resolvedPath } = safeResolvePath(getFsImplementation(), abs) |
| const permissionType = operationType === 'read' ? 'read' : 'edit' |
| const denyRule = matchingRuleForInput( |
| resolvedPath, |
| toolPermissionContext, |
| permissionType, |
| 'deny', |
| ) |
| return denyRule ? { resolvedPath, rule: denyRule } : null |
| } |
|
|
| |
| |
| |
| function validatePath( |
| filePath: string, |
| cwd: string, |
| toolPermissionContext: ToolPermissionContext, |
| operationType: FileOperationType, |
| ): ResolvedPathCheckResult { |
| |
| const cleanPath = expandTilde(filePath.replace(/^['"]|['"]$/g, '')) |
|
|
| |
| |
| |
| |
| const normalizedPath = cleanPath.replace(/\\/g, '/') |
|
|
| |
| |
| |
| |
| if (normalizedPath.includes('`')) { |
| |
| |
| |
| |
| |
| const backtickStripped = normalizedPath.replace(/`/g, '') |
| const denyHit = checkDenyRuleForGuessedPath( |
| backtickStripped, |
| cwd, |
| toolPermissionContext, |
| operationType, |
| ) |
| if (denyHit) { |
| return { |
| allowed: false, |
| resolvedPath: denyHit.resolvedPath, |
| decisionReason: { type: 'rule', rule: denyHit.rule }, |
| } |
| } |
| return { |
| allowed: false, |
| resolvedPath: normalizedPath, |
| decisionReason: { |
| type: 'other', |
| reason: |
| 'Backtick escape characters in paths cannot be statically validated and require manual approval', |
| }, |
| } |
| } |
|
|
| |
| |
| |
| |
| if (normalizedPath.includes('::')) { |
| |
| |
| |
| |
| |
| const afterProvider = normalizedPath.slice(normalizedPath.indexOf('::') + 2) |
| const denyHit = checkDenyRuleForGuessedPath( |
| afterProvider, |
| cwd, |
| toolPermissionContext, |
| operationType, |
| ) |
| if (denyHit) { |
| return { |
| allowed: false, |
| resolvedPath: denyHit.resolvedPath, |
| decisionReason: { type: 'rule', rule: denyHit.rule }, |
| } |
| } |
| return { |
| allowed: false, |
| resolvedPath: normalizedPath, |
| decisionReason: { |
| type: 'other', |
| reason: |
| 'Module-qualified provider paths (::) cannot be statically validated and require manual approval', |
| }, |
| } |
| } |
|
|
| |
| |
| if ( |
| normalizedPath.startsWith('//') || |
| /DavWWWRoot/i.test(normalizedPath) || |
| /@SSL@/i.test(normalizedPath) |
| ) { |
| return { |
| allowed: false, |
| resolvedPath: normalizedPath, |
| decisionReason: { |
| type: 'other', |
| reason: |
| 'UNC paths are blocked because they can trigger network requests and credential leakage', |
| }, |
| } |
| } |
|
|
| |
| if (normalizedPath.includes('$') || normalizedPath.includes('%')) { |
| return { |
| allowed: false, |
| resolvedPath: normalizedPath, |
| decisionReason: { |
| type: 'other', |
| reason: 'Variable expansion syntax in paths requires manual approval', |
| }, |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const providerPathRegex = |
| getPlatform() === 'windows' ? /^[a-z0-9]{2,}:/i : /^[a-z0-9]+:/i |
| if (providerPathRegex.test(normalizedPath)) { |
| return { |
| allowed: false, |
| resolvedPath: normalizedPath, |
| decisionReason: { |
| type: 'other', |
| reason: `Path '${normalizedPath}' uses a non-filesystem provider and requires manual approval`, |
| }, |
| } |
| } |
|
|
| |
| if (GLOB_PATTERN_REGEX.test(normalizedPath)) { |
| if (operationType === 'write' || operationType === 'create') { |
| return { |
| allowed: false, |
| resolvedPath: normalizedPath, |
| decisionReason: { |
| type: 'other', |
| reason: |
| 'Glob patterns are not allowed in write operations. Please specify an exact file path.', |
| }, |
| } |
| } |
|
|
| |
| |
| |
| if (containsPathTraversal(normalizedPath)) { |
| const absolutePath = isAbsolute(normalizedPath) |
| ? normalizedPath |
| : resolve(cwd, normalizedPath) |
| const { resolvedPath, isCanonical } = safeResolvePath( |
| getFsImplementation(), |
| absolutePath, |
| ) |
| const result = isPathAllowed( |
| resolvedPath, |
| toolPermissionContext, |
| operationType, |
| isCanonical ? [resolvedPath] : undefined, |
| ) |
| return { |
| allowed: result.allowed, |
| resolvedPath, |
| decisionReason: result.decisionReason, |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const basePath = getGlobBaseDirectory(normalizedPath) |
| const absoluteBasePath = isAbsolute(basePath) |
| ? basePath |
| : resolve(cwd, basePath) |
| const { resolvedPath } = safeResolvePath( |
| getFsImplementation(), |
| absoluteBasePath, |
| ) |
| const permissionType = operationType === 'read' ? 'read' : 'edit' |
| const denyRule = matchingRuleForInput( |
| resolvedPath, |
| toolPermissionContext, |
| permissionType, |
| 'deny', |
| ) |
| if (denyRule !== null) { |
| return { |
| allowed: false, |
| resolvedPath, |
| decisionReason: { type: 'rule', rule: denyRule }, |
| } |
| } |
| return { |
| allowed: false, |
| resolvedPath, |
| decisionReason: { |
| type: 'other', |
| reason: |
| 'Glob patterns in paths cannot be statically validated — symlinks inside the glob expansion are not examined. Requires manual approval.', |
| }, |
| } |
| } |
|
|
| |
| const absolutePath = isAbsolute(normalizedPath) |
| ? normalizedPath |
| : resolve(cwd, normalizedPath) |
| const { resolvedPath, isCanonical } = safeResolvePath( |
| getFsImplementation(), |
| absolutePath, |
| ) |
|
|
| const result = isPathAllowed( |
| resolvedPath, |
| toolPermissionContext, |
| operationType, |
| isCanonical ? [resolvedPath] : undefined, |
| ) |
| return { |
| allowed: result.allowed, |
| resolvedPath, |
| decisionReason: result.decisionReason, |
| } |
| } |
|
|
| function getGlobBaseDirectory(filePath: string): string { |
| const globMatch = filePath.match(GLOB_PATTERN_REGEX) |
| if (!globMatch || globMatch.index === undefined) { |
| return filePath |
| } |
| const beforeGlob = filePath.substring(0, globMatch.index) |
| const lastSepIndex = Math.max( |
| beforeGlob.lastIndexOf('/'), |
| beforeGlob.lastIndexOf('\\'), |
| ) |
| if (lastSepIndex === -1) return '.' |
| return beforeGlob.substring(0, lastSepIndex + 1) || '/' |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const SAFE_PATH_ELEMENT_TYPES = new Set<string>(['StringConstant', 'Parameter']) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| function extractPathsFromCommand(cmd: ParsedCommandElement): { |
| paths: string[] |
| operationType: FileOperationType |
| hasUnvalidatablePathArg: boolean |
| optionalWrite: boolean |
| } { |
| const canonical = resolveToCanonical(cmd.name) |
| const config = CMDLET_PATH_CONFIG[canonical] |
|
|
| if (!config) { |
| return { |
| paths: [], |
| operationType: 'read', |
| hasUnvalidatablePathArg: false, |
| optionalWrite: false, |
| } |
| } |
|
|
| |
| const switchParams = [...config.knownSwitches, ...COMMON_SWITCHES] |
| const valueParams = [...config.knownValueParams, ...COMMON_VALUE_PARAMS] |
|
|
| const paths: string[] = [] |
| const args = cmd.args |
| |
| const elementTypes = cmd.elementTypes |
| let hasUnvalidatablePathArg = false |
| let positionalsSeen = 0 |
| const positionalSkip = config.positionalSkip ?? 0 |
|
|
| function checkArgElementType(argIdx: number): void { |
| if (!elementTypes) return |
| const et = elementTypes[argIdx + 1] |
| if (et && !SAFE_PATH_ELEMENT_TYPES.has(et)) { |
| hasUnvalidatablePathArg = true |
| } |
| } |
|
|
| |
| for (let i = 0; i < args.length; i++) { |
| const arg = args[i] |
| if (!arg) continue |
|
|
| |
| |
| |
| |
| |
| |
| |
| const argElementType = elementTypes ? elementTypes[i + 1] : undefined |
| if (isPowerShellParameter(arg, argElementType)) { |
| |
| |
| const normalized = '-' + arg.slice(1) |
| const colonIdx = normalized.indexOf(':', 1) |
| const paramName = |
| colonIdx > 0 ? normalized.substring(0, colonIdx) : normalized |
| const paramLower = paramName.toLowerCase() |
|
|
| if (matchesParam(paramLower, config.pathParams)) { |
| |
| let value: string | undefined |
| if (colonIdx > 0) { |
| |
| |
| |
| |
| const rawValue = arg.substring(colonIdx + 1) |
| if (hasComplexColonValue(rawValue)) { |
| hasUnvalidatablePathArg = true |
| } else { |
| value = rawValue |
| } |
| } else { |
| |
| const nextVal = args[i + 1] |
| const nextType = elementTypes ? elementTypes[i + 2] : undefined |
| if (nextVal && !isPowerShellParameter(nextVal, nextType)) { |
| value = nextVal |
| checkArgElementType(i + 1) |
| i++ |
| } |
| } |
| if (value) { |
| paths.push(value) |
| } |
| } else if ( |
| config.leafOnlyPathParams && |
| matchesParam(paramLower, config.leafOnlyPathParams) |
| ) { |
| |
| |
| |
| |
| |
| |
| let value: string | undefined |
| if (colonIdx > 0) { |
| const rawValue = arg.substring(colonIdx + 1) |
| if (hasComplexColonValue(rawValue)) { |
| hasUnvalidatablePathArg = true |
| } else { |
| value = rawValue |
| } |
| } else { |
| const nextVal = args[i + 1] |
| const nextType = elementTypes ? elementTypes[i + 2] : undefined |
| if (nextVal && !isPowerShellParameter(nextVal, nextType)) { |
| value = nextVal |
| checkArgElementType(i + 1) |
| i++ |
| } |
| } |
| if (value !== undefined) { |
| if ( |
| value.includes('/') || |
| value.includes('\\') || |
| value === '.' || |
| value === '..' |
| ) { |
| |
| |
| hasUnvalidatablePathArg = true |
| } else { |
| |
| |
| |
| paths.push(value) |
| } |
| } |
| } else if (matchesParam(paramLower, switchParams)) { |
| |
| |
| |
| } else if (matchesParam(paramLower, valueParams)) { |
| |
| |
| |
| |
| |
| |
| if (colonIdx > 0) { |
| |
| |
| |
| |
| const rawValue = arg.substring(colonIdx + 1) |
| if (hasComplexColonValue(rawValue)) { |
| hasUnvalidatablePathArg = true |
| } |
| } else { |
| const nextArg = args[i + 1] |
| const nextArgType = elementTypes ? elementTypes[i + 2] : undefined |
| if (nextArg && !isPowerShellParameter(nextArg, nextArgType)) { |
| checkArgElementType(i + 1) |
| i++ |
| } |
| } |
| } else { |
| |
| |
| |
| |
| |
| |
| hasUnvalidatablePathArg = true |
| |
| |
| |
| |
| |
| |
| |
| if (colonIdx > 0) { |
| const rawValue = arg.substring(colonIdx + 1) |
| if (!hasComplexColonValue(rawValue)) { |
| paths.push(rawValue) |
| } |
| } |
| |
| |
| } |
| continue |
| } |
|
|
| |
| |
| |
| if (positionalsSeen < positionalSkip) { |
| positionalsSeen++ |
| continue |
| } |
| positionalsSeen++ |
| checkArgElementType(i) |
| paths.push(arg) |
| } |
|
|
| return { |
| paths, |
| operationType: config.operationType, |
| hasUnvalidatablePathArg, |
| optionalWrite: config.optionalWrite ?? false, |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function checkPathConstraints( |
| input: { command: string }, |
| parsed: ParsedPowerShellCommand, |
| toolPermissionContext: ToolPermissionContext, |
| compoundCommandHasCd = false, |
| ): PermissionResult { |
| if (!parsed.valid) { |
| return { |
| behavior: 'passthrough', |
| message: 'Cannot validate paths for unparsed command', |
| } |
| } |
|
|
| |
| |
| |
| |
| let firstAsk: PermissionResult | undefined |
|
|
| for (const statement of parsed.statements) { |
| const result = checkPathConstraintsForStatement( |
| statement, |
| toolPermissionContext, |
| compoundCommandHasCd, |
| ) |
| if (result.behavior === 'deny') { |
| return result |
| } |
| if (result.behavior === 'ask' && !firstAsk) { |
| firstAsk = result |
| } |
| } |
|
|
| return ( |
| firstAsk ?? { |
| behavior: 'passthrough', |
| message: 'All path constraints validated successfully', |
| } |
| ) |
| } |
|
|
| function checkPathConstraintsForStatement( |
| statement: ParsedPowerShellCommand['statements'][number], |
| toolPermissionContext: ToolPermissionContext, |
| compoundCommandHasCd = false, |
| ): PermissionResult { |
| const cwd = getCwd() |
| let firstAsk: PermissionResult | undefined |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| if (compoundCommandHasCd) { |
| firstAsk = { |
| behavior: 'ask', |
| message: |
| 'Compound command changes working directory (Set-Location/Push-Location/Pop-Location/New-PSDrive) — relative paths cannot be validated against the original cwd and require manual approval', |
| decisionReason: { |
| type: 'other', |
| reason: |
| 'Compound command contains cd with path operation — manual approval required to prevent path resolution bypass', |
| }, |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| let hasExpressionPipelineSource = false |
| |
| |
| |
| |
| |
| let pipelineSourceText: string | undefined |
|
|
| for (const cmd of statement.commands) { |
| if (cmd.elementType !== 'CommandAst') { |
| hasExpressionPipelineSource = true |
| pipelineSourceText = cmd.text |
| continue |
| } |
|
|
| const { paths, operationType, hasUnvalidatablePathArg, optionalWrite } = |
| extractPathsFromCommand(cmd) |
|
|
| |
| |
| |
| |
| |
| |
| if (hasExpressionPipelineSource) { |
| const canonical = resolveToCanonical(cmd.name) |
| |
| |
| |
| |
| |
| if (pipelineSourceText !== undefined) { |
| const stripped = pipelineSourceText.replace(/^['"]|['"]$/g, '') |
| const denyHit = checkDenyRuleForGuessedPath( |
| stripped, |
| cwd, |
| toolPermissionContext, |
| operationType, |
| ) |
| if (denyHit) { |
| return { |
| behavior: 'deny', |
| message: `${canonical} targeting '${denyHit.resolvedPath}' was blocked by a deny rule`, |
| decisionReason: { type: 'rule', rule: denyHit.rule }, |
| } |
| } |
| } |
| firstAsk ??= { |
| behavior: 'ask', |
| message: `${canonical} receives its path from a pipeline expression source that cannot be statically validated and requires manual approval`, |
| } |
| |
| |
| } |
|
|
| |
| |
| |
| |
| |
| if (hasUnvalidatablePathArg) { |
| const canonical = resolveToCanonical(cmd.name) |
| firstAsk ??= { |
| behavior: 'ask', |
| message: `${canonical} uses a parameter or complex path expression (array literal, subexpression, unknown parameter, etc.) that cannot be statically validated and requires manual approval`, |
| } |
| |
| |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| if ( |
| operationType !== 'read' && |
| !optionalWrite && |
| paths.length === 0 && |
| CMDLET_PATH_CONFIG[resolveToCanonical(cmd.name)] |
| ) { |
| const canonical = resolveToCanonical(cmd.name) |
| firstAsk ??= { |
| behavior: 'ask', |
| message: `${canonical} is a write operation but no target path could be determined; requires manual approval`, |
| } |
| continue |
| } |
|
|
| |
| |
| |
| |
| |
| const isRemoval = resolveToCanonical(cmd.name) === 'remove-item' |
|
|
| for (const filePath of paths) { |
| |
| |
| |
| |
| if (isRemoval && isDangerousRemovalRawPath(filePath)) { |
| return dangerousRemovalDeny(filePath) |
| } |
|
|
| const { allowed, resolvedPath, decisionReason } = validatePath( |
| filePath, |
| cwd, |
| toolPermissionContext, |
| operationType, |
| ) |
|
|
| |
| |
| if (isRemoval && isDangerousRemovalPath(resolvedPath)) { |
| return dangerousRemovalDeny(resolvedPath) |
| } |
|
|
| if (!allowed) { |
| const canonical = resolveToCanonical(cmd.name) |
| const workingDirs = Array.from( |
| allWorkingDirectories(toolPermissionContext), |
| ) |
| const dirListStr = formatDirectoryList(workingDirs) |
|
|
| const message = |
| decisionReason?.type === 'other' || |
| decisionReason?.type === 'safetyCheck' |
| ? decisionReason.reason |
| : `${canonical} targeting '${resolvedPath}' was blocked. For security, Claude Code may only access files in the allowed working directories for this session: ${dirListStr}.` |
|
|
| if (decisionReason?.type === 'rule') { |
| return { |
| behavior: 'deny', |
| message, |
| decisionReason, |
| } |
| } |
|
|
| const suggestions: PermissionUpdate[] = [] |
| if (resolvedPath) { |
| if (operationType === 'read') { |
| const suggestion = createReadRuleSuggestion( |
| getDirectoryForPath(resolvedPath), |
| 'session', |
| ) |
| if (suggestion) { |
| suggestions.push(suggestion) |
| } |
| } else { |
| suggestions.push({ |
| type: 'addDirectories', |
| directories: [getDirectoryForPath(resolvedPath)], |
| destination: 'session', |
| }) |
| } |
| } |
|
|
| if (operationType === 'write' || operationType === 'create') { |
| suggestions.push({ |
| type: 'setMode', |
| mode: 'acceptEdits', |
| destination: 'session', |
| }) |
| } |
|
|
| firstAsk ??= { |
| behavior: 'ask', |
| message, |
| blockedPath: resolvedPath, |
| decisionReason, |
| suggestions, |
| } |
| } |
| } |
| } |
|
|
| |
| if (statement.nestedCommands) { |
| for (const cmd of statement.nestedCommands) { |
| const { paths, operationType, hasUnvalidatablePathArg, optionalWrite } = |
| extractPathsFromCommand(cmd) |
|
|
| if (hasUnvalidatablePathArg) { |
| const canonical = resolveToCanonical(cmd.name) |
| firstAsk ??= { |
| behavior: 'ask', |
| message: `${canonical} uses a parameter or complex path expression (array literal, subexpression, unknown parameter, etc.) that cannot be statically validated and requires manual approval`, |
| } |
| |
| } |
|
|
| |
| |
| if ( |
| operationType !== 'read' && |
| !optionalWrite && |
| paths.length === 0 && |
| CMDLET_PATH_CONFIG[resolveToCanonical(cmd.name)] |
| ) { |
| const canonical = resolveToCanonical(cmd.name) |
| firstAsk ??= { |
| behavior: 'ask', |
| message: `${canonical} is a write operation but no target path could be determined; requires manual approval`, |
| } |
| continue |
| } |
|
|
| |
| |
| |
| |
| const isRemoval = resolveToCanonical(cmd.name) === 'remove-item' |
|
|
| for (const filePath of paths) { |
| |
| if (isRemoval && isDangerousRemovalRawPath(filePath)) { |
| return dangerousRemovalDeny(filePath) |
| } |
|
|
| const { allowed, resolvedPath, decisionReason } = validatePath( |
| filePath, |
| cwd, |
| toolPermissionContext, |
| operationType, |
| ) |
|
|
| if (isRemoval && isDangerousRemovalPath(resolvedPath)) { |
| return dangerousRemovalDeny(resolvedPath) |
| } |
|
|
| if (!allowed) { |
| const canonical = resolveToCanonical(cmd.name) |
| const workingDirs = Array.from( |
| allWorkingDirectories(toolPermissionContext), |
| ) |
| const dirListStr = formatDirectoryList(workingDirs) |
|
|
| const message = |
| decisionReason?.type === 'other' || |
| decisionReason?.type === 'safetyCheck' |
| ? decisionReason.reason |
| : `${canonical} targeting '${resolvedPath}' was blocked. For security, Claude Code may only access files in the allowed working directories for this session: ${dirListStr}.` |
|
|
| if (decisionReason?.type === 'rule') { |
| return { |
| behavior: 'deny', |
| message, |
| decisionReason, |
| } |
| } |
|
|
| const suggestions: PermissionUpdate[] = [] |
| if (resolvedPath) { |
| if (operationType === 'read') { |
| const suggestion = createReadRuleSuggestion( |
| getDirectoryForPath(resolvedPath), |
| 'session', |
| ) |
| if (suggestion) { |
| suggestions.push(suggestion) |
| } |
| } else { |
| suggestions.push({ |
| type: 'addDirectories', |
| directories: [getDirectoryForPath(resolvedPath)], |
| destination: 'session', |
| }) |
| } |
| } |
|
|
| if (operationType === 'write' || operationType === 'create') { |
| suggestions.push({ |
| type: 'setMode', |
| mode: 'acceptEdits', |
| destination: 'session', |
| }) |
| } |
|
|
| firstAsk ??= { |
| behavior: 'ask', |
| message, |
| blockedPath: resolvedPath, |
| decisionReason, |
| suggestions, |
| } |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| if (hasExpressionPipelineSource) { |
| firstAsk ??= { |
| behavior: 'ask', |
| message: `${resolveToCanonical(cmd.name)} appears inside a control-flow or chain statement where piped expression sources cannot be statically validated and requires manual approval`, |
| } |
| } |
| } |
| } |
|
|
| |
| if (statement.nestedCommands) { |
| for (const cmd of statement.nestedCommands) { |
| if (cmd.redirections) { |
| for (const redir of cmd.redirections) { |
| if (redir.isMerging) continue |
| if (!redir.target) continue |
| if (isNullRedirectionTarget(redir.target)) continue |
|
|
| const { allowed, resolvedPath, decisionReason } = validatePath( |
| redir.target, |
| cwd, |
| toolPermissionContext, |
| 'create', |
| ) |
|
|
| if (!allowed) { |
| const workingDirs = Array.from( |
| allWorkingDirectories(toolPermissionContext), |
| ) |
| const dirListStr = formatDirectoryList(workingDirs) |
|
|
| const message = |
| decisionReason?.type === 'other' || |
| decisionReason?.type === 'safetyCheck' |
| ? decisionReason.reason |
| : `Output redirection to '${resolvedPath}' was blocked. For security, Claude Code may only write to files in the allowed working directories for this session: ${dirListStr}.` |
|
|
| if (decisionReason?.type === 'rule') { |
| return { |
| behavior: 'deny', |
| message, |
| decisionReason, |
| } |
| } |
|
|
| firstAsk ??= { |
| behavior: 'ask', |
| message, |
| blockedPath: resolvedPath, |
| decisionReason, |
| suggestions: [ |
| { |
| type: 'addDirectories', |
| directories: [getDirectoryForPath(resolvedPath)], |
| destination: 'session', |
| }, |
| ], |
| } |
| } |
| } |
| } |
| } |
| } |
|
|
| |
| if (statement.redirections) { |
| for (const redir of statement.redirections) { |
| if (redir.isMerging) continue |
| if (!redir.target) continue |
| if (isNullRedirectionTarget(redir.target)) continue |
|
|
| const { allowed, resolvedPath, decisionReason } = validatePath( |
| redir.target, |
| cwd, |
| toolPermissionContext, |
| 'create', |
| ) |
|
|
| if (!allowed) { |
| const workingDirs = Array.from( |
| allWorkingDirectories(toolPermissionContext), |
| ) |
| const dirListStr = formatDirectoryList(workingDirs) |
|
|
| const message = |
| decisionReason?.type === 'other' || |
| decisionReason?.type === 'safetyCheck' |
| ? decisionReason.reason |
| : `Output redirection to '${resolvedPath}' was blocked. For security, Claude Code may only write to files in the allowed working directories for this session: ${dirListStr}.` |
|
|
| if (decisionReason?.type === 'rule') { |
| return { |
| behavior: 'deny', |
| message, |
| decisionReason, |
| } |
| } |
|
|
| firstAsk ??= { |
| behavior: 'ask', |
| message, |
| blockedPath: resolvedPath, |
| decisionReason, |
| suggestions: [ |
| { |
| type: 'addDirectories', |
| directories: [getDirectoryForPath(resolvedPath)], |
| destination: 'session', |
| }, |
| ], |
| } |
| } |
| } |
| } |
|
|
| return ( |
| firstAsk ?? { |
| behavior: 'passthrough', |
| message: 'All path constraints validated successfully', |
| } |
| ) |
| } |
|
|