1487 lines
51 KiB
TypeScript
1487 lines
51 KiB
TypeScript
import { feature } from 'bun:bundle'
|
||
import { APIUserAbortError } from '@anthropic-ai/sdk'
|
||
import type { CanUseToolFn } from '../../hooks/useCanUseTool.js'
|
||
import {
|
||
getToolNameForPermissionCheck,
|
||
mcpInfoFromString,
|
||
} from '../../services/mcp/mcpStringUtils.js'
|
||
import type { Tool, ToolPermissionContext, ToolUseContext } from '../../Tool.js'
|
||
import { AGENT_TOOL_NAME } from '../../tools/AgentTool/constants.js'
|
||
import { shouldUseSandbox } from '../../tools/BashTool/shouldUseSandbox.js'
|
||
import { BASH_TOOL_NAME } from '../../tools/BashTool/toolName.js'
|
||
import { POWERSHELL_TOOL_NAME } from '../../tools/PowerShellTool/toolName.js'
|
||
import { REPL_TOOL_NAME } from '../../tools/REPLTool/constants.js'
|
||
import type { AssistantMessage } from '../../types/message.js'
|
||
import { extractOutputRedirections } from '../bash/commands.js'
|
||
import { logForDebugging } from '../debug.js'
|
||
import { AbortError, toError } from '../errors.js'
|
||
import { logError } from '../log.js'
|
||
import { SandboxManager } from '../sandbox/sandbox-adapter.js'
|
||
import {
|
||
getSettingSourceDisplayNameLowercase,
|
||
SETTING_SOURCES,
|
||
} from '../settings/constants.js'
|
||
import { plural } from '../stringUtils.js'
|
||
import { permissionModeTitle } from './PermissionMode.js'
|
||
import type {
|
||
PermissionAskDecision,
|
||
PermissionDecision,
|
||
PermissionDecisionReason,
|
||
PermissionDenyDecision,
|
||
PermissionResult,
|
||
} from './PermissionResult.js'
|
||
import type {
|
||
PermissionBehavior,
|
||
PermissionRule,
|
||
PermissionRuleSource,
|
||
PermissionRuleValue,
|
||
} from './PermissionRule.js'
|
||
import {
|
||
applyPermissionUpdate,
|
||
applyPermissionUpdates,
|
||
persistPermissionUpdates,
|
||
} from './PermissionUpdate.js'
|
||
import type {
|
||
PermissionUpdate,
|
||
PermissionUpdateDestination,
|
||
} from './PermissionUpdateSchema.js'
|
||
import {
|
||
permissionRuleValueFromString,
|
||
permissionRuleValueToString,
|
||
} from './permissionRuleParser.js'
|
||
import {
|
||
deletePermissionRuleFromSettings,
|
||
type PermissionRuleFromEditableSettings,
|
||
shouldAllowManagedPermissionRulesOnly,
|
||
} from './permissionsLoader.js'
|
||
|
||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||
const classifierDecisionModule = feature('TRANSCRIPT_CLASSIFIER')
|
||
? (require('./classifierDecision.js') as typeof import('./classifierDecision.js'))
|
||
: null
|
||
const autoModeStateModule = feature('TRANSCRIPT_CLASSIFIER')
|
||
? (require('./autoModeState.js') as typeof import('./autoModeState.js'))
|
||
: null
|
||
|
||
import {
|
||
addToTurnClassifierDuration,
|
||
getTotalCacheCreationInputTokens,
|
||
getTotalCacheReadInputTokens,
|
||
getTotalInputTokens,
|
||
getTotalOutputTokens,
|
||
} from '../../bootstrap/state.js'
|
||
import { getFeatureValue_CACHED_WITH_REFRESH } from '../../services/analytics/growthbook.js'
|
||
import {
|
||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
logEvent,
|
||
} from '../../services/analytics/index.js'
|
||
import { sanitizeToolNameForAnalytics } from '../../services/analytics/metadata.js'
|
||
import {
|
||
clearClassifierChecking,
|
||
setClassifierChecking,
|
||
} from '../classifierApprovals.js'
|
||
import { isInProtectedNamespace } from '../envUtils.js'
|
||
import { executePermissionRequestHooks } from '../hooks.js'
|
||
import {
|
||
AUTO_REJECT_MESSAGE,
|
||
buildClassifierUnavailableMessage,
|
||
buildYoloRejectionMessage,
|
||
DONT_ASK_REJECT_MESSAGE,
|
||
} from '../messages.js'
|
||
import { calculateCostFromTokens } from '../modelCost.js'
|
||
/* eslint-enable @typescript-eslint/no-require-imports */
|
||
import { jsonStringify } from '../slowOperations.js'
|
||
import {
|
||
createDenialTrackingState,
|
||
DENIAL_LIMITS,
|
||
type DenialTrackingState,
|
||
recordDenial,
|
||
recordSuccess,
|
||
shouldFallbackToPrompting,
|
||
} from './denialTracking.js'
|
||
import {
|
||
classifyYoloAction,
|
||
formatActionForClassifier,
|
||
} from './yoloClassifier.js'
|
||
|
||
const CLASSIFIER_FAIL_CLOSED_REFRESH_MS = 30 * 60 * 1000 // 30 minutes
|
||
|
||
const PERMISSION_RULE_SOURCES = [
|
||
...SETTING_SOURCES,
|
||
'cliArg',
|
||
'command',
|
||
'session',
|
||
] as const satisfies readonly PermissionRuleSource[]
|
||
|
||
export function permissionRuleSourceDisplayString(
|
||
source: PermissionRuleSource,
|
||
): string {
|
||
return getSettingSourceDisplayNameLowercase(source)
|
||
}
|
||
|
||
export function getAllowRules(
|
||
context: ToolPermissionContext,
|
||
): PermissionRule[] {
|
||
return PERMISSION_RULE_SOURCES.flatMap(source =>
|
||
(context.alwaysAllowRules[source] || []).map(ruleString => ({
|
||
source,
|
||
ruleBehavior: 'allow',
|
||
ruleValue: permissionRuleValueFromString(ruleString),
|
||
})),
|
||
)
|
||
}
|
||
|
||
/**
|
||
* Creates a permission request message that explain the permission request
|
||
*/
|
||
export function createPermissionRequestMessage(
|
||
toolName: string,
|
||
decisionReason?: PermissionDecisionReason,
|
||
): string {
|
||
// Handle different decision reason types
|
||
if (decisionReason) {
|
||
if (
|
||
(feature('BASH_CLASSIFIER') || feature('TRANSCRIPT_CLASSIFIER')) &&
|
||
decisionReason.type === 'classifier'
|
||
) {
|
||
return `Classifier '${decisionReason.classifier}' requires approval for this ${toolName} command: ${decisionReason.reason}`
|
||
}
|
||
switch (decisionReason.type) {
|
||
case 'hook': {
|
||
const hookMessage = decisionReason.reason
|
||
? `Hook '${decisionReason.hookName}' blocked this action: ${decisionReason.reason}`
|
||
: `Hook '${decisionReason.hookName}' requires approval for this ${toolName} command`
|
||
return hookMessage
|
||
}
|
||
case 'rule': {
|
||
const ruleString = permissionRuleValueToString(
|
||
decisionReason.rule.ruleValue,
|
||
)
|
||
const sourceString = permissionRuleSourceDisplayString(
|
||
decisionReason.rule.source,
|
||
)
|
||
return `Permission rule '${ruleString}' from ${sourceString} requires approval for this ${toolName} command`
|
||
}
|
||
case 'subcommandResults': {
|
||
const needsApproval: string[] = []
|
||
for (const [cmd, result] of decisionReason.reasons) {
|
||
if (result.behavior === 'ask' || result.behavior === 'passthrough') {
|
||
// Strip output redirections for display to avoid showing filenames as commands
|
||
// Only do this for Bash tool to avoid affecting other tools
|
||
if (toolName === 'Bash') {
|
||
const { commandWithoutRedirections, redirections } =
|
||
extractOutputRedirections(cmd)
|
||
// Only use stripped version if there were actual redirections
|
||
const displayCmd =
|
||
redirections.length > 0 ? commandWithoutRedirections : cmd
|
||
needsApproval.push(displayCmd)
|
||
} else {
|
||
needsApproval.push(cmd)
|
||
}
|
||
}
|
||
}
|
||
if (needsApproval.length > 0) {
|
||
const n = needsApproval.length
|
||
return `This ${toolName} command contains multiple operations. The following ${plural(n, 'part')} ${plural(n, 'requires', 'require')} approval: ${needsApproval.join(', ')}`
|
||
}
|
||
return `This ${toolName} command contains multiple operations that require approval`
|
||
}
|
||
case 'permissionPromptTool':
|
||
return `Tool '${decisionReason.permissionPromptToolName}' requires approval for this ${toolName} command`
|
||
case 'sandboxOverride':
|
||
return 'Run outside of the sandbox'
|
||
case 'workingDir':
|
||
return decisionReason.reason
|
||
case 'safetyCheck':
|
||
case 'other':
|
||
return decisionReason.reason
|
||
case 'mode': {
|
||
const modeTitle = permissionModeTitle(decisionReason.mode)
|
||
return `Current permission mode (${modeTitle}) requires approval for this ${toolName} command`
|
||
}
|
||
case 'asyncAgent':
|
||
return decisionReason.reason
|
||
}
|
||
}
|
||
|
||
// Default message without listing allowed commands
|
||
const message = `Claude requested permissions to use ${toolName}, but you haven't granted it yet.`
|
||
|
||
return message
|
||
}
|
||
|
||
export function getDenyRules(context: ToolPermissionContext): PermissionRule[] {
|
||
return PERMISSION_RULE_SOURCES.flatMap(source =>
|
||
(context.alwaysDenyRules[source] || []).map(ruleString => ({
|
||
source,
|
||
ruleBehavior: 'deny',
|
||
ruleValue: permissionRuleValueFromString(ruleString),
|
||
})),
|
||
)
|
||
}
|
||
|
||
export function getAskRules(context: ToolPermissionContext): PermissionRule[] {
|
||
return PERMISSION_RULE_SOURCES.flatMap(source =>
|
||
(context.alwaysAskRules[source] || []).map(ruleString => ({
|
||
source,
|
||
ruleBehavior: 'ask',
|
||
ruleValue: permissionRuleValueFromString(ruleString),
|
||
})),
|
||
)
|
||
}
|
||
|
||
/**
|
||
* Check if the entire tool matches a rule
|
||
* For example, this matches "Bash" but not "Bash(prefix:*)" for BashTool
|
||
* This also matches MCP tools with a server name, e.g. the rule "mcp__server1"
|
||
*/
|
||
function toolMatchesRule(
|
||
tool: Pick<Tool, 'name' | 'mcpInfo'>,
|
||
rule: PermissionRule,
|
||
): boolean {
|
||
// Rule must not have content to match the entire tool
|
||
if (rule.ruleValue.ruleContent !== undefined) {
|
||
return false
|
||
}
|
||
|
||
// MCP tools are matched by their fully qualified mcp__server__tool name. In
|
||
// skip-prefix mode (CLAUDE_AGENT_SDK_MCP_NO_PREFIX), MCP tools have unprefixed
|
||
// display names (e.g., "Write") that collide with builtin names; rules targeting
|
||
// builtins should not match their MCP replacements.
|
||
const nameForRuleMatch = getToolNameForPermissionCheck(tool)
|
||
|
||
// Direct tool name match
|
||
if (rule.ruleValue.toolName === nameForRuleMatch) {
|
||
return true
|
||
}
|
||
|
||
// MCP server-level permission: rule "mcp__server1" matches tool "mcp__server1__tool1"
|
||
// Also supports wildcard: rule "mcp__server1__*" matches all tools from server1
|
||
const ruleInfo = mcpInfoFromString(rule.ruleValue.toolName)
|
||
const toolInfo = mcpInfoFromString(nameForRuleMatch)
|
||
|
||
return (
|
||
ruleInfo !== null &&
|
||
toolInfo !== null &&
|
||
(ruleInfo.toolName === undefined || ruleInfo.toolName === '*') &&
|
||
ruleInfo.serverName === toolInfo.serverName
|
||
)
|
||
}
|
||
|
||
/**
|
||
* Check if the entire tool is listed in the always allow rules
|
||
* For example, this finds "Bash" but not "Bash(prefix:*)" for BashTool
|
||
*/
|
||
export function toolAlwaysAllowedRule(
|
||
context: ToolPermissionContext,
|
||
tool: Pick<Tool, 'name' | 'mcpInfo'>,
|
||
): PermissionRule | null {
|
||
return (
|
||
getAllowRules(context).find(rule => toolMatchesRule(tool, rule)) || null
|
||
)
|
||
}
|
||
|
||
/**
|
||
* Check if the tool is listed in the always deny rules
|
||
*/
|
||
export function getDenyRuleForTool(
|
||
context: ToolPermissionContext,
|
||
tool: Pick<Tool, 'name' | 'mcpInfo'>,
|
||
): PermissionRule | null {
|
||
return getDenyRules(context).find(rule => toolMatchesRule(tool, rule)) || null
|
||
}
|
||
|
||
/**
|
||
* Check if the tool is listed in the always ask rules
|
||
*/
|
||
export function getAskRuleForTool(
|
||
context: ToolPermissionContext,
|
||
tool: Pick<Tool, 'name' | 'mcpInfo'>,
|
||
): PermissionRule | null {
|
||
return getAskRules(context).find(rule => toolMatchesRule(tool, rule)) || null
|
||
}
|
||
|
||
/**
|
||
* Check if a specific agent is denied via Agent(agentType) syntax.
|
||
* For example, Agent(Explore) would deny the Explore agent.
|
||
*/
|
||
export function getDenyRuleForAgent(
|
||
context: ToolPermissionContext,
|
||
agentToolName: string,
|
||
agentType: string,
|
||
): PermissionRule | null {
|
||
return (
|
||
getDenyRules(context).find(
|
||
rule =>
|
||
rule.ruleValue.toolName === agentToolName &&
|
||
rule.ruleValue.ruleContent === agentType,
|
||
) || null
|
||
)
|
||
}
|
||
|
||
/**
|
||
* Filter agents to exclude those that are denied via Agent(agentType) syntax.
|
||
*/
|
||
export function filterDeniedAgents<T extends { agentType: string }>(
|
||
agents: T[],
|
||
context: ToolPermissionContext,
|
||
agentToolName: string,
|
||
): T[] {
|
||
// Parse deny rules once and collect Agent(x) contents into a Set.
|
||
// Previously this called getDenyRuleForAgent per agent, which re-parsed
|
||
// every deny rule for every agent (O(agents×rules) parse calls).
|
||
const deniedAgentTypes = new Set<string>()
|
||
for (const rule of getDenyRules(context)) {
|
||
if (
|
||
rule.ruleValue.toolName === agentToolName &&
|
||
rule.ruleValue.ruleContent !== undefined
|
||
) {
|
||
deniedAgentTypes.add(rule.ruleValue.ruleContent)
|
||
}
|
||
}
|
||
return agents.filter(agent => !deniedAgentTypes.has(agent.agentType))
|
||
}
|
||
|
||
/**
|
||
* Map of rule contents to the associated rule for a given tool.
|
||
* e.g. the string key is "prefix:*" from "Bash(prefix:*)" for BashTool
|
||
*/
|
||
export function getRuleByContentsForTool(
|
||
context: ToolPermissionContext,
|
||
tool: Tool,
|
||
behavior: PermissionBehavior,
|
||
): Map<string, PermissionRule> {
|
||
return getRuleByContentsForToolName(
|
||
context,
|
||
getToolNameForPermissionCheck(tool),
|
||
behavior,
|
||
)
|
||
}
|
||
|
||
// Used to break circular dependency where a Tool calls this function
|
||
export function getRuleByContentsForToolName(
|
||
context: ToolPermissionContext,
|
||
toolName: string,
|
||
behavior: PermissionBehavior,
|
||
): Map<string, PermissionRule> {
|
||
const ruleByContents = new Map<string, PermissionRule>()
|
||
let rules: PermissionRule[] = []
|
||
switch (behavior) {
|
||
case 'allow':
|
||
rules = getAllowRules(context)
|
||
break
|
||
case 'deny':
|
||
rules = getDenyRules(context)
|
||
break
|
||
case 'ask':
|
||
rules = getAskRules(context)
|
||
break
|
||
}
|
||
for (const rule of rules) {
|
||
if (
|
||
rule.ruleValue.toolName === toolName &&
|
||
rule.ruleValue.ruleContent !== undefined &&
|
||
rule.ruleBehavior === behavior
|
||
) {
|
||
ruleByContents.set(rule.ruleValue.ruleContent, rule)
|
||
}
|
||
}
|
||
return ruleByContents
|
||
}
|
||
|
||
/**
|
||
* Runs PermissionRequest hooks for headless/async agents that cannot show
|
||
* permission prompts. This gives hooks an opportunity to allow or deny
|
||
* tool use before the fallback auto-deny kicks in.
|
||
*
|
||
* Returns a PermissionDecision if a hook made a decision, or null if no
|
||
* hook provided a decision (caller should proceed to auto-deny).
|
||
*/
|
||
async function runPermissionRequestHooksForHeadlessAgent(
|
||
tool: Tool,
|
||
input: { [key: string]: unknown },
|
||
toolUseID: string,
|
||
context: ToolUseContext,
|
||
permissionMode: string | undefined,
|
||
suggestions: PermissionUpdate[] | undefined,
|
||
): Promise<PermissionDecision | null> {
|
||
try {
|
||
for await (const hookResult of executePermissionRequestHooks(
|
||
tool.name,
|
||
toolUseID,
|
||
input,
|
||
context,
|
||
permissionMode,
|
||
suggestions as any,
|
||
context.abortController.signal,
|
||
)) {
|
||
if (!hookResult.permissionRequestResult) {
|
||
continue
|
||
}
|
||
const decision = hookResult.permissionRequestResult
|
||
if (decision.behavior === 'allow') {
|
||
const finalInput = decision.updatedInput ?? input
|
||
// Persist permission updates if provided
|
||
if (decision.updatedPermissions?.length) {
|
||
persistPermissionUpdates(decision.updatedPermissions as any)
|
||
context.setAppState(prev => ({
|
||
...prev,
|
||
toolPermissionContext: applyPermissionUpdates(
|
||
prev.toolPermissionContext,
|
||
decision.updatedPermissions as any,
|
||
),
|
||
}))
|
||
}
|
||
return {
|
||
behavior: 'allow',
|
||
updatedInput: finalInput,
|
||
decisionReason: {
|
||
type: 'hook',
|
||
hookName: 'PermissionRequest',
|
||
},
|
||
}
|
||
}
|
||
if (decision.behavior === 'deny') {
|
||
if (decision.interrupt) {
|
||
logForDebugging(
|
||
`Hook interrupt: tool=${tool.name} hookMessage=${decision.message}`,
|
||
)
|
||
context.abortController.abort()
|
||
}
|
||
return {
|
||
behavior: 'deny',
|
||
message: decision.message || 'Permission denied by hook',
|
||
decisionReason: {
|
||
type: 'hook',
|
||
hookName: 'PermissionRequest',
|
||
reason: decision.message,
|
||
},
|
||
}
|
||
}
|
||
}
|
||
} catch (error) {
|
||
// If hooks fail, fall through to auto-deny rather than crashing
|
||
logError(
|
||
new Error('PermissionRequest hook failed for headless agent', {
|
||
cause: toError(error),
|
||
}),
|
||
)
|
||
}
|
||
return null
|
||
}
|
||
|
||
export const hasPermissionsToUseTool: CanUseToolFn = async (
|
||
tool,
|
||
input,
|
||
context,
|
||
assistantMessage,
|
||
toolUseID,
|
||
): Promise<PermissionDecision> => {
|
||
const result = await hasPermissionsToUseToolInner(tool, input, context)
|
||
|
||
|
||
// Reset consecutive denials on any allowed tool use in auto mode.
|
||
// This ensures that a successful tool use (even one auto-allowed by rules)
|
||
// breaks the consecutive denial streak.
|
||
if (result.behavior === 'allow') {
|
||
const appState = context.getAppState()
|
||
if (feature('TRANSCRIPT_CLASSIFIER')) {
|
||
const currentDenialState =
|
||
context.localDenialTracking ?? appState.denialTracking
|
||
if (
|
||
appState.toolPermissionContext.mode === 'auto' &&
|
||
currentDenialState &&
|
||
currentDenialState.consecutiveDenials > 0
|
||
) {
|
||
const newDenialState = recordSuccess(currentDenialState)
|
||
persistDenialState(context, newDenialState)
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
|
||
// Apply dontAsk mode transformation: convert 'ask' to 'deny'
|
||
// This is done at the end so it can't be bypassed by early returns
|
||
if (result.behavior === 'ask') {
|
||
const appState = context.getAppState()
|
||
|
||
if (appState.toolPermissionContext.mode === 'dontAsk') {
|
||
return {
|
||
behavior: 'deny',
|
||
decisionReason: {
|
||
type: 'mode',
|
||
mode: 'dontAsk',
|
||
},
|
||
message: DONT_ASK_REJECT_MESSAGE(tool.name),
|
||
}
|
||
}
|
||
// Apply auto mode: use AI classifier instead of prompting user
|
||
// Check this BEFORE shouldAvoidPermissionPrompts so classifiers work in headless mode
|
||
if (
|
||
feature('TRANSCRIPT_CLASSIFIER') &&
|
||
(appState.toolPermissionContext.mode === 'auto' ||
|
||
(appState.toolPermissionContext.mode === 'plan' &&
|
||
(autoModeStateModule?.isAutoModeActive() ?? false)))
|
||
) {
|
||
// Non-classifier-approvable safetyCheck decisions stay immune to ALL
|
||
// auto-approve paths: the acceptEdits fast-path, the safe-tool allowlist,
|
||
// and the classifier. Step 1g only guards bypassPermissions; this guards
|
||
// auto. classifierApprovable safetyChecks (sensitive-file paths) fall
|
||
// through to the classifier — the fast-paths below naturally don't fire
|
||
// because the tool's own checkPermissions still returns 'ask'.
|
||
if (
|
||
result.decisionReason?.type === 'safetyCheck' &&
|
||
!result.decisionReason.classifierApprovable
|
||
) {
|
||
if (appState.toolPermissionContext.shouldAvoidPermissionPrompts) {
|
||
return {
|
||
behavior: 'deny',
|
||
message: result.message,
|
||
decisionReason: {
|
||
type: 'asyncAgent',
|
||
reason:
|
||
'Safety check requires interactive approval and permission prompts are not available in this context',
|
||
},
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
if (tool.requiresUserInteraction?.() && result.behavior === 'ask') {
|
||
return result
|
||
}
|
||
|
||
// Use local denial tracking for async subagents (whose setAppState
|
||
// is a no-op), otherwise read from appState as before.
|
||
const denialState =
|
||
context.localDenialTracking ??
|
||
appState.denialTracking ??
|
||
createDenialTrackingState()
|
||
|
||
// PowerShell requires explicit user permission in auto mode unless
|
||
// POWERSHELL_AUTO_MODE (ant-only build flag) is on. When disabled, this
|
||
// guard keeps PS out of the classifier and skips the acceptEdits
|
||
// fast-path below. When enabled, PS flows through to the classifier like
|
||
// Bash — the classifier prompt gets POWERSHELL_DENY_GUIDANCE appended so
|
||
// it recognizes `iex (iwr ...)` as download-and-execute, etc.
|
||
// Note: this runs inside the behavior === 'ask' branch, so allow rules
|
||
// that fire earlier (step 2b toolAlwaysAllowedRule, PS prefix allow)
|
||
// return before reaching here. Allow-rule protection is handled by
|
||
// permissionSetup.ts: isOverlyBroadPowerShellAllowRule strips PowerShell(*)
|
||
// and isDangerousPowerShellPermission strips iex/pwsh/Start-Process
|
||
// prefix rules for ant users and auto mode entry.
|
||
if (
|
||
tool.name === POWERSHELL_TOOL_NAME &&
|
||
!feature('POWERSHELL_AUTO_MODE')
|
||
) {
|
||
if (appState.toolPermissionContext.shouldAvoidPermissionPrompts) {
|
||
return {
|
||
behavior: 'deny',
|
||
message: 'PowerShell tool requires interactive approval',
|
||
decisionReason: {
|
||
type: 'asyncAgent',
|
||
reason:
|
||
'PowerShell tool requires interactive approval and permission prompts are not available in this context',
|
||
},
|
||
}
|
||
}
|
||
logForDebugging(
|
||
`Skipping auto mode classifier for ${tool.name}: tool requires explicit user permission`,
|
||
)
|
||
return result
|
||
}
|
||
|
||
// Before running the auto mode classifier, check if acceptEdits mode would
|
||
// allow this action. This avoids expensive classifier API calls for safe
|
||
// operations like file edits in the working directory.
|
||
// Skip for Agent and REPL — their checkPermissions returns 'allow' for
|
||
// acceptEdits mode, which would silently bypass the classifier. REPL
|
||
// code can contain VM escapes between inner tool calls; the classifier
|
||
// must see the glue JavaScript, not just the inner tool calls.
|
||
if (
|
||
result.behavior === 'ask' &&
|
||
tool.name !== AGENT_TOOL_NAME &&
|
||
tool.name !== REPL_TOOL_NAME
|
||
) {
|
||
try {
|
||
const parsedInput = tool.inputSchema.parse(input)
|
||
const acceptEditsResult = await tool.checkPermissions(parsedInput, {
|
||
...context,
|
||
getAppState: () => {
|
||
const state = context.getAppState()
|
||
return {
|
||
...state,
|
||
toolPermissionContext: {
|
||
...state.toolPermissionContext,
|
||
mode: 'acceptEdits' as const,
|
||
},
|
||
}
|
||
},
|
||
})
|
||
if (acceptEditsResult.behavior === 'allow') {
|
||
const newDenialState = recordSuccess(denialState)
|
||
persistDenialState(context, newDenialState)
|
||
logForDebugging(
|
||
`Skipping auto mode classifier for ${tool.name}: would be allowed in acceptEdits mode`,
|
||
)
|
||
logEvent('tengu_auto_mode_decision', {
|
||
decision:
|
||
'allowed' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
toolName: sanitizeToolNameForAnalytics(tool.name),
|
||
inProtectedNamespace: isInProtectedNamespace(),
|
||
// msg_id of the agent completion that produced this tool_use —
|
||
// the action at the bottom of the classifier transcript. Joins
|
||
// the decision back to the main agent's API response.
|
||
agentMsgId: assistantMessage.message
|
||
.id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
confidence:
|
||
'high' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
fastPath:
|
||
'acceptEdits' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
})
|
||
return {
|
||
behavior: 'allow',
|
||
updatedInput: acceptEditsResult.updatedInput ?? input,
|
||
decisionReason: {
|
||
type: 'mode',
|
||
mode: 'auto',
|
||
},
|
||
}
|
||
}
|
||
} catch (e) {
|
||
if (e instanceof AbortError || e instanceof APIUserAbortError) {
|
||
throw e
|
||
}
|
||
// If the acceptEdits check fails, fall through to the classifier
|
||
}
|
||
}
|
||
|
||
// Allowlisted tools are safe and don't need YOLO classification.
|
||
// This uses the safe-tool allowlist to skip unnecessary classifier API calls.
|
||
if (classifierDecisionModule!.isAutoModeAllowlistedTool(tool.name)) {
|
||
const newDenialState = recordSuccess(denialState)
|
||
persistDenialState(context, newDenialState)
|
||
logForDebugging(
|
||
`Skipping auto mode classifier for ${tool.name}: tool is on the safe allowlist`,
|
||
)
|
||
logEvent('tengu_auto_mode_decision', {
|
||
decision:
|
||
'allowed' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
toolName: sanitizeToolNameForAnalytics(tool.name),
|
||
inProtectedNamespace: isInProtectedNamespace(),
|
||
agentMsgId: assistantMessage.message
|
||
.id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
confidence:
|
||
'high' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
fastPath:
|
||
'allowlist' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
})
|
||
return {
|
||
behavior: 'allow',
|
||
updatedInput: input,
|
||
decisionReason: {
|
||
type: 'mode',
|
||
mode: 'auto',
|
||
},
|
||
}
|
||
}
|
||
|
||
// Run the auto mode classifier
|
||
const action = formatActionForClassifier(tool.name, input)
|
||
setClassifierChecking(toolUseID)
|
||
let classifierResult
|
||
try {
|
||
classifierResult = await classifyYoloAction(
|
||
context.messages,
|
||
action,
|
||
context.options.tools,
|
||
appState.toolPermissionContext,
|
||
context.abortController.signal,
|
||
)
|
||
} finally {
|
||
clearClassifierChecking(toolUseID)
|
||
}
|
||
|
||
// Notify ants when classifier error dumped prompts (will be in /share)
|
||
if (
|
||
process.env.USER_TYPE === 'ant' &&
|
||
classifierResult.errorDumpPath &&
|
||
context.addNotification
|
||
) {
|
||
context.addNotification({
|
||
key: 'auto-mode-error-dump',
|
||
text: `Auto mode classifier error — prompts dumped to ${classifierResult.errorDumpPath} (included in /share)`,
|
||
priority: 'immediate',
|
||
color: 'error',
|
||
})
|
||
}
|
||
|
||
// Log classifier decision for metrics (including overhead telemetry)
|
||
const yoloDecision = classifierResult.unavailable
|
||
? 'unavailable'
|
||
: classifierResult.shouldBlock
|
||
? 'blocked'
|
||
: 'allowed'
|
||
|
||
// Compute classifier cost in USD for overhead analysis
|
||
const classifierCostUSD =
|
||
classifierResult.usage && classifierResult.model
|
||
? calculateCostFromTokens(
|
||
classifierResult.model,
|
||
classifierResult.usage,
|
||
)
|
||
: undefined
|
||
logEvent('tengu_auto_mode_decision', {
|
||
decision:
|
||
yoloDecision as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
toolName: sanitizeToolNameForAnalytics(tool.name),
|
||
inProtectedNamespace: isInProtectedNamespace(),
|
||
// msg_id of the agent completion that produced this tool_use —
|
||
// the action at the bottom of the classifier transcript.
|
||
agentMsgId: assistantMessage.message
|
||
.id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
classifierModel:
|
||
classifierResult.model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
consecutiveDenials: classifierResult.shouldBlock
|
||
? denialState.consecutiveDenials + 1
|
||
: 0,
|
||
totalDenials: classifierResult.shouldBlock
|
||
? denialState.totalDenials + 1
|
||
: denialState.totalDenials,
|
||
// Overhead telemetry: token usage and latency for the classifier API call
|
||
classifierInputTokens: classifierResult.usage?.inputTokens,
|
||
classifierOutputTokens: classifierResult.usage?.outputTokens,
|
||
classifierCacheReadInputTokens:
|
||
classifierResult.usage?.cacheReadInputTokens,
|
||
classifierCacheCreationInputTokens:
|
||
classifierResult.usage?.cacheCreationInputTokens,
|
||
classifierDurationMs: classifierResult.durationMs,
|
||
// Character lengths of the prompt components sent to the classifier
|
||
classifierSystemPromptLength:
|
||
classifierResult.promptLengths?.systemPrompt,
|
||
classifierToolCallsLength: classifierResult.promptLengths?.toolCalls,
|
||
classifierUserPromptsLength:
|
||
classifierResult.promptLengths?.userPrompts,
|
||
// Session totals at time of classifier call (for computing overhead %).
|
||
// These are main-transcript-only — sideQuery (used by the classifier)
|
||
// does NOT call addToTotalSessionCost, so classifier tokens are excluded.
|
||
sessionInputTokens: getTotalInputTokens(),
|
||
sessionOutputTokens: getTotalOutputTokens(),
|
||
sessionCacheReadInputTokens: getTotalCacheReadInputTokens(),
|
||
sessionCacheCreationInputTokens: getTotalCacheCreationInputTokens(),
|
||
classifierCostUSD,
|
||
classifierStage:
|
||
classifierResult.stage as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
classifierStage1InputTokens: classifierResult.stage1Usage?.inputTokens,
|
||
classifierStage1OutputTokens:
|
||
classifierResult.stage1Usage?.outputTokens,
|
||
classifierStage1CacheReadInputTokens:
|
||
classifierResult.stage1Usage?.cacheReadInputTokens,
|
||
classifierStage1CacheCreationInputTokens:
|
||
classifierResult.stage1Usage?.cacheCreationInputTokens,
|
||
classifierStage1DurationMs: classifierResult.stage1DurationMs,
|
||
classifierStage1RequestId:
|
||
classifierResult.stage1RequestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
classifierStage1MsgId:
|
||
classifierResult.stage1MsgId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
classifierStage1CostUSD:
|
||
classifierResult.stage1Usage && classifierResult.model
|
||
? calculateCostFromTokens(
|
||
classifierResult.model,
|
||
classifierResult.stage1Usage,
|
||
)
|
||
: undefined,
|
||
classifierStage2InputTokens: classifierResult.stage2Usage?.inputTokens,
|
||
classifierStage2OutputTokens:
|
||
classifierResult.stage2Usage?.outputTokens,
|
||
classifierStage2CacheReadInputTokens:
|
||
classifierResult.stage2Usage?.cacheReadInputTokens,
|
||
classifierStage2CacheCreationInputTokens:
|
||
classifierResult.stage2Usage?.cacheCreationInputTokens,
|
||
classifierStage2DurationMs: classifierResult.stage2DurationMs,
|
||
classifierStage2RequestId:
|
||
classifierResult.stage2RequestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
classifierStage2MsgId:
|
||
classifierResult.stage2MsgId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
classifierStage2CostUSD:
|
||
classifierResult.stage2Usage && classifierResult.model
|
||
? calculateCostFromTokens(
|
||
classifierResult.model,
|
||
classifierResult.stage2Usage,
|
||
)
|
||
: undefined,
|
||
})
|
||
|
||
if (classifierResult.durationMs !== undefined) {
|
||
addToTurnClassifierDuration(classifierResult.durationMs)
|
||
}
|
||
|
||
if (classifierResult.shouldBlock) {
|
||
// Transcript exceeded the classifier's context window — deterministic
|
||
// error, won't recover on retry. Skip iron_gate and fall back to
|
||
// normal prompting so the user can approve/deny manually.
|
||
if (classifierResult.transcriptTooLong) {
|
||
if (appState.toolPermissionContext.shouldAvoidPermissionPrompts) {
|
||
// Permanent condition (transcript only grows) — deny-retry-deny
|
||
// wastes tokens without ever hitting the denial-limit abort.
|
||
throw new AbortError(
|
||
'Agent aborted: auto mode classifier transcript exceeded context window in headless mode',
|
||
)
|
||
}
|
||
logForDebugging(
|
||
'Auto mode classifier transcript too long, falling back to normal permission handling',
|
||
{ level: 'warn' },
|
||
)
|
||
return {
|
||
...result,
|
||
decisionReason: {
|
||
type: 'other',
|
||
reason:
|
||
'Auto mode classifier transcript exceeded context window — falling back to manual approval',
|
||
},
|
||
}
|
||
}
|
||
// When classifier is unavailable (API error), behavior depends on
|
||
// the tengu_iron_gate_closed gate.
|
||
if (classifierResult.unavailable) {
|
||
if (
|
||
getFeatureValue_CACHED_WITH_REFRESH(
|
||
'tengu_iron_gate_closed',
|
||
true,
|
||
CLASSIFIER_FAIL_CLOSED_REFRESH_MS,
|
||
)
|
||
) {
|
||
logForDebugging(
|
||
'Auto mode classifier unavailable, denying with retry guidance (fail closed)',
|
||
{ level: 'warn' },
|
||
)
|
||
return {
|
||
behavior: 'deny',
|
||
decisionReason: {
|
||
type: 'classifier',
|
||
classifier: 'auto-mode',
|
||
reason: 'Classifier unavailable',
|
||
},
|
||
message: buildClassifierUnavailableMessage(
|
||
tool.name,
|
||
classifierResult.model,
|
||
),
|
||
}
|
||
}
|
||
// Fail open: fall back to normal permission handling
|
||
logForDebugging(
|
||
'Auto mode classifier unavailable, falling back to normal permission handling (fail open)',
|
||
{ level: 'warn' },
|
||
)
|
||
return result
|
||
}
|
||
|
||
// Update denial tracking and check limits
|
||
const newDenialState = recordDenial(denialState)
|
||
persistDenialState(context, newDenialState)
|
||
|
||
logForDebugging(
|
||
`Auto mode classifier blocked action: ${classifierResult.reason}`,
|
||
{ level: 'warn' },
|
||
)
|
||
|
||
// If denial limit hit, fall back to prompting so the user
|
||
// can review. We check after the classifier so we can include
|
||
// its reason in the prompt.
|
||
const denialLimitResult = handleDenialLimitExceeded(
|
||
newDenialState,
|
||
appState,
|
||
classifierResult.reason,
|
||
assistantMessage,
|
||
tool,
|
||
result,
|
||
context,
|
||
)
|
||
if (denialLimitResult) {
|
||
return denialLimitResult
|
||
}
|
||
|
||
return {
|
||
behavior: 'deny',
|
||
decisionReason: {
|
||
type: 'classifier',
|
||
classifier: 'auto-mode',
|
||
reason: classifierResult.reason,
|
||
},
|
||
message: buildYoloRejectionMessage(classifierResult.reason),
|
||
}
|
||
}
|
||
|
||
// Reset consecutive denials on success
|
||
const newDenialState = recordSuccess(denialState)
|
||
persistDenialState(context, newDenialState)
|
||
|
||
return {
|
||
behavior: 'allow',
|
||
updatedInput: input,
|
||
decisionReason: {
|
||
type: 'classifier',
|
||
classifier: 'auto-mode',
|
||
reason: classifierResult.reason,
|
||
},
|
||
}
|
||
}
|
||
|
||
// When permission prompts should be avoided (e.g., background/headless agents),
|
||
// run PermissionRequest hooks first to give them a chance to allow/deny.
|
||
// Only auto-deny if no hook provides a decision.
|
||
if (appState.toolPermissionContext.shouldAvoidPermissionPrompts) {
|
||
const hookDecision = await runPermissionRequestHooksForHeadlessAgent(
|
||
tool,
|
||
input,
|
||
toolUseID,
|
||
context,
|
||
appState.toolPermissionContext.mode,
|
||
result.suggestions,
|
||
)
|
||
if (hookDecision) {
|
||
return hookDecision
|
||
}
|
||
return {
|
||
behavior: 'deny',
|
||
decisionReason: {
|
||
type: 'asyncAgent',
|
||
reason: 'Permission prompts are not available in this context',
|
||
},
|
||
message: AUTO_REJECT_MESSAGE(tool.name),
|
||
}
|
||
}
|
||
}
|
||
|
||
return result
|
||
}
|
||
|
||
/**
|
||
* Persist denial tracking state. For async subagents with localDenialTracking,
|
||
* mutate the local state in place (since setAppState is a no-op). Otherwise,
|
||
* write to appState as usual.
|
||
*/
|
||
function persistDenialState(
|
||
context: ToolUseContext,
|
||
newState: DenialTrackingState,
|
||
): void {
|
||
if (context.localDenialTracking) {
|
||
Object.assign(context.localDenialTracking, newState)
|
||
} else {
|
||
context.setAppState(prev => {
|
||
// recordSuccess returns the same reference when state is
|
||
// unchanged. Returning prev here lets store.setState's Object.is check
|
||
// skip the listener loop entirely.
|
||
if (prev.denialTracking === newState) return prev
|
||
return { ...prev, denialTracking: newState }
|
||
})
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Check if a denial limit was exceeded and return an 'ask' result
|
||
* so the user can review. Returns null if no limit was hit.
|
||
*/
|
||
function handleDenialLimitExceeded(
|
||
denialState: DenialTrackingState,
|
||
appState: {
|
||
toolPermissionContext: { shouldAvoidPermissionPrompts?: boolean }
|
||
},
|
||
classifierReason: string,
|
||
assistantMessage: AssistantMessage,
|
||
tool: Tool,
|
||
result: PermissionDecision,
|
||
context: ToolUseContext,
|
||
): PermissionDecision | null {
|
||
if (!shouldFallbackToPrompting(denialState)) {
|
||
return null
|
||
}
|
||
|
||
const hitTotalLimit = denialState.totalDenials >= DENIAL_LIMITS.maxTotal
|
||
const isHeadless = appState.toolPermissionContext.shouldAvoidPermissionPrompts
|
||
// Capture counts before persistDenialState, which may mutate denialState
|
||
// in-place via Object.assign for subagents with localDenialTracking.
|
||
const totalCount = denialState.totalDenials
|
||
const consecutiveCount = denialState.consecutiveDenials
|
||
const warning = hitTotalLimit
|
||
? `${totalCount} actions were blocked this session. Please review the transcript before continuing.`
|
||
: `${consecutiveCount} consecutive actions were blocked. Please review the transcript before continuing.`
|
||
|
||
logEvent('tengu_auto_mode_denial_limit_exceeded', {
|
||
limit: (hitTotalLimit
|
||
? 'total'
|
||
: 'consecutive') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
mode: (isHeadless
|
||
? 'headless'
|
||
: 'cli') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
messageID: assistantMessage.message
|
||
.id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
consecutiveDenials: consecutiveCount,
|
||
totalDenials: totalCount,
|
||
toolName: sanitizeToolNameForAnalytics(tool.name),
|
||
})
|
||
|
||
if (isHeadless) {
|
||
throw new AbortError(
|
||
'Agent aborted: too many classifier denials in headless mode',
|
||
)
|
||
}
|
||
|
||
logForDebugging(
|
||
`Classifier denial limit exceeded, falling back to prompting: ${warning}`,
|
||
{ level: 'warn' },
|
||
)
|
||
|
||
if (hitTotalLimit) {
|
||
persistDenialState(context, {
|
||
...denialState,
|
||
totalDenials: 0,
|
||
consecutiveDenials: 0,
|
||
})
|
||
}
|
||
|
||
// Preserve the original classifier value (e.g. 'dangerous-agent-action')
|
||
// so downstream analytics in interactiveHandler can log the correct
|
||
// user override event.
|
||
const originalClassifier =
|
||
result.decisionReason?.type === 'classifier'
|
||
? result.decisionReason.classifier
|
||
: 'auto-mode'
|
||
|
||
return {
|
||
...result,
|
||
decisionReason: {
|
||
type: 'classifier',
|
||
classifier: originalClassifier,
|
||
reason: `${warning}\n\nLatest blocked action: ${classifierReason}`,
|
||
},
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Check only the rule-based steps of the permission pipeline — the subset
|
||
* that bypassPermissions mode respects (everything that fires before step 2a).
|
||
*
|
||
* Returns a deny/ask decision if a rule blocks the tool, or null if no rule
|
||
* objects. Unlike hasPermissionsToUseTool, this does NOT run the auto mode classifier,
|
||
* mode-based transformations (dontAsk/auto/asyncAgent), PermissionRequest hooks,
|
||
* or bypassPermissions / always-allowed checks.
|
||
*
|
||
* Caller must pre-check tool.requiresUserInteraction() — step 1e is not replicated.
|
||
*/
|
||
export async function checkRuleBasedPermissions(
|
||
tool: Tool,
|
||
input: { [key: string]: unknown },
|
||
context: ToolUseContext,
|
||
): Promise<PermissionAskDecision | PermissionDenyDecision | null> {
|
||
const appState = context.getAppState()
|
||
|
||
// 1a. Entire tool is denied by rule
|
||
const denyRule = getDenyRuleForTool(appState.toolPermissionContext, tool)
|
||
if (denyRule) {
|
||
return {
|
||
behavior: 'deny',
|
||
decisionReason: {
|
||
type: 'rule',
|
||
rule: denyRule,
|
||
},
|
||
message: `Permission to use ${tool.name} has been denied.`,
|
||
}
|
||
}
|
||
|
||
// 1b. Entire tool has an ask rule
|
||
const askRule = getAskRuleForTool(appState.toolPermissionContext, tool)
|
||
if (askRule) {
|
||
const canSandboxAutoAllow =
|
||
tool.name === BASH_TOOL_NAME &&
|
||
SandboxManager.isSandboxingEnabled() &&
|
||
SandboxManager.isAutoAllowBashIfSandboxedEnabled() &&
|
||
shouldUseSandbox(input)
|
||
|
||
if (!canSandboxAutoAllow) {
|
||
return {
|
||
behavior: 'ask',
|
||
decisionReason: {
|
||
type: 'rule',
|
||
rule: askRule,
|
||
},
|
||
message: createPermissionRequestMessage(tool.name),
|
||
}
|
||
}
|
||
// Fall through to let tool.checkPermissions handle command-specific rules
|
||
}
|
||
|
||
// 1c. Tool-specific permission check (e.g. bash subcommand rules)
|
||
let toolPermissionResult: PermissionResult = {
|
||
behavior: 'passthrough',
|
||
message: createPermissionRequestMessage(tool.name),
|
||
}
|
||
try {
|
||
const parsedInput = tool.inputSchema.parse(input)
|
||
toolPermissionResult = await tool.checkPermissions(parsedInput, context)
|
||
} catch (e) {
|
||
if (e instanceof AbortError || e instanceof APIUserAbortError) {
|
||
throw e
|
||
}
|
||
logError(e)
|
||
}
|
||
|
||
// 1d. Tool implementation denied (catches bash subcommand denies wrapped
|
||
// in subcommandResults — no need to inspect decisionReason.type)
|
||
if (toolPermissionResult?.behavior === 'deny') {
|
||
return toolPermissionResult
|
||
}
|
||
|
||
// 1f. Content-specific ask rules from tool.checkPermissions
|
||
// (e.g. Bash(npm publish:*) → {ask, type:'rule', ruleBehavior:'ask'})
|
||
if (
|
||
toolPermissionResult?.behavior === 'ask' &&
|
||
toolPermissionResult.decisionReason?.type === 'rule' &&
|
||
toolPermissionResult.decisionReason.rule.ruleBehavior === 'ask'
|
||
) {
|
||
return toolPermissionResult
|
||
}
|
||
|
||
// 1g. Safety checks (e.g. .git/, .claude/, .vscode/, shell configs) are
|
||
// bypass-immune — they must prompt even when a PreToolUse hook returned
|
||
// allow. checkPathSafetyForAutoEdit returns {type:'safetyCheck'} for these.
|
||
if (
|
||
toolPermissionResult?.behavior === 'ask' &&
|
||
toolPermissionResult.decisionReason?.type === 'safetyCheck'
|
||
) {
|
||
return toolPermissionResult
|
||
}
|
||
|
||
// No rule-based objection
|
||
return null
|
||
}
|
||
|
||
async function hasPermissionsToUseToolInner(
|
||
tool: Tool,
|
||
input: { [key: string]: unknown },
|
||
context: ToolUseContext,
|
||
): Promise<PermissionDecision> {
|
||
if (context.abortController.signal.aborted) {
|
||
throw new AbortError()
|
||
}
|
||
|
||
let appState = context.getAppState()
|
||
|
||
// 1. Check if the tool is denied
|
||
// 1a. Entire tool is denied
|
||
const denyRule = getDenyRuleForTool(appState.toolPermissionContext, tool)
|
||
if (denyRule) {
|
||
return {
|
||
behavior: 'deny',
|
||
decisionReason: {
|
||
type: 'rule',
|
||
rule: denyRule,
|
||
},
|
||
message: `Permission to use ${tool.name} has been denied.`,
|
||
}
|
||
}
|
||
|
||
// 1b. Check if the entire tool should always ask for permission
|
||
const askRule = getAskRuleForTool(appState.toolPermissionContext, tool)
|
||
if (askRule) {
|
||
// When autoAllowBashIfSandboxed is on, sandboxed commands skip the ask rule and
|
||
// auto-allow via Bash's checkPermissions. Commands that won't be sandboxed (excluded
|
||
// commands, dangerouslyDisableSandbox) still need to respect the ask rule.
|
||
const canSandboxAutoAllow =
|
||
tool.name === BASH_TOOL_NAME &&
|
||
SandboxManager.isSandboxingEnabled() &&
|
||
SandboxManager.isAutoAllowBashIfSandboxedEnabled() &&
|
||
shouldUseSandbox(input)
|
||
|
||
if (!canSandboxAutoAllow) {
|
||
return {
|
||
behavior: 'ask',
|
||
decisionReason: {
|
||
type: 'rule',
|
||
rule: askRule,
|
||
},
|
||
message: createPermissionRequestMessage(tool.name),
|
||
}
|
||
}
|
||
// Fall through to let Bash's checkPermissions handle command-specific rules
|
||
}
|
||
|
||
// 1c. Ask the tool implementation for a permission result
|
||
// Overridden unless tool input schema is not valid
|
||
let toolPermissionResult: PermissionResult = {
|
||
behavior: 'passthrough',
|
||
message: createPermissionRequestMessage(tool.name),
|
||
}
|
||
try {
|
||
const parsedInput = tool.inputSchema.parse(input)
|
||
toolPermissionResult = await tool.checkPermissions(parsedInput, context)
|
||
} catch (e) {
|
||
// Rethrow abort errors so they propagate properly
|
||
if (e instanceof AbortError || e instanceof APIUserAbortError) {
|
||
throw e
|
||
}
|
||
logError(e)
|
||
}
|
||
|
||
// 1d. Tool implementation denied permission
|
||
if (toolPermissionResult?.behavior === 'deny') {
|
||
return toolPermissionResult
|
||
}
|
||
|
||
// 1e. Tool requires user interaction even in bypass mode
|
||
if (
|
||
tool.requiresUserInteraction?.() &&
|
||
toolPermissionResult?.behavior === 'ask'
|
||
) {
|
||
return toolPermissionResult
|
||
}
|
||
|
||
// 1f. Content-specific ask rules from tool.checkPermissions take precedence
|
||
// over bypassPermissions mode. When a user explicitly configures a
|
||
// content-specific ask rule (e.g. Bash(npm publish:*)), the tool's
|
||
// checkPermissions returns {behavior:'ask', decisionReason:{type:'rule',
|
||
// rule:{ruleBehavior:'ask'}}}. This must be respected even in bypass mode,
|
||
// just as deny rules are respected at step 1d.
|
||
if (
|
||
toolPermissionResult?.behavior === 'ask' &&
|
||
toolPermissionResult.decisionReason?.type === 'rule' &&
|
||
toolPermissionResult.decisionReason.rule.ruleBehavior === 'ask'
|
||
) {
|
||
return toolPermissionResult
|
||
}
|
||
|
||
// 1g. Safety checks (e.g. .git/, .claude/, .vscode/, shell configs) are
|
||
// bypass-immune — they must prompt even in bypassPermissions mode.
|
||
// checkPathSafetyForAutoEdit returns {type:'safetyCheck'} for these paths.
|
||
if (
|
||
toolPermissionResult?.behavior === 'ask' &&
|
||
toolPermissionResult.decisionReason?.type === 'safetyCheck'
|
||
) {
|
||
return toolPermissionResult
|
||
}
|
||
|
||
// 2a. Check if mode allows the tool to run
|
||
// IMPORTANT: Call getAppState() to get the latest value
|
||
appState = context.getAppState()
|
||
// Check if permissions should be bypassed:
|
||
// - Direct bypassPermissions mode
|
||
// - Plan mode when the user originally started with bypass mode (isBypassPermissionsModeAvailable)
|
||
const shouldBypassPermissions =
|
||
appState.toolPermissionContext.mode === 'bypassPermissions' ||
|
||
(appState.toolPermissionContext.mode === 'plan' &&
|
||
appState.toolPermissionContext.isBypassPermissionsModeAvailable)
|
||
if (shouldBypassPermissions) {
|
||
return {
|
||
behavior: 'allow',
|
||
updatedInput: getUpdatedInputOrFallback(toolPermissionResult, input),
|
||
decisionReason: {
|
||
type: 'mode',
|
||
mode: appState.toolPermissionContext.mode,
|
||
},
|
||
}
|
||
}
|
||
|
||
// 2b. Entire tool is allowed
|
||
const alwaysAllowedRule = toolAlwaysAllowedRule(
|
||
appState.toolPermissionContext,
|
||
tool,
|
||
)
|
||
if (alwaysAllowedRule) {
|
||
return {
|
||
behavior: 'allow',
|
||
updatedInput: getUpdatedInputOrFallback(toolPermissionResult, input),
|
||
decisionReason: {
|
||
type: 'rule',
|
||
rule: alwaysAllowedRule,
|
||
},
|
||
}
|
||
}
|
||
|
||
// 3. Convert "passthrough" to "ask"
|
||
const result: PermissionDecision =
|
||
toolPermissionResult.behavior === 'passthrough'
|
||
? {
|
||
...toolPermissionResult,
|
||
behavior: 'ask' as const,
|
||
message: createPermissionRequestMessage(
|
||
tool.name,
|
||
toolPermissionResult.decisionReason,
|
||
),
|
||
}
|
||
: toolPermissionResult
|
||
|
||
if (result.behavior === 'ask' && result.suggestions) {
|
||
logForDebugging(
|
||
`Permission suggestions for ${tool.name}: ${jsonStringify(result.suggestions, null, 2)}`,
|
||
)
|
||
}
|
||
|
||
return result
|
||
}
|
||
|
||
type EditPermissionRuleArgs = {
|
||
initialContext: ToolPermissionContext
|
||
setToolPermissionContext: (updatedContext: ToolPermissionContext) => void
|
||
}
|
||
|
||
/**
|
||
* Delete a permission rule from the appropriate destination
|
||
*/
|
||
export async function deletePermissionRule({
|
||
rule,
|
||
initialContext,
|
||
setToolPermissionContext,
|
||
}: EditPermissionRuleArgs & { rule: PermissionRule }): Promise<void> {
|
||
if (
|
||
rule.source === 'policySettings' ||
|
||
rule.source === 'flagSettings' ||
|
||
rule.source === 'command'
|
||
) {
|
||
throw new Error('Cannot delete permission rules from read-only settings')
|
||
}
|
||
|
||
const updatedContext = applyPermissionUpdate(initialContext, {
|
||
type: 'removeRules',
|
||
rules: [rule.ruleValue],
|
||
behavior: rule.ruleBehavior,
|
||
destination: rule.source as PermissionUpdateDestination,
|
||
})
|
||
|
||
// Per-destination logic to delete the rule from settings
|
||
const destination = rule.source
|
||
switch (destination) {
|
||
case 'localSettings':
|
||
case 'userSettings':
|
||
case 'projectSettings': {
|
||
// Note: Typescript doesn't know that rule conforms to `PermissionRuleFromEditableSettings` even when we switch on `rule.source`
|
||
deletePermissionRuleFromSettings(
|
||
rule as PermissionRuleFromEditableSettings,
|
||
)
|
||
break
|
||
}
|
||
case 'cliArg':
|
||
case 'session': {
|
||
// No action needed for in-memory sources - not persisted to disk
|
||
break
|
||
}
|
||
}
|
||
|
||
// Update React state with updated context
|
||
setToolPermissionContext(updatedContext)
|
||
}
|
||
|
||
/**
|
||
* Helper to convert PermissionRule array to PermissionUpdate array
|
||
*/
|
||
function convertRulesToUpdates(
|
||
rules: PermissionRule[],
|
||
updateType: 'addRules' | 'replaceRules',
|
||
): PermissionUpdate[] {
|
||
// Group rules by source and behavior
|
||
const grouped = new Map<string, PermissionRuleValue[]>()
|
||
|
||
for (const rule of rules) {
|
||
const key = `${rule.source}:${rule.ruleBehavior}`
|
||
if (!grouped.has(key)) {
|
||
grouped.set(key, [])
|
||
}
|
||
grouped.get(key)!.push(rule.ruleValue)
|
||
}
|
||
|
||
// Convert to PermissionUpdate array
|
||
const updates: PermissionUpdate[] = []
|
||
for (const [key, ruleValues] of grouped) {
|
||
const [source, behavior] = key.split(':')
|
||
updates.push({
|
||
type: updateType,
|
||
rules: ruleValues,
|
||
behavior: behavior as PermissionBehavior,
|
||
destination: source as PermissionUpdateDestination,
|
||
})
|
||
}
|
||
|
||
return updates
|
||
}
|
||
|
||
/**
|
||
* Apply permission rules to context (additive - for initial setup)
|
||
*/
|
||
export function applyPermissionRulesToPermissionContext(
|
||
toolPermissionContext: ToolPermissionContext,
|
||
rules: PermissionRule[],
|
||
): ToolPermissionContext {
|
||
const updates = convertRulesToUpdates(rules, 'addRules')
|
||
return applyPermissionUpdates(toolPermissionContext, updates)
|
||
}
|
||
|
||
/**
|
||
* Sync permission rules from disk (replacement - for settings changes)
|
||
*/
|
||
export function syncPermissionRulesFromDisk(
|
||
toolPermissionContext: ToolPermissionContext,
|
||
rules: PermissionRule[],
|
||
): ToolPermissionContext {
|
||
let context = toolPermissionContext
|
||
|
||
// When allowManagedPermissionRulesOnly is enabled, clear all non-policy sources
|
||
if (shouldAllowManagedPermissionRulesOnly()) {
|
||
const sourcesToClear: PermissionUpdateDestination[] = [
|
||
'userSettings',
|
||
'projectSettings',
|
||
'localSettings',
|
||
'cliArg',
|
||
'session',
|
||
]
|
||
const behaviors: PermissionBehavior[] = ['allow', 'deny', 'ask']
|
||
|
||
for (const source of sourcesToClear) {
|
||
for (const behavior of behaviors) {
|
||
context = applyPermissionUpdate(context, {
|
||
type: 'replaceRules',
|
||
rules: [],
|
||
behavior,
|
||
destination: source,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
// Clear all disk-based source:behavior combos before applying new rules.
|
||
// Without this, removing a rule from settings (e.g. deleting a deny entry)
|
||
// would leave the old rule in the context because convertRulesToUpdates
|
||
// only generates replaceRules for source:behavior pairs that have rules —
|
||
// an empty group produces no update, so stale rules persist.
|
||
const diskSources: PermissionUpdateDestination[] = [
|
||
'userSettings',
|
||
'projectSettings',
|
||
'localSettings',
|
||
]
|
||
for (const diskSource of diskSources) {
|
||
for (const behavior of ['allow', 'deny', 'ask'] as PermissionBehavior[]) {
|
||
context = applyPermissionUpdate(context, {
|
||
type: 'replaceRules',
|
||
rules: [],
|
||
behavior,
|
||
destination: diskSource,
|
||
})
|
||
}
|
||
}
|
||
|
||
const updates = convertRulesToUpdates(rules, 'replaceRules')
|
||
return applyPermissionUpdates(context, updates)
|
||
}
|
||
|
||
/**
|
||
* Extract updatedInput from a permission result, falling back to the original input.
|
||
* Handles the case where some PermissionResult variants don't have updatedInput.
|
||
*/
|
||
function getUpdatedInputOrFallback(
|
||
permissionResult: PermissionResult,
|
||
fallback: Record<string, unknown>,
|
||
): Record<string, unknown> {
|
||
return (
|
||
('updatedInput' in permissionResult
|
||
? permissionResult.updatedInput
|
||
: undefined) ?? fallback
|
||
)
|
||
}
|