mirror of
http://10.0.2.1:3031/sauer/claude-code.git
synced 2026-06-30 19:36:57 +10:00
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,
|
|||
|
|
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
|
|||
|
|
)
|
|||
|
|
}
|