chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1 @@
|
||||
export { default } from './shell-tool'
|
||||
@@ -0,0 +1,802 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { Tool } from '@sdk/base-tool'
|
||||
import { ToolkitConfig } from '@sdk/toolkit-config'
|
||||
import { isWindows } from '@sdk/utils'
|
||||
|
||||
const DEFAULT_SETTINGS: Record<string, unknown> = {}
|
||||
const REQUIRED_SETTINGS: string[] = []
|
||||
|
||||
interface ShellResult {
|
||||
success: boolean
|
||||
commandSucceeded?: boolean
|
||||
error?: string
|
||||
stdout: string
|
||||
stderr: string
|
||||
returncode: number
|
||||
command: string
|
||||
attempts: ShellAttempt[]
|
||||
}
|
||||
|
||||
interface ShellAttempt {
|
||||
attempt: number
|
||||
timeoutMs: number
|
||||
durationMs: number
|
||||
status: 'success' | 'timeout' | 'error'
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface ExecuteOptions {
|
||||
cwd?: string
|
||||
longRunning?: boolean
|
||||
captureOutput?: boolean
|
||||
}
|
||||
|
||||
interface ProcessExecutionError extends Error {
|
||||
stdout?: Buffer | string
|
||||
stderr?: Buffer | string
|
||||
status?: number
|
||||
code?: number | string | null
|
||||
}
|
||||
|
||||
interface ShellInvocation {
|
||||
binaryName: string
|
||||
args: string[]
|
||||
}
|
||||
|
||||
const DEFAULT_TIMEOUT_SECONDS = 30
|
||||
const LONG_RUNNING_TIMEOUT_SECONDS = 86_400
|
||||
const MILLISECONDS_PER_SECOND = 1_000
|
||||
|
||||
const CRITICAL_COMMAND_SEQUENCES = [
|
||||
['rm', '-rf', '/'],
|
||||
['rm', '-rf', '/*'],
|
||||
['kill', '-9', '-1']
|
||||
] as const
|
||||
|
||||
const CRITICAL_COMMAND_TOKENS = ['mkfs', 'format', 'fdisk'] as const
|
||||
const HIGH_RISK_DD_TOKENS = ['dd'] as const
|
||||
const HIGH_RISK_EVAL_DOWNLOAD_TOKENS = ['curl', 'wget'] as const
|
||||
const ELEVATED_COMMAND_TOKENS = ['sudo', 'doas', 'pkexec', 'su'] as const
|
||||
const POWERSHELL_ELEVATED_COMMAND_TOKENS = [
|
||||
'start-process',
|
||||
'runas'
|
||||
] as const
|
||||
const PERMISSION_COMMAND_TOKENS = ['chmod', 'chown'] as const
|
||||
const PACKAGE_MANAGER_COMMAND_TOKENS = [
|
||||
'apt',
|
||||
'apt-get',
|
||||
'yum',
|
||||
'brew',
|
||||
'pip',
|
||||
'pip3'
|
||||
] as const
|
||||
|
||||
const MEDIUM_RISK_COMMAND_PATTERNS: string[] = []
|
||||
|
||||
const UNSAFE_COMMAND_PATTERNS = [
|
||||
'fork()',
|
||||
'while true; do',
|
||||
'while ($true)'
|
||||
]
|
||||
|
||||
const POWERSHELL_HIGH_RISK_COMMAND_TOKENS = [
|
||||
'invoke-expression',
|
||||
'iex',
|
||||
'set-executionpolicy',
|
||||
'stop-computer',
|
||||
'restart-computer'
|
||||
] as const
|
||||
const POWERSHELL_DESTRUCTIVE_COMMAND_TOKENS = [
|
||||
'remove-item',
|
||||
'del',
|
||||
'erase',
|
||||
'rmdir',
|
||||
'rd'
|
||||
] as const
|
||||
|
||||
const TERMINAL_AUTH_COMMANDS = new Set<string>([
|
||||
...ELEVATED_COMMAND_TOKENS,
|
||||
...POWERSHELL_ELEVATED_COMMAND_TOKENS
|
||||
])
|
||||
const TERMINAL_AUTH_WRAPPERS = new Set<string>([
|
||||
'env',
|
||||
'command',
|
||||
'builtin',
|
||||
'nohup',
|
||||
'time'
|
||||
])
|
||||
|
||||
export default class ShellTool extends Tool {
|
||||
private static readonly TOOLKIT = 'operating_system_control'
|
||||
private readonly config: ReturnType<typeof ToolkitConfig.load>
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.config = ToolkitConfig.load(ShellTool.TOOLKIT, this.toolName)
|
||||
const toolSettings = ToolkitConfig.loadToolSettings(
|
||||
ShellTool.TOOLKIT,
|
||||
this.toolName,
|
||||
DEFAULT_SETTINGS
|
||||
)
|
||||
this.settings = toolSettings
|
||||
this.requiredSettings = REQUIRED_SETTINGS
|
||||
this.checkRequiredSettings(this.toolName)
|
||||
}
|
||||
|
||||
get toolName(): string {
|
||||
return 'shell'
|
||||
}
|
||||
|
||||
get toolkit(): string {
|
||||
return ShellTool.TOOLKIT
|
||||
}
|
||||
|
||||
get description(): string {
|
||||
return this.config['description']
|
||||
}
|
||||
|
||||
async executeCommand(
|
||||
command: string,
|
||||
options: ExecuteOptions = {}
|
||||
): Promise<ShellResult> {
|
||||
const { cwd = process.cwd() } = options
|
||||
const timeoutMs = ShellTool.getTimeoutMs(options)
|
||||
const shellInvocation = ShellTool.getShellInvocation(command)
|
||||
const analyzedCommand = await this.resolveCommandForSafetyAnalysis(command)
|
||||
const isSafe = await this.isSafeCommand(analyzedCommand)
|
||||
|
||||
if (!isSafe) {
|
||||
const riskLevel = await this.getCommandRiskLevel(analyzedCommand)
|
||||
const riskDescription = await this.getRiskDescription(analyzedCommand)
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: `Blocked unsafe shell command (${riskLevel} risk): This command may ${riskDescription}.`,
|
||||
stdout: '',
|
||||
stderr: `Blocked unsafe shell command (${riskLevel} risk): This command may ${riskDescription}.`,
|
||||
returncode: -1,
|
||||
command,
|
||||
attempts: []
|
||||
}
|
||||
}
|
||||
|
||||
const requiresVisibleTerminal = this.requiresVisibleTerminal(analyzedCommand)
|
||||
const attempts: ShellAttempt[] = []
|
||||
const attempt = 1
|
||||
const attemptStartedAt = Date.now()
|
||||
|
||||
try {
|
||||
if (requiresVisibleTerminal) {
|
||||
await this.report('bridges.tools.command_requires_terminal_auth')
|
||||
|
||||
await super.executeCommand({
|
||||
binaryName: shellInvocation.binaryName,
|
||||
args: shellInvocation.args,
|
||||
options: {
|
||||
openInTerminal: true,
|
||||
waitForExit: true,
|
||||
cwd,
|
||||
timeout: timeoutMs
|
||||
},
|
||||
skipBinaryDownload: true
|
||||
})
|
||||
attempts.push({
|
||||
attempt,
|
||||
timeoutMs,
|
||||
durationMs: Date.now() - attemptStartedAt,
|
||||
status: 'success'
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
commandSucceeded: true,
|
||||
stdout:
|
||||
'Command executed in a visible terminal. Review that terminal for command output.',
|
||||
stderr: '',
|
||||
returncode: 0,
|
||||
command,
|
||||
attempts
|
||||
}
|
||||
}
|
||||
|
||||
const resultOutput = await super.executeCommand({
|
||||
binaryName: shellInvocation.binaryName,
|
||||
args: shellInvocation.args,
|
||||
options: {
|
||||
sync: false,
|
||||
cwd,
|
||||
timeout: timeoutMs
|
||||
},
|
||||
skipBinaryDownload: true
|
||||
})
|
||||
attempts.push({
|
||||
attempt,
|
||||
timeoutMs,
|
||||
durationMs: Date.now() - attemptStartedAt,
|
||||
status: 'success'
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
commandSucceeded: true,
|
||||
stdout: resultOutput.trim(),
|
||||
stderr: '',
|
||||
returncode: 0,
|
||||
command,
|
||||
attempts
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = (error as Error).message
|
||||
const processError = ShellTool.readProcessError(error)
|
||||
const timedOut = ShellTool.isTimeoutErrorMessage(errorMessage)
|
||||
const durationMs = Date.now() - attemptStartedAt
|
||||
|
||||
if (timedOut) {
|
||||
const timeoutMessage = `Command timed out after ${ShellTool.formatTimeoutMs(timeoutMs)} (1 attempt)`
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: timeoutMessage,
|
||||
stdout: '',
|
||||
stderr: timeoutMessage,
|
||||
returncode: -1,
|
||||
command,
|
||||
attempts: [
|
||||
...attempts,
|
||||
{
|
||||
attempt,
|
||||
timeoutMs,
|
||||
durationMs,
|
||||
status: 'timeout',
|
||||
error: timeoutMessage
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
errorMessage.includes('failed with exit code') ||
|
||||
processError.exitCode !== -1
|
||||
) {
|
||||
const exitCodeMatch = errorMessage.match(/exit code (\d+)/)
|
||||
const exitCode =
|
||||
processError.exitCode !== -1
|
||||
? processError.exitCode
|
||||
: exitCodeMatch && exitCodeMatch[1]
|
||||
? parseInt(exitCodeMatch[1], 10)
|
||||
: -1
|
||||
const stderrMatch = errorMessage.match(/exit code \d+: ([\s\S]*)$/)
|
||||
const parsedErrorOutput =
|
||||
stderrMatch && stderrMatch[1] ? stderrMatch[1].trim() : errorMessage
|
||||
const stderr =
|
||||
processError.stderr || (processError.stdout ? '' : parsedErrorOutput)
|
||||
const failureOutput = ShellTool.joinOutput([
|
||||
processError.stdout,
|
||||
stderr
|
||||
]) || errorMessage
|
||||
const attemptResult: ShellAttempt = {
|
||||
attempt,
|
||||
timeoutMs,
|
||||
durationMs,
|
||||
status: 'error'
|
||||
}
|
||||
attemptResult.error = failureOutput
|
||||
attempts.push(attemptResult)
|
||||
|
||||
return {
|
||||
success: false,
|
||||
commandSucceeded: false,
|
||||
error: requiresVisibleTerminal
|
||||
? `Command failed in the visible terminal with exit code ${exitCode}. Review that terminal for details.`
|
||||
: failureOutput,
|
||||
stdout: processError.stdout,
|
||||
stderr: requiresVisibleTerminal
|
||||
? `Command failed in the visible terminal with exit code ${exitCode}. Review that terminal for details.`
|
||||
: stderr,
|
||||
returncode: exitCode,
|
||||
command,
|
||||
attempts
|
||||
}
|
||||
}
|
||||
|
||||
const failureOutput = ShellTool.joinOutput([
|
||||
processError.stdout,
|
||||
processError.stderr
|
||||
]) || errorMessage
|
||||
attempts.push({
|
||||
attempt,
|
||||
timeoutMs,
|
||||
durationMs,
|
||||
status: 'error',
|
||||
error: failureOutput
|
||||
})
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: failureOutput,
|
||||
stdout: processError.stdout,
|
||||
stderr: processError.stderr || errorMessage,
|
||||
returncode: -1,
|
||||
command,
|
||||
attempts
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static getShellInvocation(command: string): ShellInvocation {
|
||||
if (isWindows()) {
|
||||
const trimmedCommand = command.trim()
|
||||
if (
|
||||
trimmedCommand.toLowerCase().endsWith('.ps1') &&
|
||||
!/\s/.test(trimmedCommand)
|
||||
) {
|
||||
return {
|
||||
binaryName: 'powershell.exe',
|
||||
args: [
|
||||
'-NoProfile',
|
||||
'-ExecutionPolicy',
|
||||
'Bypass',
|
||||
'-File',
|
||||
trimmedCommand
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
binaryName: 'powershell.exe',
|
||||
args: [
|
||||
'-NoProfile',
|
||||
'-ExecutionPolicy',
|
||||
'Bypass',
|
||||
'-Command',
|
||||
command
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
binaryName: 'bash',
|
||||
args: ['-c', command]
|
||||
}
|
||||
}
|
||||
|
||||
private static getTimeoutMs(options: ExecuteOptions): number {
|
||||
const timeoutSeconds = options.longRunning
|
||||
? LONG_RUNNING_TIMEOUT_SECONDS
|
||||
: DEFAULT_TIMEOUT_SECONDS
|
||||
|
||||
return timeoutSeconds * MILLISECONDS_PER_SECOND
|
||||
}
|
||||
|
||||
private static formatTimeoutMs(timeoutMs: number): string {
|
||||
const seconds = timeoutMs / MILLISECONDS_PER_SECOND
|
||||
|
||||
if (Number.isInteger(seconds)) {
|
||||
return `${seconds} seconds`
|
||||
}
|
||||
|
||||
return `${timeoutMs}ms`
|
||||
}
|
||||
|
||||
private static isTimeoutErrorMessage(errorMessage: string): boolean {
|
||||
const normalizedErrorMessage = errorMessage.toLowerCase()
|
||||
|
||||
return (
|
||||
normalizedErrorMessage.includes('timed out') ||
|
||||
normalizedErrorMessage.includes('timeout') ||
|
||||
normalizedErrorMessage.includes('etimedout')
|
||||
)
|
||||
}
|
||||
|
||||
private static readProcessError(error: unknown): {
|
||||
stdout: string
|
||||
stderr: string
|
||||
exitCode: number
|
||||
} {
|
||||
const processError = error as ProcessExecutionError
|
||||
const exitCode =
|
||||
typeof processError.status === 'number'
|
||||
? processError.status
|
||||
: typeof processError.code === 'number'
|
||||
? processError.code
|
||||
: -1
|
||||
|
||||
return {
|
||||
stdout: ShellTool.toOutputString(processError.stdout),
|
||||
stderr: ShellTool.toOutputString(processError.stderr),
|
||||
exitCode
|
||||
}
|
||||
}
|
||||
|
||||
private static toOutputString(output?: Buffer | string): string {
|
||||
if (!output) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return output.toString().trim()
|
||||
}
|
||||
|
||||
private static joinOutput(outputs: string[]): string {
|
||||
return outputs
|
||||
.map((output) => output.trim())
|
||||
.filter((output) => output.length > 0)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
async isSafeCommand(command: string): Promise<boolean> {
|
||||
const commandLower = command.toLowerCase()
|
||||
const tokens = this.tokenizeCommand(commandLower)
|
||||
|
||||
for (const pattern of UNSAFE_COMMAND_PATTERNS) {
|
||||
if (commandLower.includes(pattern)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
this.hasAnyTokenSequence(tokens, CRITICAL_COMMAND_SEQUENCES) ||
|
||||
this.hasCommandToken(tokens, CRITICAL_COMMAND_TOKENS) ||
|
||||
this.hasCommandToken(tokens, POWERSHELL_HIGH_RISK_COMMAND_TOKENS) ||
|
||||
this.hasDangerousPowerShellRemovePattern(tokens) ||
|
||||
this.hasDangerousDdPattern(tokens) ||
|
||||
this.hasEvalDownloadPattern(tokens)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (this.isDownloadPipedToShell(commandLower)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
async getCommandRiskLevel(command: string): Promise<string> {
|
||||
const commandLower = command.toLowerCase()
|
||||
const tokens = this.tokenizeCommand(commandLower)
|
||||
|
||||
let riskLevel = 'low'
|
||||
|
||||
if (
|
||||
this.hasAnyTokenSequence(tokens, CRITICAL_COMMAND_SEQUENCES) ||
|
||||
this.hasCommandToken(tokens, CRITICAL_COMMAND_TOKENS) ||
|
||||
this.hasDangerousPowerShellRemovePattern(tokens)
|
||||
) {
|
||||
riskLevel = 'critical'
|
||||
}
|
||||
|
||||
if (riskLevel === 'low') {
|
||||
if (
|
||||
this.hasCommandToken(tokens, POWERSHELL_HIGH_RISK_COMMAND_TOKENS) ||
|
||||
this.hasDangerousDdPattern(tokens) ||
|
||||
this.hasEvalDownloadPattern(tokens)
|
||||
) {
|
||||
riskLevel = 'high'
|
||||
}
|
||||
}
|
||||
|
||||
if (riskLevel === 'low' && this.isDownloadPipedToShell(commandLower)) {
|
||||
riskLevel = 'high'
|
||||
}
|
||||
|
||||
if (riskLevel === 'low') {
|
||||
for (const pattern of MEDIUM_RISK_COMMAND_PATTERNS) {
|
||||
if (commandLower.includes(pattern)) {
|
||||
riskLevel = 'medium'
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return riskLevel
|
||||
}
|
||||
|
||||
async getRiskDescription(command: string): Promise<string> {
|
||||
const riskLevel = await this.getCommandRiskLevel(command)
|
||||
const commandLower = command.toLowerCase()
|
||||
const tokens = this.tokenizeCommand(commandLower)
|
||||
|
||||
if (this.hasCommandToken(tokens, ['rm'])) {
|
||||
return 'delete files or directories permanently'
|
||||
} else if (
|
||||
this.hasCommandToken(tokens, [
|
||||
...ELEVATED_COMMAND_TOKENS,
|
||||
...POWERSHELL_ELEVATED_COMMAND_TOKENS
|
||||
])
|
||||
) {
|
||||
return 'make system-level changes with elevated privileges'
|
||||
} else if (
|
||||
this.hasCommandToken(tokens, ['kill', 'stop-process'])
|
||||
) {
|
||||
return 'terminate running processes'
|
||||
} else if (this.hasDangerousPowerShellRemovePattern(tokens)) {
|
||||
return 'delete files or directories recursively'
|
||||
} else if (this.hasCommandToken(tokens, PERMISSION_COMMAND_TOKENS)) {
|
||||
return 'change file permissions or ownership'
|
||||
} else if (
|
||||
this.hasCommandToken(tokens, PACKAGE_MANAGER_COMMAND_TOKENS)
|
||||
) {
|
||||
return 'install or modify system packages'
|
||||
} else if (this.isDownloadPipedToShell(commandLower)) {
|
||||
return 'download remote content and execute it as a shell script'
|
||||
} else if (this.hasCommandToken(tokens, HIGH_RISK_EVAL_DOWNLOAD_TOKENS)) {
|
||||
return 'download content from the internet'
|
||||
} else {
|
||||
const descriptions: Record<string, string> = {
|
||||
critical: 'cause severe system damage',
|
||||
high: 'cause significant system changes',
|
||||
medium: 'modify your system',
|
||||
low: 'perform system operations'
|
||||
}
|
||||
return descriptions[riskLevel] || 'affect your system'
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveCommandForSafetyAnalysis(command: string): Promise<string> {
|
||||
const trimmedCommand = command.trim()
|
||||
if (!trimmedCommand || /\s/.test(trimmedCommand)) {
|
||||
return command
|
||||
}
|
||||
|
||||
const resolvedPath = path.resolve(trimmedCommand)
|
||||
|
||||
try {
|
||||
const stats = await fs.promises.stat(resolvedPath)
|
||||
if (!stats.isFile()) {
|
||||
return command
|
||||
}
|
||||
|
||||
const fileContent = await fs.promises.readFile(resolvedPath, 'utf8')
|
||||
if (!fileContent.trim()) {
|
||||
return command
|
||||
}
|
||||
|
||||
return fileContent
|
||||
} catch {
|
||||
return command
|
||||
}
|
||||
}
|
||||
|
||||
private isDownloadPipedToShell(commandLower: string): boolean {
|
||||
const downloadsRemoteContent =
|
||||
this.hasCommandToken(this.tokenizeCommand(commandLower), ['curl', 'wget'])
|
||||
const pipesToShell =
|
||||
commandLower.includes('| bash') ||
|
||||
commandLower.includes('| sh') ||
|
||||
commandLower.includes('| iex') ||
|
||||
commandLower.includes('| invoke-expression')
|
||||
|
||||
return downloadsRemoteContent && pipesToShell
|
||||
}
|
||||
|
||||
private tokenizeCommand(command: string): string[] {
|
||||
const tokens: string[] = []
|
||||
let currentToken = ''
|
||||
let quote: '\'' | '"' | null = null
|
||||
let escaped = false
|
||||
|
||||
const flushToken = (): void => {
|
||||
if (!currentToken) {
|
||||
return
|
||||
}
|
||||
|
||||
tokens.push(currentToken)
|
||||
currentToken = ''
|
||||
}
|
||||
|
||||
for (const char of command) {
|
||||
if (quote) {
|
||||
if (escaped) {
|
||||
currentToken += char
|
||||
escaped = false
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === '\\' && quote === '"') {
|
||||
escaped = true
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === quote) {
|
||||
quote = null
|
||||
continue
|
||||
}
|
||||
|
||||
currentToken += char
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === '\'' || char === '"') {
|
||||
quote = char
|
||||
continue
|
||||
}
|
||||
|
||||
if (
|
||||
char === '\n' ||
|
||||
char === ';' ||
|
||||
char === '|' ||
|
||||
char === '&' ||
|
||||
char === ' ' ||
|
||||
char === '\t' ||
|
||||
char === '\r' ||
|
||||
char === '>' ||
|
||||
char === '<'
|
||||
) {
|
||||
flushToken()
|
||||
continue
|
||||
}
|
||||
|
||||
currentToken += char
|
||||
}
|
||||
|
||||
flushToken()
|
||||
return tokens
|
||||
}
|
||||
|
||||
private hasTokenSequence(tokens: string[], sequence: string[]): boolean {
|
||||
if (sequence.length === 0 || tokens.length < sequence.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
for (let index = 0; index <= tokens.length - sequence.length; index += 1) {
|
||||
const matches = sequence.every(
|
||||
(token, offset) => tokens[index + offset] === token
|
||||
)
|
||||
if (matches) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private hasCommandToken(tokens: string[], commands: readonly string[]): boolean {
|
||||
return tokens.some((token) => {
|
||||
const normalizedToken = this.normalizeCommandToken(token)
|
||||
return commands.some(
|
||||
(command) =>
|
||||
normalizedToken === command || normalizedToken.startsWith(`${command}.`)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
private hasDangerousPowerShellRemovePattern(tokens: string[]): boolean {
|
||||
if (!this.hasCommandToken(tokens, POWERSHELL_DESTRUCTIVE_COMMAND_TOKENS)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return tokens.some((token) =>
|
||||
['-recurse', '-r', '/s'].includes(token)
|
||||
)
|
||||
}
|
||||
|
||||
private hasDangerousDdPattern(tokens: string[]): boolean {
|
||||
if (!this.hasCommandToken(tokens, HIGH_RISK_DD_TOKENS)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return tokens.some((token) => token.startsWith('if='))
|
||||
}
|
||||
|
||||
private hasEvalDownloadPattern(tokens: string[]): boolean {
|
||||
for (let index = 0; index < tokens.length - 1; index += 1) {
|
||||
if (tokens[index] !== 'eval') {
|
||||
continue
|
||||
}
|
||||
|
||||
const nextToken = tokens[index + 1] || ''
|
||||
if (
|
||||
HIGH_RISK_EVAL_DOWNLOAD_TOKENS.some((token) =>
|
||||
nextToken.startsWith(`$(${token}`)
|
||||
)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private normalizeCommandToken(token: string): string {
|
||||
const strippedToken = token.replace(/^[([{]+|[)\]}]+$/g, '')
|
||||
if (strippedToken.includes('/')) {
|
||||
return strippedToken.split('/').pop() || strippedToken
|
||||
}
|
||||
|
||||
if (strippedToken.includes('\\')) {
|
||||
return strippedToken.split('\\').pop() || strippedToken
|
||||
}
|
||||
|
||||
return strippedToken
|
||||
}
|
||||
|
||||
private hasAnyTokenSequence(
|
||||
tokens: string[],
|
||||
sequences: readonly (readonly string[])[]
|
||||
): boolean {
|
||||
return sequences.some((sequence) =>
|
||||
this.hasTokenSequence(tokens, [...sequence])
|
||||
)
|
||||
}
|
||||
|
||||
private requiresVisibleTerminal(command: string): boolean {
|
||||
let currentToken = ''
|
||||
let quote: '\'' | '"' | null = null
|
||||
let atCommandStart = true
|
||||
let escaped = false
|
||||
|
||||
const flushToken = (): boolean => {
|
||||
if (!currentToken) {
|
||||
return false
|
||||
}
|
||||
|
||||
const token = currentToken
|
||||
currentToken = ''
|
||||
|
||||
if (!atCommandStart) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (this.isShellAssignment(token) || TERMINAL_AUTH_WRAPPERS.has(token)) {
|
||||
return false
|
||||
}
|
||||
|
||||
atCommandStart = false
|
||||
return TERMINAL_AUTH_COMMANDS.has(token)
|
||||
}
|
||||
|
||||
for (const char of command) {
|
||||
if (quote) {
|
||||
if (escaped) {
|
||||
escaped = false
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === '\\' && quote === '"') {
|
||||
escaped = true
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === quote) {
|
||||
quote = null
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === '\'' || char === '"') {
|
||||
quote = char
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === '\n' || char === ';' || char === '|' || char === '&') {
|
||||
if (flushToken()) {
|
||||
return true
|
||||
}
|
||||
atCommandStart = true
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === ' ' || char === '\t' || char === '\r') {
|
||||
if (flushToken()) {
|
||||
return true
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
currentToken += char
|
||||
}
|
||||
|
||||
return flushToken()
|
||||
}
|
||||
|
||||
private isShellAssignment(token: string): boolean {
|
||||
const separatorIndex = token.indexOf('=')
|
||||
if (separatorIndex <= 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
return !token.slice(0, separatorIndex).includes('/')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,597 @@
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Sequence
|
||||
|
||||
from bridges.python.src.sdk.base_tool import BaseTool, ExecuteCommandOptions
|
||||
from bridges.python.src.sdk.toolkit_config import ToolkitConfig
|
||||
|
||||
DEFAULT_SETTINGS: Dict[str, Any] = {}
|
||||
REQUIRED_SETTINGS: List[str] = []
|
||||
|
||||
DEFAULT_TIMEOUT_SECONDS = 30
|
||||
LONG_RUNNING_TIMEOUT_SECONDS = 86_400
|
||||
MILLISECONDS_PER_SECOND = 1_000
|
||||
|
||||
CRITICAL_COMMAND_SEQUENCES: Sequence[Sequence[str]] = (
|
||||
("rm", "-rf", "/"),
|
||||
("rm", "-rf", "/*"),
|
||||
("kill", "-9", "-1"),
|
||||
)
|
||||
|
||||
CRITICAL_COMMAND_TOKENS: Sequence[str] = ("mkfs", "format", "fdisk")
|
||||
HIGH_RISK_DD_TOKENS: Sequence[str] = ("dd",)
|
||||
HIGH_RISK_EVAL_DOWNLOAD_TOKENS: Sequence[str] = ("curl", "wget")
|
||||
ELEVATED_COMMAND_TOKENS: Sequence[str] = ("sudo", "doas", "pkexec", "su")
|
||||
PERMISSION_COMMAND_TOKENS: Sequence[str] = ("chmod", "chown")
|
||||
PACKAGE_MANAGER_COMMAND_TOKENS: Sequence[str] = (
|
||||
"apt",
|
||||
"apt-get",
|
||||
"yum",
|
||||
"brew",
|
||||
"pip",
|
||||
"pip3",
|
||||
)
|
||||
|
||||
MEDIUM_RISK_COMMAND_PATTERNS: Sequence[str] = ()
|
||||
|
||||
UNSAFE_COMMAND_PATTERNS: Sequence[str] = (
|
||||
"fork()",
|
||||
"while true; do",
|
||||
)
|
||||
|
||||
TERMINAL_AUTH_COMMANDS = set(ELEVATED_COMMAND_TOKENS)
|
||||
TERMINAL_AUTH_WRAPPERS = {"env", "command", "builtin", "nohup", "time"}
|
||||
|
||||
|
||||
class ShellTool(BaseTool):
|
||||
TOOLKIT = "operating_system_control"
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.config = ToolkitConfig.load(self.TOOLKIT, self.tool_name)
|
||||
self.settings = ToolkitConfig.load_tool_settings(
|
||||
self.TOOLKIT, self.tool_name, DEFAULT_SETTINGS
|
||||
)
|
||||
self.required_settings = REQUIRED_SETTINGS
|
||||
self._check_required_settings(self.tool_name)
|
||||
|
||||
@property
|
||||
def tool_name(self) -> str:
|
||||
return "shell"
|
||||
|
||||
@property
|
||||
def toolkit(self) -> str:
|
||||
return self.TOOLKIT
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return self.config["description"]
|
||||
|
||||
def execute_command(
|
||||
self,
|
||||
command: str,
|
||||
cwd: Optional[str] = None,
|
||||
long_running: bool = False,
|
||||
capture_output: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
analyzed_command = self._resolve_command_for_safety_analysis(command)
|
||||
is_safe = self.is_safe_command(analyzed_command)
|
||||
|
||||
if not is_safe:
|
||||
risk_level = self.get_command_risk_level(analyzed_command)
|
||||
risk_description = self.get_risk_description(analyzed_command)
|
||||
return {
|
||||
"success": False,
|
||||
"stdout": "",
|
||||
"stderr": f"Blocked unsafe shell command ({risk_level} risk): This command may {risk_description}.",
|
||||
"returncode": -1,
|
||||
"command": command,
|
||||
"attempts": [],
|
||||
}
|
||||
|
||||
binary_name, args = self._get_shell_invocation(command)
|
||||
requires_visible_terminal = self._requires_visible_terminal(analyzed_command)
|
||||
timeout_seconds = self._get_timeout_seconds(long_running)
|
||||
timeout_milliseconds = int(timeout_seconds * MILLISECONDS_PER_SECOND)
|
||||
attempts: List[Dict[str, Any]] = []
|
||||
attempt = 1
|
||||
attempt_started_at = self._now_milliseconds()
|
||||
|
||||
try:
|
||||
if requires_visible_terminal:
|
||||
self.report("bridges.tools.command_requires_terminal_auth")
|
||||
|
||||
super().execute_command(
|
||||
ExecuteCommandOptions(
|
||||
binary_name=binary_name,
|
||||
args=args,
|
||||
options={
|
||||
"open_in_terminal": True,
|
||||
"wait_for_exit": True,
|
||||
"cwd": cwd or os.getcwd(),
|
||||
"timeout": timeout_milliseconds,
|
||||
},
|
||||
skip_binary_download=True,
|
||||
)
|
||||
)
|
||||
attempts.append(
|
||||
self._build_attempt(
|
||||
attempt,
|
||||
timeout_milliseconds,
|
||||
attempt_started_at,
|
||||
"success",
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"commandSucceeded": True,
|
||||
"stdout": "Command executed in a visible terminal. Review that terminal for command output.",
|
||||
"stderr": "",
|
||||
"returncode": 0,
|
||||
"command": command,
|
||||
"attempts": attempts,
|
||||
}
|
||||
|
||||
result_output = super().execute_command(
|
||||
ExecuteCommandOptions(
|
||||
binary_name=binary_name,
|
||||
args=args,
|
||||
options={
|
||||
"sync": False,
|
||||
"cwd": cwd or os.getcwd(),
|
||||
"timeout": timeout_seconds,
|
||||
},
|
||||
skip_binary_download=True,
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"commandSucceeded": True,
|
||||
"stdout": result_output.strip(),
|
||||
"stderr": "",
|
||||
"returncode": 0,
|
||||
"command": command,
|
||||
"attempts": attempts
|
||||
+ [
|
||||
self._build_attempt(
|
||||
attempt,
|
||||
timeout_milliseconds,
|
||||
attempt_started_at,
|
||||
"success",
|
||||
)
|
||||
],
|
||||
}
|
||||
except Exception as error:
|
||||
error_message = str(error)
|
||||
timed_out = self._is_timeout_error_message(error_message)
|
||||
duration_milliseconds = self._elapsed_milliseconds(attempt_started_at)
|
||||
|
||||
if timed_out:
|
||||
timeout_message = (
|
||||
f"Command timed out after "
|
||||
f"{self._format_timeout_seconds(timeout_seconds)} "
|
||||
"(1 attempt)"
|
||||
)
|
||||
|
||||
return {
|
||||
"success": False,
|
||||
"stdout": "",
|
||||
"stderr": timeout_message,
|
||||
"returncode": -1,
|
||||
"command": command,
|
||||
"attempts": attempts
|
||||
+ [
|
||||
{
|
||||
"attempt": attempt,
|
||||
"timeoutMs": timeout_milliseconds,
|
||||
"durationMs": duration_milliseconds,
|
||||
"status": "timeout",
|
||||
"error": timeout_message,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
if "failed with exit code" in error_message:
|
||||
exit_code_match = re.search(r"exit code (\d+)", error_message)
|
||||
exit_code = int(exit_code_match.group(1)) if exit_code_match else -1
|
||||
stderr_match = re.search(r"exit code \d+: ([\s\S]*)$", error_message)
|
||||
stderr = stderr_match.group(1) if stderr_match else error_message
|
||||
failure_output = (
|
||||
f"Command failed in the visible terminal with exit code {exit_code}. Review that terminal for details."
|
||||
if requires_visible_terminal
|
||||
else stderr
|
||||
)
|
||||
attempt_result = {
|
||||
"attempt": attempt,
|
||||
"timeoutMs": timeout_milliseconds,
|
||||
"durationMs": duration_milliseconds,
|
||||
"status": "error",
|
||||
"error": failure_output,
|
||||
}
|
||||
attempts.append(attempt_result)
|
||||
|
||||
return {
|
||||
"success": False,
|
||||
"commandSucceeded": False,
|
||||
"stdout": "",
|
||||
"stderr": failure_output,
|
||||
"returncode": exit_code,
|
||||
"command": command,
|
||||
"attempts": attempts,
|
||||
}
|
||||
|
||||
attempts.append(
|
||||
{
|
||||
"attempt": attempt,
|
||||
"timeoutMs": timeout_milliseconds,
|
||||
"durationMs": duration_milliseconds,
|
||||
"status": "error",
|
||||
"error": error_message,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"success": False,
|
||||
"stdout": "",
|
||||
"stderr": error_message,
|
||||
"returncode": -1,
|
||||
"command": command,
|
||||
"attempts": attempts,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _now_milliseconds() -> int:
|
||||
return int(time.time() * MILLISECONDS_PER_SECOND)
|
||||
|
||||
@staticmethod
|
||||
def _elapsed_milliseconds(started_at: int) -> int:
|
||||
return max(ShellTool._now_milliseconds() - started_at, 0)
|
||||
|
||||
@staticmethod
|
||||
def _build_attempt(
|
||||
attempt: int,
|
||||
timeout_milliseconds: int,
|
||||
started_at: int,
|
||||
status: str,
|
||||
error: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
result: Dict[str, Any] = {
|
||||
"attempt": attempt,
|
||||
"timeoutMs": timeout_milliseconds,
|
||||
"durationMs": ShellTool._elapsed_milliseconds(started_at),
|
||||
"status": status,
|
||||
}
|
||||
|
||||
if error:
|
||||
result["error"] = error
|
||||
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _get_shell_invocation(command: str) -> tuple[str, List[str]]:
|
||||
if os.name == "nt":
|
||||
trimmed_command = command.strip()
|
||||
if trimmed_command.lower().endswith(".ps1") and not any(
|
||||
char.isspace() for char in trimmed_command
|
||||
):
|
||||
return (
|
||||
"powershell.exe",
|
||||
[
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
trimmed_command,
|
||||
],
|
||||
)
|
||||
|
||||
return (
|
||||
"powershell.exe",
|
||||
[
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-Command",
|
||||
command,
|
||||
],
|
||||
)
|
||||
|
||||
return "bash", ["-c", command]
|
||||
|
||||
@staticmethod
|
||||
def _get_timeout_seconds(long_running: bool) -> float:
|
||||
return float(
|
||||
LONG_RUNNING_TIMEOUT_SECONDS
|
||||
if long_running
|
||||
else DEFAULT_TIMEOUT_SECONDS
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _format_timeout_seconds(timeout_seconds: float) -> str:
|
||||
if timeout_seconds.is_integer():
|
||||
return f"{int(timeout_seconds)} seconds"
|
||||
|
||||
return f"{timeout_seconds:.3f} seconds"
|
||||
|
||||
@staticmethod
|
||||
def _is_timeout_error_message(error_message: str) -> bool:
|
||||
normalized_error_message = error_message.lower()
|
||||
|
||||
return (
|
||||
"timed out" in normalized_error_message
|
||||
or "timeout" in normalized_error_message
|
||||
or "etimedout" in normalized_error_message
|
||||
)
|
||||
|
||||
def is_safe_command(self, command: str) -> bool:
|
||||
command_lower = command.lower()
|
||||
tokens = self._tokenize_command(command_lower)
|
||||
|
||||
for pattern in UNSAFE_COMMAND_PATTERNS:
|
||||
if pattern in command_lower:
|
||||
return False
|
||||
|
||||
if (
|
||||
self._has_any_token_sequence(tokens, CRITICAL_COMMAND_SEQUENCES)
|
||||
or self._has_command_token(tokens, CRITICAL_COMMAND_TOKENS)
|
||||
or self._has_dangerous_dd_pattern(tokens)
|
||||
or self._has_eval_download_pattern(tokens)
|
||||
):
|
||||
return False
|
||||
|
||||
if self._is_download_piped_to_shell(command_lower):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def get_command_risk_level(self, command: str) -> str:
|
||||
command_lower = command.lower()
|
||||
tokens = self._tokenize_command(command_lower)
|
||||
|
||||
risk_level = "low"
|
||||
|
||||
if self._has_any_token_sequence(
|
||||
tokens, CRITICAL_COMMAND_SEQUENCES
|
||||
) or self._has_command_token(tokens, CRITICAL_COMMAND_TOKENS):
|
||||
risk_level = "critical"
|
||||
|
||||
if risk_level == "low":
|
||||
if self._has_dangerous_dd_pattern(
|
||||
tokens
|
||||
) or self._has_eval_download_pattern(tokens):
|
||||
risk_level = "high"
|
||||
|
||||
if risk_level == "low" and self._is_download_piped_to_shell(command_lower):
|
||||
risk_level = "high"
|
||||
|
||||
if risk_level == "low":
|
||||
for pattern in MEDIUM_RISK_COMMAND_PATTERNS:
|
||||
if pattern in command_lower:
|
||||
risk_level = "medium"
|
||||
break
|
||||
|
||||
return risk_level
|
||||
|
||||
def get_risk_description(self, command: str) -> str:
|
||||
risk_level = self.get_command_risk_level(command)
|
||||
command_lower = command.lower()
|
||||
tokens = self._tokenize_command(command_lower)
|
||||
|
||||
if self._has_command_token(tokens, ("rm",)):
|
||||
return "delete files or directories permanently"
|
||||
if self._has_command_token(tokens, ELEVATED_COMMAND_TOKENS):
|
||||
return "make system-level changes with elevated privileges"
|
||||
if self._has_command_token(tokens, ("kill",)):
|
||||
return "terminate running processes"
|
||||
if self._has_command_token(tokens, PERMISSION_COMMAND_TOKENS):
|
||||
return "change file permissions or ownership"
|
||||
if self._has_command_token(tokens, PACKAGE_MANAGER_COMMAND_TOKENS):
|
||||
return "install or modify system packages"
|
||||
if self._is_download_piped_to_shell(command_lower):
|
||||
return "download remote content and execute it as a shell script"
|
||||
if self._has_command_token(tokens, HIGH_RISK_EVAL_DOWNLOAD_TOKENS):
|
||||
return "download content from the internet"
|
||||
|
||||
descriptions = {
|
||||
"critical": "cause severe system damage",
|
||||
"high": "cause significant system changes",
|
||||
"medium": "modify your system",
|
||||
"low": "perform system operations",
|
||||
}
|
||||
return descriptions.get(risk_level, "affect your system")
|
||||
|
||||
def _resolve_command_for_safety_analysis(self, command: str) -> str:
|
||||
trimmed_command = command.strip()
|
||||
if not trimmed_command or any(char.isspace() for char in trimmed_command):
|
||||
return command
|
||||
|
||||
resolved_path = Path(trimmed_command).expanduser().resolve()
|
||||
|
||||
try:
|
||||
if not resolved_path.is_file():
|
||||
return command
|
||||
|
||||
file_content = resolved_path.read_text(encoding="utf-8")
|
||||
if not file_content.strip():
|
||||
return command
|
||||
|
||||
return file_content
|
||||
except Exception:
|
||||
return command
|
||||
|
||||
def _is_download_piped_to_shell(self, command_lower: str) -> bool:
|
||||
downloads_remote_content = self._has_command_token(
|
||||
self._tokenize_command(command_lower), ("curl", "wget")
|
||||
)
|
||||
pipes_to_shell = "| bash" in command_lower or "| sh" in command_lower
|
||||
return downloads_remote_content and pipes_to_shell
|
||||
|
||||
def _tokenize_command(self, command: str) -> List[str]:
|
||||
tokens: List[str] = []
|
||||
current_token = ""
|
||||
quote: Optional[str] = None
|
||||
escaped = False
|
||||
|
||||
def flush_token() -> None:
|
||||
nonlocal current_token
|
||||
if not current_token:
|
||||
return
|
||||
tokens.append(current_token)
|
||||
current_token = ""
|
||||
|
||||
for char in command:
|
||||
if quote:
|
||||
if escaped:
|
||||
current_token += char
|
||||
escaped = False
|
||||
continue
|
||||
|
||||
if char == "\\" and quote == '"':
|
||||
escaped = True
|
||||
continue
|
||||
|
||||
if char == quote:
|
||||
quote = None
|
||||
continue
|
||||
|
||||
current_token += char
|
||||
continue
|
||||
|
||||
if char in ("'", '"'):
|
||||
quote = char
|
||||
continue
|
||||
|
||||
if char in ("\n", ";", "|", "&", " ", "\t", "\r", ">", "<"):
|
||||
flush_token()
|
||||
continue
|
||||
|
||||
current_token += char
|
||||
|
||||
flush_token()
|
||||
return tokens
|
||||
|
||||
def _has_token_sequence(
|
||||
self, tokens: Sequence[str], sequence: Sequence[str]
|
||||
) -> bool:
|
||||
if len(sequence) == 0 or len(tokens) < len(sequence):
|
||||
return False
|
||||
|
||||
for index in range(len(tokens) - len(sequence) + 1):
|
||||
matches = all(
|
||||
tokens[index + offset] == token
|
||||
for offset, token in enumerate(sequence)
|
||||
)
|
||||
if matches:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _has_command_token(
|
||||
self, tokens: Sequence[str], commands: Sequence[str]
|
||||
) -> bool:
|
||||
for token in tokens:
|
||||
normalized_token = self._normalize_command_token(token)
|
||||
for command in commands:
|
||||
if normalized_token == command or normalized_token.startswith(
|
||||
f"{command}."
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _has_dangerous_dd_pattern(self, tokens: Sequence[str]) -> bool:
|
||||
if not self._has_command_token(tokens, HIGH_RISK_DD_TOKENS):
|
||||
return False
|
||||
|
||||
return any(token.startswith("if=") for token in tokens)
|
||||
|
||||
def _has_eval_download_pattern(self, tokens: Sequence[str]) -> bool:
|
||||
for index in range(len(tokens) - 1):
|
||||
if tokens[index] != "eval":
|
||||
continue
|
||||
|
||||
next_token = tokens[index + 1]
|
||||
if any(
|
||||
next_token.startswith(f"$({token}")
|
||||
for token in HIGH_RISK_EVAL_DOWNLOAD_TOKENS
|
||||
):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _normalize_command_token(self, token: str) -> str:
|
||||
stripped_token = re.sub(r"^[([{]+|[)\]}]+$", "", token)
|
||||
if "/" in stripped_token:
|
||||
return stripped_token.split("/")[-1] or stripped_token
|
||||
return stripped_token
|
||||
|
||||
def _has_any_token_sequence(
|
||||
self, tokens: Sequence[str], sequences: Sequence[Sequence[str]]
|
||||
) -> bool:
|
||||
return any(self._has_token_sequence(tokens, sequence) for sequence in sequences)
|
||||
|
||||
def _requires_visible_terminal(self, command: str) -> bool:
|
||||
current_token = ""
|
||||
quote: Optional[str] = None
|
||||
at_command_start = True
|
||||
escaped = False
|
||||
|
||||
def flush_token() -> bool:
|
||||
nonlocal current_token, at_command_start
|
||||
if not current_token:
|
||||
return False
|
||||
|
||||
token = current_token
|
||||
current_token = ""
|
||||
|
||||
if not at_command_start:
|
||||
return False
|
||||
|
||||
if self._is_shell_assignment(token) or token in TERMINAL_AUTH_WRAPPERS:
|
||||
return False
|
||||
|
||||
at_command_start = False
|
||||
return token in TERMINAL_AUTH_COMMANDS
|
||||
|
||||
for char in command:
|
||||
if quote:
|
||||
if escaped:
|
||||
escaped = False
|
||||
continue
|
||||
|
||||
if char == "\\" and quote == '"':
|
||||
escaped = True
|
||||
continue
|
||||
|
||||
if char == quote:
|
||||
quote = None
|
||||
continue
|
||||
|
||||
if char in ("'", '"'):
|
||||
quote = char
|
||||
continue
|
||||
|
||||
if char in ("\n", ";", "|", "&"):
|
||||
if flush_token():
|
||||
return True
|
||||
at_command_start = True
|
||||
continue
|
||||
|
||||
if char in (" ", "\t", "\r"):
|
||||
if flush_token():
|
||||
return True
|
||||
continue
|
||||
|
||||
current_token += char
|
||||
|
||||
return flush_token()
|
||||
|
||||
def _is_shell_assignment(self, token: str) -> bool:
|
||||
separator_index = token.find("=")
|
||||
if separator_index <= 0:
|
||||
return False
|
||||
|
||||
return "/" not in token[:separator_index]
|
||||
Reference in New Issue
Block a user