/ tools / BashTool / shouldUseSandbox.ts
shouldUseSandbox.ts
  1  import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js'
  2  import { splitCommand_DEPRECATED } from '../../utils/bash/commands.js'
  3  import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js'
  4  import { getSettings_DEPRECATED } from '../../utils/settings/settings.js'
  5  import {
  6    BINARY_HIJACK_VARS,
  7    bashPermissionRule,
  8    matchWildcardPattern,
  9    stripAllLeadingEnvVars,
 10    stripSafeWrappers,
 11  } from './bashPermissions.js'
 12  
 13  type SandboxInput = {
 14    command?: string
 15    dangerouslyDisableSandbox?: boolean
 16  }
 17  
 18  // NOTE: excludedCommands is a user-facing convenience feature, not a security boundary.
 19  // It is not a security bug to be able to bypass excludedCommands — the sandbox permission
 20  // system (which prompts users) is the actual security control.
 21  function containsExcludedCommand(command: string): boolean {
 22    // Check dynamic config for disabled commands and substrings (only for ants)
 23    if (process.env.USER_TYPE === 'ant') {
 24      const disabledCommands = getFeatureValue_CACHED_MAY_BE_STALE<{
 25        commands: string[]
 26        substrings: string[]
 27      }>('tengu_sandbox_disabled_commands', { commands: [], substrings: [] })
 28  
 29      // Check if command contains any disabled substrings
 30      for (const substring of disabledCommands.substrings) {
 31        if (command.includes(substring)) {
 32          return true
 33        }
 34      }
 35  
 36      // Check if command starts with any disabled commands
 37      try {
 38        const commandParts = splitCommand_DEPRECATED(command)
 39        for (const part of commandParts) {
 40          const baseCommand = part.trim().split(' ')[0]
 41          if (baseCommand && disabledCommands.commands.includes(baseCommand)) {
 42            return true
 43          }
 44        }
 45      } catch {
 46        // If we can't parse the command (e.g., malformed bash syntax),
 47        // treat it as not excluded to allow other validation checks to handle it
 48        // This prevents crashes when rendering tool use messages
 49      }
 50    }
 51  
 52    // Check user-configured excluded commands from settings
 53    const settings = getSettings_DEPRECATED()
 54    const userExcludedCommands = settings.sandbox?.excludedCommands ?? []
 55  
 56    if (userExcludedCommands.length === 0) {
 57      return false
 58    }
 59  
 60    // Split compound commands (e.g. "docker ps && curl evil.com") into individual
 61    // subcommands and check each one against excluded patterns. This prevents a
 62    // compound command from escaping the sandbox just because its first subcommand
 63    // matches an excluded pattern.
 64    let subcommands: string[]
 65    try {
 66      subcommands = splitCommand_DEPRECATED(command)
 67    } catch {
 68      subcommands = [command]
 69    }
 70  
 71    for (const subcommand of subcommands) {
 72      const trimmed = subcommand.trim()
 73      // Also try matching with env var prefixes and wrapper commands stripped, so
 74      // that `FOO=bar bazel ...` and `timeout 30 bazel ...` match `bazel:*`. Not a
 75      // security boundary (see NOTE at top); the &&-split above already lets
 76      // `export FOO=bar && bazel ...` match. BINARY_HIJACK_VARS kept as a heuristic.
 77      //
 78      // We iteratively apply both stripping operations until no new candidates are
 79      // produced (fixed-point), matching the approach in filterRulesByContentsMatchingInput.
 80      // This handles interleaved patterns like `timeout 300 FOO=bar bazel run`
 81      // where single-pass composition would fail.
 82      const candidates = [trimmed]
 83      const seen = new Set(candidates)
 84      let startIdx = 0
 85      while (startIdx < candidates.length) {
 86        const endIdx = candidates.length
 87        for (let i = startIdx; i < endIdx; i++) {
 88          const cmd = candidates[i]!
 89          const envStripped = stripAllLeadingEnvVars(cmd, BINARY_HIJACK_VARS)
 90          if (!seen.has(envStripped)) {
 91            candidates.push(envStripped)
 92            seen.add(envStripped)
 93          }
 94          const wrapperStripped = stripSafeWrappers(cmd)
 95          if (!seen.has(wrapperStripped)) {
 96            candidates.push(wrapperStripped)
 97            seen.add(wrapperStripped)
 98          }
 99        }
100        startIdx = endIdx
101      }
102  
103      for (const pattern of userExcludedCommands) {
104        const rule = bashPermissionRule(pattern)
105        for (const cand of candidates) {
106          switch (rule.type) {
107            case 'prefix':
108              if (cand === rule.prefix || cand.startsWith(rule.prefix + ' ')) {
109                return true
110              }
111              break
112            case 'exact':
113              if (cand === rule.command) {
114                return true
115              }
116              break
117            case 'wildcard':
118              if (matchWildcardPattern(rule.pattern, cand)) {
119                return true
120              }
121              break
122          }
123        }
124      }
125    }
126  
127    return false
128  }
129  
130  export function shouldUseSandbox(input: Partial<SandboxInput>): boolean {
131    if (!SandboxManager.isSandboxingEnabled()) {
132      return false
133    }
134  
135    // Don't sandbox if explicitly overridden AND unsandboxed commands are allowed by policy
136    if (
137      input.dangerouslyDisableSandbox &&
138      SandboxManager.areUnsandboxedCommandsAllowed()
139    ) {
140      return false
141    }
142  
143    if (!input.command) {
144      return false
145    }
146  
147    // Don't sandbox if the command contains user-configured excluded commands
148    if (containsExcludedCommand(input.command)) {
149      return false
150    }
151  
152    return true
153  }