π File detail
utils/permissions/permissions.ts
π― Use case
This file lives under βutils/β, which covers cross-cutting helpers (shell, tempfiles, settings, messages, process input, β¦). On the API surface it exposes permissionRuleSourceDisplayString, getAllowRules, createPermissionRequestMessage, getDenyRules, and getAskRules (and more) β mainly functions, hooks, or classes. Dependencies touch bun:bundle, @anthropic-ai, and Bash(prefix:*). It composes internal code from hooks, services, Tool, tools, and types (relative imports).
Generated from folder role, exports, dependency roots, and inline comments β not hand-reviewed for every path.
π§ Inline summary
import { feature } from 'bun:bundle' import { APIUserAbortError } from '@anthropic-ai/sdk' import type { CanUseToolFn } from '../../hooks/useCanUseTool.js' import { getToolNameForPermissionCheck,
π€ Exports (heuristic)
permissionRuleSourceDisplayStringgetAllowRulescreatePermissionRequestMessagegetDenyRulesgetAskRulestoolAlwaysAllowedRulegetDenyRuleForToolgetAskRuleForToolgetDenyRuleForAgentfilterDeniedAgentsgetRuleByContentsForToolgetRuleByContentsForToolNamehasPermissionsToUseToolcheckRuleBasedPermissionsdeletePermissionRuleapplyPermissionRulesToPermissionContextsyncPermissionRulesFromDisk
π External import roots
Package roots from from "β¦" (relative paths omitted).
bun:bundle@anthropic-aiBash(prefix:*)
π₯οΈ Source preview
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,
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)
context.setAppState(prev => ({
...prev,
toolPermissionContext: applyPermissionUpdates(
prev.toolPermissionContext,
decision.updatedPermissions!,
),
}))
}
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
)
}