| import type { z } from 'zod/v4' |
| import { |
| isUnsafeCompoundCommand_DEPRECATED, |
| splitCommand_DEPRECATED, |
| } from '../../utils/bash/commands.js' |
| import { |
| buildParsedCommandFromRoot, |
| type IParsedCommand, |
| ParsedCommand, |
| } from '../../utils/bash/ParsedCommand.js' |
| import { type Node, PARSE_ABORTED } from '../../utils/bash/parser.js' |
| import type { PermissionResult } from '../../utils/permissions/PermissionResult.js' |
| import type { PermissionUpdate } from '../../utils/permissions/PermissionUpdateSchema.js' |
| import { createPermissionRequestMessage } from '../../utils/permissions/permissions.js' |
| import { BashTool } from './BashTool.js' |
| import { bashCommandIsSafeAsync_DEPRECATED } from './bashSecurity.js' |
|
|
| export type CommandIdentityCheckers = { |
| isNormalizedCdCommand: (command: string) => boolean |
| isNormalizedGitCommand: (command: string) => boolean |
| } |
|
|
| async function segmentedCommandPermissionResult( |
| input: z.infer<typeof BashTool.inputSchema>, |
| segments: string[], |
| bashToolHasPermissionFn: ( |
| input: z.infer<typeof BashTool.inputSchema>, |
| ) => Promise<PermissionResult>, |
| checkers: CommandIdentityCheckers, |
| ): Promise<PermissionResult> { |
| |
| const cdCommands = segments.filter(segment => { |
| const trimmed = segment.trim() |
| return checkers.isNormalizedCdCommand(trimmed) |
| }) |
| if (cdCommands.length > 1) { |
| const decisionReason = { |
| type: 'other' as const, |
| reason: |
| 'Multiple directory changes in one command require approval for clarity', |
| } |
| return { |
| behavior: 'ask', |
| decisionReason, |
| message: createPermissionRequestMessage(BashTool.name, decisionReason), |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| { |
| let hasCd = false |
| let hasGit = false |
| for (const segment of segments) { |
| const subcommands = splitCommand_DEPRECATED(segment) |
| for (const sub of subcommands) { |
| const trimmed = sub.trim() |
| if (checkers.isNormalizedCdCommand(trimmed)) { |
| hasCd = true |
| } |
| if (checkers.isNormalizedGitCommand(trimmed)) { |
| hasGit = true |
| } |
| } |
| } |
| if (hasCd && hasGit) { |
| const decisionReason = { |
| type: 'other' as const, |
| reason: |
| 'Compound commands with cd and git require approval to prevent bare repository attacks', |
| } |
| return { |
| behavior: 'ask', |
| decisionReason, |
| message: createPermissionRequestMessage(BashTool.name, decisionReason), |
| } |
| } |
| } |
|
|
| const segmentResults = new Map<string, PermissionResult>() |
|
|
| |
| for (const segment of segments) { |
| const trimmedSegment = segment.trim() |
| if (!trimmedSegment) continue |
|
|
| const segmentResult = await bashToolHasPermissionFn({ |
| ...input, |
| command: trimmedSegment, |
| }) |
| segmentResults.set(trimmedSegment, segmentResult) |
| } |
|
|
| |
| const deniedSegment = Array.from(segmentResults.entries()).find( |
| ([, result]) => result.behavior === 'deny', |
| ) |
|
|
| if (deniedSegment) { |
| const [segmentCommand, segmentResult] = deniedSegment |
| return { |
| behavior: 'deny', |
| message: |
| segmentResult.behavior === 'deny' |
| ? segmentResult.message |
| : `Permission denied for: ${segmentCommand}`, |
| decisionReason: { |
| type: 'subcommandResults', |
| reasons: segmentResults, |
| }, |
| } |
| } |
|
|
| const allAllowed = Array.from(segmentResults.values()).every( |
| result => result.behavior === 'allow', |
| ) |
|
|
| if (allAllowed) { |
| return { |
| behavior: 'allow', |
| updatedInput: input, |
| decisionReason: { |
| type: 'subcommandResults', |
| reasons: segmentResults, |
| }, |
| } |
| } |
|
|
| |
| const suggestions: PermissionUpdate[] = [] |
| for (const [, result] of segmentResults) { |
| if ( |
| result.behavior !== 'allow' && |
| 'suggestions' in result && |
| result.suggestions |
| ) { |
| suggestions.push(...result.suggestions) |
| } |
| } |
|
|
| const decisionReason = { |
| type: 'subcommandResults' as const, |
| reasons: segmentResults, |
| } |
|
|
| return { |
| behavior: 'ask', |
| message: createPermissionRequestMessage(BashTool.name, decisionReason), |
| decisionReason, |
| suggestions: suggestions.length > 0 ? suggestions : undefined, |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| async function buildSegmentWithoutRedirections( |
| segmentCommand: string, |
| ): Promise<string> { |
| |
| if (!segmentCommand.includes('>')) { |
| return segmentCommand |
| } |
|
|
| |
| const parsed = await ParsedCommand.parse(segmentCommand) |
| return parsed?.withoutOutputRedirections() ?? segmentCommand |
| } |
|
|
| |
| |
| |
| |
| |
| export async function checkCommandOperatorPermissions( |
| input: z.infer<typeof BashTool.inputSchema>, |
| bashToolHasPermissionFn: ( |
| input: z.infer<typeof BashTool.inputSchema>, |
| ) => Promise<PermissionResult>, |
| checkers: CommandIdentityCheckers, |
| astRoot: Node | null | typeof PARSE_ABORTED, |
| ): Promise<PermissionResult> { |
| const parsed = |
| astRoot && astRoot !== PARSE_ABORTED |
| ? buildParsedCommandFromRoot(input.command, astRoot) |
| : await ParsedCommand.parse(input.command) |
| if (!parsed) { |
| return { behavior: 'passthrough', message: 'Failed to parse command' } |
| } |
| return bashToolCheckCommandOperatorPermissions( |
| input, |
| bashToolHasPermissionFn, |
| checkers, |
| parsed, |
| ) |
| } |
|
|
| |
| |
| |
| |
| async function bashToolCheckCommandOperatorPermissions( |
| input: z.infer<typeof BashTool.inputSchema>, |
| bashToolHasPermissionFn: ( |
| input: z.infer<typeof BashTool.inputSchema>, |
| ) => Promise<PermissionResult>, |
| checkers: CommandIdentityCheckers, |
| parsed: IParsedCommand, |
| ): Promise<PermissionResult> { |
| |
| const tsAnalysis = parsed.getTreeSitterAnalysis() |
| const isUnsafeCompound = tsAnalysis |
| ? tsAnalysis.compoundStructure.hasSubshell || |
| tsAnalysis.compoundStructure.hasCommandGroup |
| : isUnsafeCompoundCommand_DEPRECATED(input.command) |
| if (isUnsafeCompound) { |
| |
| |
| const safetyResult = await bashCommandIsSafeAsync_DEPRECATED(input.command) |
|
|
| const decisionReason = { |
| type: 'other' as const, |
| reason: |
| safetyResult.behavior === 'ask' && safetyResult.message |
| ? safetyResult.message |
| : 'This command uses shell operators that require approval for safety', |
| } |
| return { |
| behavior: 'ask', |
| message: createPermissionRequestMessage(BashTool.name, decisionReason), |
| decisionReason, |
| |
| } |
| } |
|
|
| |
| const pipeSegments = parsed.getPipeSegments() |
|
|
| |
| if (pipeSegments.length <= 1) { |
| return { |
| behavior: 'passthrough', |
| message: 'No pipes found in command', |
| } |
| } |
|
|
| |
| const segments = await Promise.all( |
| pipeSegments.map(segment => buildSegmentWithoutRedirections(segment)), |
| ) |
|
|
| |
| return segmentedCommandPermissionResult( |
| input, |
| segments, |
| bashToolHasPermissionFn, |
| checkers, |
| ) |
| } |
|
|