154 lines
5.1 KiB
TypeScript
154 lines
5.1 KiB
TypeScript
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
|
|
}
|