chore: initialize recovered claude workspace

This commit is contained in:
2026-04-02 15:29:01 +08:00
commit a10efa3b4b
1940 changed files with 506426 additions and 0 deletions

View File

@@ -0,0 +1,141 @@
import { feature } from 'bun:bundle'
import z from 'zod/v4'
import { PAUSE_ICON } from '../../constants/figures.js'
// Types extracted to src/types/permissions.ts to break import cycles
import {
EXTERNAL_PERMISSION_MODES,
type ExternalPermissionMode,
PERMISSION_MODES,
type PermissionMode,
} from '../../types/permissions.js'
import { lazySchema } from '../lazySchema.js'
// Re-export for backwards compatibility
export {
EXTERNAL_PERMISSION_MODES,
PERMISSION_MODES,
type ExternalPermissionMode,
type PermissionMode,
}
export const permissionModeSchema = lazySchema(() => z.enum(PERMISSION_MODES))
export const externalPermissionModeSchema = lazySchema(() =>
z.enum(EXTERNAL_PERMISSION_MODES),
)
type ModeColorKey =
| 'text'
| 'planMode'
| 'permission'
| 'autoAccept'
| 'error'
| 'warning'
type PermissionModeConfig = {
title: string
shortTitle: string
symbol: string
color: ModeColorKey
external: ExternalPermissionMode
}
const PERMISSION_MODE_CONFIG: Partial<
Record<PermissionMode, PermissionModeConfig>
> = {
default: {
title: 'Default',
shortTitle: 'Default',
symbol: '',
color: 'text',
external: 'default',
},
plan: {
title: 'Plan Mode',
shortTitle: 'Plan',
symbol: PAUSE_ICON,
color: 'planMode',
external: 'plan',
},
acceptEdits: {
title: 'Accept edits',
shortTitle: 'Accept',
symbol: '⏵⏵',
color: 'autoAccept',
external: 'acceptEdits',
},
bypassPermissions: {
title: 'Bypass Permissions',
shortTitle: 'Bypass',
symbol: '⏵⏵',
color: 'error',
external: 'bypassPermissions',
},
dontAsk: {
title: "Don't Ask",
shortTitle: 'DontAsk',
symbol: '⏵⏵',
color: 'error',
external: 'dontAsk',
},
...(feature('TRANSCRIPT_CLASSIFIER')
? {
auto: {
title: 'Auto mode',
shortTitle: 'Auto',
symbol: '⏵⏵',
color: 'warning' as ModeColorKey,
external: 'default' as ExternalPermissionMode,
},
}
: {}),
}
/**
* Type guard to check if a PermissionMode is an ExternalPermissionMode.
* auto is ant-only and excluded from external modes.
*/
export function isExternalPermissionMode(
mode: PermissionMode,
): mode is ExternalPermissionMode {
// External users can't have auto, so always true for them
if (process.env.USER_TYPE !== 'ant') {
return true
}
return mode !== 'auto' && mode !== 'bubble'
}
function getModeConfig(mode: PermissionMode): PermissionModeConfig {
return PERMISSION_MODE_CONFIG[mode] ?? PERMISSION_MODE_CONFIG.default!
}
export function toExternalPermissionMode(
mode: PermissionMode,
): ExternalPermissionMode {
return getModeConfig(mode).external
}
export function permissionModeFromString(str: string): PermissionMode {
return (PERMISSION_MODES as readonly string[]).includes(str)
? (str as PermissionMode)
: 'default'
}
export function permissionModeTitle(mode: PermissionMode): string {
return getModeConfig(mode).title
}
export function isDefaultMode(mode: PermissionMode | undefined): boolean {
return mode === 'default' || mode === undefined
}
export function permissionModeShortTitle(mode: PermissionMode): string {
return getModeConfig(mode).shortTitle
}
export function permissionModeSymbol(mode: PermissionMode): string {
return getModeConfig(mode).symbol
}
export function getModeColor(mode: PermissionMode): ModeColorKey {
return getModeConfig(mode).color
}

View File

@@ -0,0 +1,127 @@
import type { Tool, ToolUseContext } from 'src/Tool.js'
import z from 'zod/v4'
import { logForDebugging } from '../debug.js'
import { lazySchema } from '../lazySchema.js'
import type {
PermissionDecision,
PermissionDecisionReason,
} from './PermissionResult.js'
import {
applyPermissionUpdates,
persistPermissionUpdates,
} from './PermissionUpdate.js'
import { permissionUpdateSchema } from './PermissionUpdateSchema.js'
export const inputSchema = lazySchema(() =>
z.object({
tool_name: z
.string()
.describe('The name of the tool requesting permission'),
input: z.record(z.string(), z.unknown()).describe('The input for the tool'),
tool_use_id: z
.string()
.optional()
.describe('The unique tool use request ID'),
}),
)
export type Input = z.infer<ReturnType<typeof inputSchema>>
// Zod schema for permission results
// This schema is used to validate the MCP permission prompt tool
// so we maintain it as a subset of the real PermissionDecision type
// Matches PermissionDecisionClassificationSchema in entrypoints/sdk/coreSchemas.ts.
// Malformed values fall through to undefined (same pattern as updatedPermissions
// below) so a bad string from the SDK host doesn't reject the whole decision.
const decisionClassificationField = lazySchema(() =>
z
.enum(['user_temporary', 'user_permanent', 'user_reject'])
.optional()
.catch(undefined),
)
const PermissionAllowResultSchema = lazySchema(() =>
z.object({
behavior: z.literal('allow'),
updatedInput: z.record(z.string(), z.unknown()),
// SDK hosts may send malformed entries; fall back to undefined rather
// than rejecting the entire allow decision (anthropics/claude-code#29440)
updatedPermissions: z
.array(permissionUpdateSchema())
.optional()
.catch(ctx => {
logForDebugging(
`Malformed updatedPermissions from SDK host ignored: ${ctx.error.issues[0]?.message ?? 'unknown'}`,
{ level: 'warn' },
)
return undefined
}),
toolUseID: z.string().optional(),
decisionClassification: decisionClassificationField(),
}),
)
const PermissionDenyResultSchema = lazySchema(() =>
z.object({
behavior: z.literal('deny'),
message: z.string(),
interrupt: z.boolean().optional(),
toolUseID: z.string().optional(),
decisionClassification: decisionClassificationField(),
}),
)
export const outputSchema = lazySchema(() =>
z.union([PermissionAllowResultSchema(), PermissionDenyResultSchema()]),
)
export type Output = z.infer<ReturnType<typeof outputSchema>>
/**
* Normalizes the result of a permission prompt tool to a PermissionDecision.
*/
export function permissionPromptToolResultToPermissionDecision(
result: Output,
tool: Tool,
input: { [key: string]: unknown },
toolUseContext: ToolUseContext,
): PermissionDecision {
const decisionReason: PermissionDecisionReason = {
type: 'permissionPromptTool',
permissionPromptToolName: tool.name,
toolResult: result,
}
if (result.behavior === 'allow') {
const updatedPermissions = result.updatedPermissions
if (updatedPermissions) {
toolUseContext.setAppState(prev => ({
...prev,
toolPermissionContext: applyPermissionUpdates(
prev.toolPermissionContext,
updatedPermissions,
),
}))
persistPermissionUpdates(updatedPermissions)
}
// Mobile clients responding from a push notification don't have the
// original tool input, so they send `{}` to satisfy the schema. Treat an
// empty object as "use original" so the tool doesn't run with no args.
const updatedInput =
Object.keys(result.updatedInput).length > 0 ? result.updatedInput : input
return {
...result,
updatedInput,
decisionReason,
}
} else if (result.behavior === 'deny' && result.interrupt) {
logForDebugging(
`SDK permission prompt deny+interrupt: tool=${tool.name} message=${result.message}`,
)
toolUseContext.abortController.abort()
}
return {
...result,
decisionReason,
}
}

View File

@@ -0,0 +1,35 @@
// Types extracted to src/types/permissions.ts to break import cycles
import type {
PermissionAllowDecision,
PermissionAskDecision,
PermissionDecision,
PermissionDecisionReason,
PermissionDenyDecision,
PermissionMetadata,
PermissionResult,
} from '../../types/permissions.js'
// Re-export for backwards compatibility
export type {
PermissionAllowDecision,
PermissionAskDecision,
PermissionDecision,
PermissionDecisionReason,
PermissionDenyDecision,
PermissionMetadata,
PermissionResult,
}
// Helper function to get the appropriate prose description for rule behavior
export function getRuleBehaviorDescription(
permissionResult: PermissionResult['behavior'],
): string {
switch (permissionResult) {
case 'allow':
return 'allowed'
case 'deny':
return 'denied'
default:
return 'asked for confirmation for'
}
}

View File

@@ -0,0 +1,40 @@
import z from 'zod/v4'
// Types extracted to src/types/permissions.ts to break import cycles
import type {
PermissionBehavior,
PermissionRule,
PermissionRuleSource,
PermissionRuleValue,
} from '../../types/permissions.js'
import { lazySchema } from '../lazySchema.js'
// Re-export for backwards compatibility
export type {
PermissionBehavior,
PermissionRule,
PermissionRuleSource,
PermissionRuleValue,
}
/**
* ToolPermissionBehavior is the behavior associated with a permission rule.
* 'allow' means the rule allows the tool to run.
* 'deny' means the rule denies the tool from running.
* 'ask' means the rule forces a prompt to be shown to the user.
*/
export const permissionBehaviorSchema = lazySchema(() =>
z.enum(['allow', 'deny', 'ask']),
)
/**
* PermissionRuleValue is the content of a permission rule.
* @param toolName - The name of the tool this rule applies to
* @param ruleContent - The optional content of the rule.
* Each tool may implement custom handling in `checkPermissions()`
*/
export const permissionRuleValueSchema = lazySchema(() =>
z.object({
toolName: z.string(),
ruleContent: z.string().optional(),
}),
)

View File

@@ -0,0 +1,389 @@
import { posix } from 'path'
import type { ToolPermissionContext } from '../../Tool.js'
// Types extracted to src/types/permissions.ts to break import cycles
import type {
AdditionalWorkingDirectory,
WorkingDirectorySource,
} from '../../types/permissions.js'
import { logForDebugging } from '../debug.js'
import type { EditableSettingSource } from '../settings/constants.js'
import {
getSettingsForSource,
updateSettingsForSource,
} from '../settings/settings.js'
import { jsonStringify } from '../slowOperations.js'
import { toPosixPath } from './filesystem.js'
import type { PermissionRuleValue } from './PermissionRule.js'
import type {
PermissionUpdate,
PermissionUpdateDestination,
} from './PermissionUpdateSchema.js'
import {
permissionRuleValueFromString,
permissionRuleValueToString,
} from './permissionRuleParser.js'
import { addPermissionRulesToSettings } from './permissionsLoader.js'
// Re-export for backwards compatibility
export type { AdditionalWorkingDirectory, WorkingDirectorySource }
export function extractRules(
updates: PermissionUpdate[] | undefined,
): PermissionRuleValue[] {
if (!updates) return []
return updates.flatMap(update => {
switch (update.type) {
case 'addRules':
return update.rules
default:
return []
}
})
}
export function hasRules(updates: PermissionUpdate[] | undefined): boolean {
return extractRules(updates).length > 0
}
/**
* Applies a single permission update to the context and returns the updated context
* @param context The current permission context
* @param update The permission update to apply
* @returns The updated permission context
*/
export function applyPermissionUpdate(
context: ToolPermissionContext,
update: PermissionUpdate,
): ToolPermissionContext {
switch (update.type) {
case 'setMode':
logForDebugging(
`Applying permission update: Setting mode to '${update.mode}'`,
)
return {
...context,
mode: update.mode,
}
case 'addRules': {
const ruleStrings = update.rules.map(rule =>
permissionRuleValueToString(rule),
)
logForDebugging(
`Applying permission update: Adding ${update.rules.length} ${update.behavior} rule(s) to destination '${update.destination}': ${jsonStringify(ruleStrings)}`,
)
// Determine which collection to update based on behavior
const ruleKind =
update.behavior === 'allow'
? 'alwaysAllowRules'
: update.behavior === 'deny'
? 'alwaysDenyRules'
: 'alwaysAskRules'
return {
...context,
[ruleKind]: {
...context[ruleKind],
[update.destination]: [
...(context[ruleKind][update.destination] || []),
...ruleStrings,
],
},
}
}
case 'replaceRules': {
const ruleStrings = update.rules.map(rule =>
permissionRuleValueToString(rule),
)
logForDebugging(
`Replacing all ${update.behavior} rules for destination '${update.destination}' with ${update.rules.length} rule(s): ${jsonStringify(ruleStrings)}`,
)
// Determine which collection to update based on behavior
const ruleKind =
update.behavior === 'allow'
? 'alwaysAllowRules'
: update.behavior === 'deny'
? 'alwaysDenyRules'
: 'alwaysAskRules'
return {
...context,
[ruleKind]: {
...context[ruleKind],
[update.destination]: ruleStrings, // Replace all rules for this source
},
}
}
case 'addDirectories': {
logForDebugging(
`Applying permission update: Adding ${update.directories.length} director${update.directories.length === 1 ? 'y' : 'ies'} with destination '${update.destination}': ${jsonStringify(update.directories)}`,
)
const newAdditionalDirs = new Map(context.additionalWorkingDirectories)
for (const directory of update.directories) {
newAdditionalDirs.set(directory, {
path: directory,
source: update.destination,
})
}
return {
...context,
additionalWorkingDirectories: newAdditionalDirs,
}
}
case 'removeRules': {
const ruleStrings = update.rules.map(rule =>
permissionRuleValueToString(rule),
)
logForDebugging(
`Applying permission update: Removing ${update.rules.length} ${update.behavior} rule(s) from source '${update.destination}': ${jsonStringify(ruleStrings)}`,
)
// Determine which collection to update based on behavior
const ruleKind =
update.behavior === 'allow'
? 'alwaysAllowRules'
: update.behavior === 'deny'
? 'alwaysDenyRules'
: 'alwaysAskRules'
// Filter out the rules to be removed
const existingRules = context[ruleKind][update.destination] || []
const rulesToRemove = new Set(ruleStrings)
const filteredRules = existingRules.filter(
rule => !rulesToRemove.has(rule),
)
return {
...context,
[ruleKind]: {
...context[ruleKind],
[update.destination]: filteredRules,
},
}
}
case 'removeDirectories': {
logForDebugging(
`Applying permission update: Removing ${update.directories.length} director${update.directories.length === 1 ? 'y' : 'ies'}: ${jsonStringify(update.directories)}`,
)
const newAdditionalDirs = new Map(context.additionalWorkingDirectories)
for (const directory of update.directories) {
newAdditionalDirs.delete(directory)
}
return {
...context,
additionalWorkingDirectories: newAdditionalDirs,
}
}
default:
return context
}
}
/**
* Applies multiple permission updates to the context and returns the updated context
* @param context The current permission context
* @param updates The permission updates to apply
* @returns The updated permission context
*/
export function applyPermissionUpdates(
context: ToolPermissionContext,
updates: PermissionUpdate[],
): ToolPermissionContext {
let updatedContext = context
for (const update of updates) {
updatedContext = applyPermissionUpdate(updatedContext, update)
}
return updatedContext
}
export function supportsPersistence(
destination: PermissionUpdateDestination,
): destination is EditableSettingSource {
return (
destination === 'localSettings' ||
destination === 'userSettings' ||
destination === 'projectSettings'
)
}
/**
* Persists a permission update to the appropriate settings source
* @param update The permission update to persist
*/
export function persistPermissionUpdate(update: PermissionUpdate): void {
if (!supportsPersistence(update.destination)) return
logForDebugging(
`Persisting permission update: ${update.type} to source '${update.destination}'`,
)
switch (update.type) {
case 'addRules': {
logForDebugging(
`Persisting ${update.rules.length} ${update.behavior} rule(s) to ${update.destination}`,
)
addPermissionRulesToSettings(
{
ruleValues: update.rules,
ruleBehavior: update.behavior,
},
update.destination,
)
break
}
case 'addDirectories': {
logForDebugging(
`Persisting ${update.directories.length} director${update.directories.length === 1 ? 'y' : 'ies'} to ${update.destination}`,
)
const existingSettings = getSettingsForSource(update.destination)
const existingDirs =
existingSettings?.permissions?.additionalDirectories || []
// Add new directories, avoiding duplicates
const dirsToAdd = update.directories.filter(
dir => !existingDirs.includes(dir),
)
if (dirsToAdd.length > 0) {
const updatedDirs = [...existingDirs, ...dirsToAdd]
updateSettingsForSource(update.destination, {
permissions: {
additionalDirectories: updatedDirs,
},
})
}
break
}
case 'removeRules': {
// Handle rule removal
logForDebugging(
`Removing ${update.rules.length} ${update.behavior} rule(s) from ${update.destination}`,
)
const existingSettings = getSettingsForSource(update.destination)
const existingPermissions = existingSettings?.permissions || {}
const existingRules = existingPermissions[update.behavior] || []
// Convert rules to normalized strings for comparison
// Normalize via parse→serialize roundtrip so "Bash(*)" and "Bash" match
const rulesToRemove = new Set(
update.rules.map(permissionRuleValueToString),
)
const filteredRules = existingRules.filter(rule => {
const normalized = permissionRuleValueToString(
permissionRuleValueFromString(rule),
)
return !rulesToRemove.has(normalized)
})
updateSettingsForSource(update.destination, {
permissions: {
[update.behavior]: filteredRules,
},
})
break
}
case 'removeDirectories': {
logForDebugging(
`Removing ${update.directories.length} director${update.directories.length === 1 ? 'y' : 'ies'} from ${update.destination}`,
)
const existingSettings = getSettingsForSource(update.destination)
const existingDirs =
existingSettings?.permissions?.additionalDirectories || []
// Remove specified directories
const dirsToRemove = new Set(update.directories)
const filteredDirs = existingDirs.filter(dir => !dirsToRemove.has(dir))
updateSettingsForSource(update.destination, {
permissions: {
additionalDirectories: filteredDirs,
},
})
break
}
case 'setMode': {
logForDebugging(
`Persisting mode '${update.mode}' to ${update.destination}`,
)
updateSettingsForSource(update.destination, {
permissions: {
defaultMode: update.mode,
},
})
break
}
case 'replaceRules': {
logForDebugging(
`Replacing all ${update.behavior} rules in ${update.destination} with ${update.rules.length} rule(s)`,
)
const ruleStrings = update.rules.map(permissionRuleValueToString)
updateSettingsForSource(update.destination, {
permissions: {
[update.behavior]: ruleStrings,
},
})
break
}
}
}
/**
* Persists multiple permission updates to the appropriate settings sources
* Only persists updates with persistable sources
* @param updates The permission updates to persist
*/
export function persistPermissionUpdates(updates: PermissionUpdate[]): void {
for (const update of updates) {
persistPermissionUpdate(update)
}
}
/**
* Creates a Read rule suggestion for a directory.
* @param dirPath The directory path to create a rule for
* @param destination The destination for the permission rule (defaults to 'session')
* @returns A PermissionUpdate for a Read rule, or undefined for the root directory
*/
export function createReadRuleSuggestion(
dirPath: string,
destination: PermissionUpdateDestination = 'session',
): PermissionUpdate | undefined {
// Convert to POSIX format for pattern matching (handles Windows internally)
const pathForPattern = toPosixPath(dirPath)
// Root directory is too broad to be a reasonable permission target
if (pathForPattern === '/') {
return undefined
}
// For absolute paths, prepend an extra / to create //path/** pattern
const ruleContent = posix.isAbsolute(pathForPattern)
? `/${pathForPattern}/**`
: `${pathForPattern}/**`
return {
type: 'addRules',
rules: [
{
toolName: 'Read',
ruleContent,
},
],
behavior: 'allow',
destination,
}
}

View File

@@ -0,0 +1,78 @@
/**
* Zod schemas for permission updates.
*
* This file is intentionally kept minimal with no complex dependencies
* so it can be safely imported by src/types/hooks.ts without creating
* circular dependencies.
*/
import z from 'zod/v4'
// Types extracted to src/types/permissions.ts to break import cycles
import type {
PermissionUpdate,
PermissionUpdateDestination,
} from '../../types/permissions.js'
import { lazySchema } from '../lazySchema.js'
import { externalPermissionModeSchema } from './PermissionMode.js'
import {
permissionBehaviorSchema,
permissionRuleValueSchema,
} from './PermissionRule.js'
// Re-export for backwards compatibility
export type { PermissionUpdate, PermissionUpdateDestination }
/**
* PermissionUpdateDestination is where a new permission rule should be saved to.
*/
export const permissionUpdateDestinationSchema = lazySchema(() =>
z.enum([
// User settings (global)
'userSettings',
// Project settings (shared per-directory)
'projectSettings',
// Local settings (gitignored)
'localSettings',
// In-memory for the current session only
'session',
// From the command line arguments
'cliArg',
]),
)
export const permissionUpdateSchema = lazySchema(() =>
z.discriminatedUnion('type', [
z.object({
type: z.literal('addRules'),
rules: z.array(permissionRuleValueSchema()),
behavior: permissionBehaviorSchema(),
destination: permissionUpdateDestinationSchema(),
}),
z.object({
type: z.literal('replaceRules'),
rules: z.array(permissionRuleValueSchema()),
behavior: permissionBehaviorSchema(),
destination: permissionUpdateDestinationSchema(),
}),
z.object({
type: z.literal('removeRules'),
rules: z.array(permissionRuleValueSchema()),
behavior: permissionBehaviorSchema(),
destination: permissionUpdateDestinationSchema(),
}),
z.object({
type: z.literal('setMode'),
mode: externalPermissionModeSchema(),
destination: permissionUpdateDestinationSchema(),
}),
z.object({
type: z.literal('addDirectories'),
directories: z.array(z.string()),
destination: permissionUpdateDestinationSchema(),
}),
z.object({
type: z.literal('removeDirectories'),
directories: z.array(z.string()),
destination: permissionUpdateDestinationSchema(),
}),
]),
)

View File

@@ -0,0 +1,39 @@
// Auto mode state functions — lives in its own module so callers can
// conditionally require() it on feature('TRANSCRIPT_CLASSIFIER').
let autoModeActive = false
let autoModeFlagCli = false
// Set by the async verifyAutoModeGateAccess check when it
// reads a fresh tengu_auto_mode_config.enabled === 'disabled' from GrowthBook.
// Used by isAutoModeGateEnabled() to block SDK/explicit re-entry after kick-out.
let autoModeCircuitBroken = false
export function setAutoModeActive(active: boolean): void {
autoModeActive = active
}
export function isAutoModeActive(): boolean {
return autoModeActive
}
export function setAutoModeFlagCli(passed: boolean): void {
autoModeFlagCli = passed
}
export function getAutoModeFlagCli(): boolean {
return autoModeFlagCli
}
export function setAutoModeCircuitBroken(broken: boolean): void {
autoModeCircuitBroken = broken
}
export function isAutoModeCircuitBroken(): boolean {
return autoModeCircuitBroken
}
export function _resetForTesting(): void {
autoModeActive = false
autoModeFlagCli = false
autoModeCircuitBroken = false
}

View File

@@ -0,0 +1,61 @@
// Stub for external builds - classifier permissions feature is ANT-ONLY
export const PROMPT_PREFIX = 'prompt:'
export type ClassifierResult = {
matches: boolean
matchedDescription?: string
confidence: 'high' | 'medium' | 'low'
reason: string
}
export type ClassifierBehavior = 'deny' | 'ask' | 'allow'
export function extractPromptDescription(
_ruleContent: string | undefined,
): string | null {
return null
}
export function createPromptRuleContent(description: string): string {
return `${PROMPT_PREFIX} ${description.trim()}`
}
export function isClassifierPermissionsEnabled(): boolean {
return false
}
export function getBashPromptDenyDescriptions(_context: unknown): string[] {
return []
}
export function getBashPromptAskDescriptions(_context: unknown): string[] {
return []
}
export function getBashPromptAllowDescriptions(_context: unknown): string[] {
return []
}
export async function classifyBashCommand(
_command: string,
_cwd: string,
_descriptions: string[],
_behavior: ClassifierBehavior,
_signal: AbortSignal,
_isNonInteractiveSession: boolean,
): Promise<ClassifierResult> {
return {
matches: false,
confidence: 'high',
reason: 'This feature is disabled',
}
}
export async function generateGenericDescription(
_command: string,
specificDescription: string | undefined,
_signal: AbortSignal,
): Promise<string | null> {
return specificDescription || null
}

View File

@@ -0,0 +1,155 @@
import { feature } from 'bun:bundle'
import { useEffect, useRef } from 'react'
import {
type AppState,
useAppState,
useAppStateStore,
useSetAppState,
} from 'src/state/AppState.js'
import type { ToolPermissionContext } from 'src/Tool.js'
import { getIsRemoteMode } from '../../bootstrap/state.js'
import {
createDisabledBypassPermissionsContext,
shouldDisableBypassPermissions,
verifyAutoModeGateAccess,
} from './permissionSetup.js'
let bypassPermissionsCheckRan = false
export async function checkAndDisableBypassPermissionsIfNeeded(
toolPermissionContext: ToolPermissionContext,
setAppState: (f: (prev: AppState) => AppState) => void,
): Promise<void> {
// Check if bypassPermissions should be disabled based on Statsig gate
// Do this only once, before the first query, to ensure we have the latest gate value
if (bypassPermissionsCheckRan) {
return
}
bypassPermissionsCheckRan = true
if (!toolPermissionContext.isBypassPermissionsModeAvailable) {
return
}
const shouldDisable = await shouldDisableBypassPermissions()
if (!shouldDisable) {
return
}
setAppState(prev => {
return {
...prev,
toolPermissionContext: createDisabledBypassPermissionsContext(
prev.toolPermissionContext,
),
}
})
}
/**
* Reset the run-once flag for checkAndDisableBypassPermissionsIfNeeded.
* Call this after /login so the gate check re-runs with the new org.
*/
export function resetBypassPermissionsCheck(): void {
bypassPermissionsCheckRan = false
}
export function useKickOffCheckAndDisableBypassPermissionsIfNeeded(): void {
const toolPermissionContext = useAppState(s => s.toolPermissionContext)
const setAppState = useSetAppState()
// Run once, when the component mounts
useEffect(() => {
if (getIsRemoteMode()) return
void checkAndDisableBypassPermissionsIfNeeded(
toolPermissionContext,
setAppState,
)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
}
let autoModeCheckRan = false
export async function checkAndDisableAutoModeIfNeeded(
toolPermissionContext: ToolPermissionContext,
setAppState: (f: (prev: AppState) => AppState) => void,
fastMode?: boolean,
): Promise<void> {
if (feature('TRANSCRIPT_CLASSIFIER')) {
if (autoModeCheckRan) {
return
}
autoModeCheckRan = true
const { updateContext, notification } = await verifyAutoModeGateAccess(
toolPermissionContext,
fastMode,
)
setAppState(prev => {
// Apply the transform to CURRENT context, not the stale snapshot we
// passed to verifyAutoModeGateAccess. The async GrowthBook await inside
// can be outrun by a mid-turn shift-tab; spreading a stale context here
// would revert the user's mode change.
const nextCtx = updateContext(prev.toolPermissionContext)
const newState =
nextCtx === prev.toolPermissionContext
? prev
: { ...prev, toolPermissionContext: nextCtx }
if (!notification) return newState
return {
...newState,
notifications: {
...newState.notifications,
queue: [
...newState.notifications.queue,
{
key: 'auto-mode-gate-notification',
text: notification,
color: 'warning' as const,
priority: 'high' as const,
},
],
},
}
})
}
}
/**
* Reset the run-once flag for checkAndDisableAutoModeIfNeeded.
* Call this after /login so the gate check re-runs with the new org.
*/
export function resetAutoModeGateCheck(): void {
autoModeCheckRan = false
}
export function useKickOffCheckAndDisableAutoModeIfNeeded(): void {
const mainLoopModel = useAppState(s => s.mainLoopModel)
const mainLoopModelForSession = useAppState(s => s.mainLoopModelForSession)
const fastMode = useAppState(s => s.fastMode)
const setAppState = useSetAppState()
const store = useAppStateStore()
const isFirstRunRef = useRef(true)
// Runs on mount (startup check) AND whenever the model or fast mode changes
// (kick-out / carousel-restore). Watching both model fields covers /model,
// Cmd+P picker, /config, and bridge onSetModel paths; fastMode covers
// /fast on|off for the tengu_auto_mode_config.disableFastMode circuit
// breaker. The print.ts headless paths are covered by the sync
// isAutoModeGateEnabled() check.
useEffect(() => {
if (getIsRemoteMode()) return
if (isFirstRunRef.current) {
isFirstRunRef.current = false
} else {
resetAutoModeGateCheck()
}
void checkAndDisableAutoModeIfNeeded(
store.getState().toolPermissionContext,
setAppState,
fastMode,
)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mainLoopModel, mainLoopModelForSession, fastMode])
}

View File

@@ -0,0 +1,98 @@
import { feature } from 'bun:bundle'
import { ASK_USER_QUESTION_TOOL_NAME } from '../../tools/AskUserQuestionTool/prompt.js'
import { ENTER_PLAN_MODE_TOOL_NAME } from '../../tools/EnterPlanModeTool/constants.js'
import { EXIT_PLAN_MODE_TOOL_NAME } from '../../tools/ExitPlanModeTool/constants.js'
import { FILE_READ_TOOL_NAME } from '../../tools/FileReadTool/prompt.js'
import { GLOB_TOOL_NAME } from '../../tools/GlobTool/prompt.js'
import { GREP_TOOL_NAME } from '../../tools/GrepTool/prompt.js'
import { LIST_MCP_RESOURCES_TOOL_NAME } from '../../tools/ListMcpResourcesTool/prompt.js'
import { LSP_TOOL_NAME } from '../../tools/LSPTool/prompt.js'
import { SEND_MESSAGE_TOOL_NAME } from '../../tools/SendMessageTool/constants.js'
import { SLEEP_TOOL_NAME } from '../../tools/SleepTool/prompt.js'
import { TASK_CREATE_TOOL_NAME } from '../../tools/TaskCreateTool/constants.js'
import { TASK_GET_TOOL_NAME } from '../../tools/TaskGetTool/constants.js'
import { TASK_LIST_TOOL_NAME } from '../../tools/TaskListTool/constants.js'
import { TASK_OUTPUT_TOOL_NAME } from '../../tools/TaskOutputTool/constants.js'
import { TASK_STOP_TOOL_NAME } from '../../tools/TaskStopTool/prompt.js'
import { TASK_UPDATE_TOOL_NAME } from '../../tools/TaskUpdateTool/constants.js'
import { TEAM_CREATE_TOOL_NAME } from '../../tools/TeamCreateTool/constants.js'
import { TEAM_DELETE_TOOL_NAME } from '../../tools/TeamDeleteTool/constants.js'
import { TODO_WRITE_TOOL_NAME } from '../../tools/TodoWriteTool/constants.js'
import { TOOL_SEARCH_TOOL_NAME } from '../../tools/ToolSearchTool/prompt.js'
import { YOLO_CLASSIFIER_TOOL_NAME } from './yoloClassifier.js'
// Ant-only tool names: conditional require so Bun can DCE these in external builds.
// Gates mirror tools.ts. Keeps the tool name strings out of cli.js.
/* eslint-disable @typescript-eslint/no-require-imports */
const TERMINAL_CAPTURE_TOOL_NAME = feature('TERMINAL_PANEL')
? (
require('../../tools/TerminalCaptureTool/prompt.js') as typeof import('../../tools/TerminalCaptureTool/prompt.js')
).TERMINAL_CAPTURE_TOOL_NAME
: null
const OVERFLOW_TEST_TOOL_NAME = feature('OVERFLOW_TEST_TOOL')
? (
require('../../tools/OverflowTestTool/OverflowTestTool.js') as typeof import('../../tools/OverflowTestTool/OverflowTestTool.js')
).OVERFLOW_TEST_TOOL_NAME
: null
const VERIFY_PLAN_EXECUTION_TOOL_NAME =
process.env.USER_TYPE === 'ant'
? (
require('../../tools/VerifyPlanExecutionTool/constants.js') as typeof import('../../tools/VerifyPlanExecutionTool/constants.js')
).VERIFY_PLAN_EXECUTION_TOOL_NAME
: null
const WORKFLOW_TOOL_NAME = feature('WORKFLOW_SCRIPTS')
? (
require('../../tools/WorkflowTool/constants.js') as typeof import('../../tools/WorkflowTool/constants.js')
).WORKFLOW_TOOL_NAME
: null
/* eslint-enable @typescript-eslint/no-require-imports */
/**
* Tools that are safe and don't need any classifier checking.
* Used by the auto mode classifier to skip unnecessary API calls.
* Does NOT include write/edit tools — those are handled by the
* acceptEdits fast path (allowed in CWD, classified outside CWD).
*/
const SAFE_YOLO_ALLOWLISTED_TOOLS = new Set([
// Read-only file operations
FILE_READ_TOOL_NAME,
// Search / read-only
GREP_TOOL_NAME,
GLOB_TOOL_NAME,
LSP_TOOL_NAME,
TOOL_SEARCH_TOOL_NAME,
LIST_MCP_RESOURCES_TOOL_NAME,
'ReadMcpResourceTool', // no exported constant
// Task management (metadata only)
TODO_WRITE_TOOL_NAME,
TASK_CREATE_TOOL_NAME,
TASK_GET_TOOL_NAME,
TASK_UPDATE_TOOL_NAME,
TASK_LIST_TOOL_NAME,
TASK_STOP_TOOL_NAME,
TASK_OUTPUT_TOOL_NAME,
// Plan mode / UI
ASK_USER_QUESTION_TOOL_NAME,
ENTER_PLAN_MODE_TOOL_NAME,
EXIT_PLAN_MODE_TOOL_NAME,
// Swarm coordination (internal mailbox/team state only — teammates have
// their own permission checks, so no actual security bypass).
TEAM_CREATE_TOOL_NAME,
// Agent cleanup
TEAM_DELETE_TOOL_NAME,
SEND_MESSAGE_TOOL_NAME,
// Workflow orchestration — subagents go through canUseTool individually
...(WORKFLOW_TOOL_NAME ? [WORKFLOW_TOOL_NAME] : []),
// Misc safe
SLEEP_TOOL_NAME,
// Ant-only safe tools (gates mirror tools.ts)
...(TERMINAL_CAPTURE_TOOL_NAME ? [TERMINAL_CAPTURE_TOOL_NAME] : []),
...(OVERFLOW_TEST_TOOL_NAME ? [OVERFLOW_TEST_TOOL_NAME] : []),
...(VERIFY_PLAN_EXECUTION_TOOL_NAME ? [VERIFY_PLAN_EXECUTION_TOOL_NAME] : []),
// Internal classifier tool
YOLO_CLASSIFIER_TOOL_NAME,
])
export function isAutoModeAllowlistedTool(toolName: string): boolean {
return SAFE_YOLO_ALLOWLISTED_TOOLS.has(toolName)
}

View File

@@ -0,0 +1,39 @@
/**
* Shared infrastructure for classifier-based permission systems.
*
* This module provides common types, schemas, and utilities used by both:
* - bashClassifier.ts (semantic Bash command matching)
* - yoloClassifier.ts (YOLO mode security classification)
*/
import type { BetaContentBlock } from '@anthropic-ai/sdk/resources/beta/messages.js'
import type { z } from 'zod/v4'
/**
* Extract tool use block from message content by tool name.
*/
export function extractToolUseBlock(
content: BetaContentBlock[],
toolName: string,
): Extract<BetaContentBlock, { type: 'tool_use' }> | null {
const block = content.find(b => b.type === 'tool_use' && b.name === toolName)
if (!block || block.type !== 'tool_use') {
return null
}
return block
}
/**
* Parse and validate classifier response from tool use block.
* Returns null if parsing fails.
*/
export function parseClassifierResponse<T extends z.ZodTypeAny>(
toolUseBlock: Extract<BetaContentBlock, { type: 'tool_use' }>,
schema: T,
): z.infer<T> | null {
const parseResult = schema.safeParse(toolUseBlock.input)
if (!parseResult.success) {
return null
}
return parseResult.data
}

View File

@@ -0,0 +1,80 @@
/**
* Pattern lists for dangerous shell-tool allow-rule prefixes.
*
* An allow rule like `Bash(python:*)` or `PowerShell(node:*)` lets the model
* run arbitrary code via that interpreter, bypassing the auto-mode classifier.
* These lists feed the isDangerous{Bash,PowerShell}Permission predicates in
* permissionSetup.ts, which strip such rules at auto-mode entry.
*
* The matcher in each predicate handles the rule-shape variants (exact, `:*`,
* trailing `*`, ` *`, ` -…*`). PS-specific cmdlet strings live in
* isDangerousPowerShellPermission (permissionSetup.ts).
*/
/**
* Cross-platform code-execution entry points present on both Unix and Windows.
* Shared to prevent the two lists drifting apart on interpreter additions.
*/
export const CROSS_PLATFORM_CODE_EXEC = [
// Interpreters
'python',
'python3',
'python2',
'node',
'deno',
'tsx',
'ruby',
'perl',
'php',
'lua',
// Package runners
'npx',
'bunx',
'npm run',
'yarn run',
'pnpm run',
'bun run',
// Shells reachable from both (Git Bash / WSL on Windows, native on Unix)
'bash',
'sh',
// Remote arbitrary-command wrapper (native OpenSSH on Win10+)
'ssh',
] as const
export const DANGEROUS_BASH_PATTERNS: readonly string[] = [
...CROSS_PLATFORM_CODE_EXEC,
'zsh',
'fish',
'eval',
'exec',
'env',
'xargs',
'sudo',
// Anthropic internal: ant-only tools plus general tools that ant sandbox
// dotfile data shows are commonly over-allowlisted as broad prefixes.
// These stay ant-only — external users don't have coo, and the rest are
// an empirical-risk call grounded in ant sandbox data, not a universal
// "this tool is unsafe" judgment. PS may want these once it has usage data.
...(process.env.USER_TYPE === 'ant'
? [
'fa run',
// Cluster code launcher — arbitrary code on the cluster
'coo',
// Network/exfil: gh gist create --public, gh api arbitrary HTTP,
// curl/wget POST. gh api needs its own entry — the matcher is
// exact-shape, not prefix, so pattern 'gh' alone does not catch
// rule 'gh api:*' (same reason 'npm run' is separate from 'npm').
'gh',
'gh api',
'curl',
'wget',
// git config core.sshCommand / hooks install = arbitrary code
'git',
// Cloud resource writes (s3 public buckets, k8s mutations)
'kubectl',
'aws',
'gcloud',
'gsutil',
]
: []),
]

View File

@@ -0,0 +1,45 @@
/**
* Denial tracking infrastructure for permission classifiers.
* Tracks consecutive denials and total denials to determine
* when to fall back to prompting.
*/
export type DenialTrackingState = {
consecutiveDenials: number
totalDenials: number
}
export const DENIAL_LIMITS = {
maxConsecutive: 3,
maxTotal: 20,
} as const
export function createDenialTrackingState(): DenialTrackingState {
return {
consecutiveDenials: 0,
totalDenials: 0,
}
}
export function recordDenial(state: DenialTrackingState): DenialTrackingState {
return {
...state,
consecutiveDenials: state.consecutiveDenials + 1,
totalDenials: state.totalDenials + 1,
}
}
export function recordSuccess(state: DenialTrackingState): DenialTrackingState {
if (state.consecutiveDenials === 0) return state // No change needed
return {
...state,
consecutiveDenials: 0,
}
}
export function shouldFallbackToPrompting(state: DenialTrackingState): boolean {
return (
state.consecutiveDenials >= DENIAL_LIMITS.maxConsecutive ||
state.totalDenials >= DENIAL_LIMITS.maxTotal
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,101 @@
import { feature } from 'bun:bundle'
import type { ToolPermissionContext } from '../../Tool.js'
import { logForDebugging } from '../debug.js'
import type { PermissionMode } from './PermissionMode.js'
import {
getAutoModeUnavailableReason,
isAutoModeGateEnabled,
transitionPermissionMode,
} from './permissionSetup.js'
// Checks both the cached isAutoModeAvailable (set at startup by
// verifyAutoModeGateAccess) and the live isAutoModeGateEnabled() — these can
// diverge if the circuit breaker or settings change mid-session. The
// live check prevents transitionPermissionMode from throwing
// (permissionSetup.ts:~559), which would silently crash the shift+tab handler
// and leave the user stuck at the current mode.
function canCycleToAuto(ctx: ToolPermissionContext): boolean {
if (feature('TRANSCRIPT_CLASSIFIER')) {
const gateEnabled = isAutoModeGateEnabled()
const can = !!ctx.isAutoModeAvailable && gateEnabled
if (!can) {
logForDebugging(
`[auto-mode] canCycleToAuto=false: ctx.isAutoModeAvailable=${ctx.isAutoModeAvailable} isAutoModeGateEnabled=${gateEnabled} reason=${getAutoModeUnavailableReason()}`,
)
}
return can
}
return false
}
/**
* Determines the next permission mode when cycling through modes with Shift+Tab.
*/
export function getNextPermissionMode(
toolPermissionContext: ToolPermissionContext,
_teamContext?: { leadAgentId: string },
): PermissionMode {
switch (toolPermissionContext.mode) {
case 'default':
// Ants skip acceptEdits and plan — auto mode replaces them
if (process.env.USER_TYPE === 'ant') {
if (toolPermissionContext.isBypassPermissionsModeAvailable) {
return 'bypassPermissions'
}
if (canCycleToAuto(toolPermissionContext)) {
return 'auto'
}
return 'default'
}
return 'acceptEdits'
case 'acceptEdits':
return 'plan'
case 'plan':
if (toolPermissionContext.isBypassPermissionsModeAvailable) {
return 'bypassPermissions'
}
if (canCycleToAuto(toolPermissionContext)) {
return 'auto'
}
return 'default'
case 'bypassPermissions':
if (canCycleToAuto(toolPermissionContext)) {
return 'auto'
}
return 'default'
case 'dontAsk':
// Not exposed in UI cycle yet, but return default if somehow reached
return 'default'
default:
// Covers auto (when TRANSCRIPT_CLASSIFIER is enabled) and any future modes — always fall back to default
return 'default'
}
}
/**
* Computes the next permission mode and prepares the context for it.
* Handles any context cleanup needed for the target mode (e.g., stripping
* dangerous permissions when entering auto mode).
*
* @returns The next mode and the context to use (with dangerous permissions stripped if needed)
*/
export function cyclePermissionMode(
toolPermissionContext: ToolPermissionContext,
teamContext?: { leadAgentId: string },
): { nextMode: PermissionMode; context: ToolPermissionContext } {
const nextMode = getNextPermissionMode(toolPermissionContext, teamContext)
return {
nextMode,
context: transitionPermissionMode(
toolPermissionContext.mode,
nextMode,
toolPermissionContext,
),
}
}

View File

@@ -0,0 +1,485 @@
import memoize from 'lodash-es/memoize.js'
import { homedir } from 'os'
import { dirname, isAbsolute, resolve } from 'path'
import type { ToolPermissionContext } from '../../Tool.js'
import { getPlatform } from '../../utils/platform.js'
import {
getFsImplementation,
getPathsForPermissionCheck,
safeResolvePath,
} from '../fsOperations.js'
import { containsPathTraversal } from '../path.js'
import { SandboxManager } from '../sandbox/sandbox-adapter.js'
import { containsVulnerableUncPath } from '../shell/readOnlyCommandValidation.js'
import {
checkEditableInternalPath,
checkPathSafetyForAutoEdit,
checkReadableInternalPath,
matchingRuleForInput,
pathInAllowedWorkingPath,
pathInWorkingPath,
} from './filesystem.js'
import type { PermissionDecisionReason } from './PermissionResult.js'
const MAX_DIRS_TO_LIST = 5
const GLOB_PATTERN_REGEX = /[*?[\]{}]/
export type FileOperationType = 'read' | 'write' | 'create'
export type PathCheckResult = {
allowed: boolean
decisionReason?: PermissionDecisionReason
}
export type ResolvedPathCheckResult = PathCheckResult & {
resolvedPath: string
}
export function formatDirectoryList(directories: string[]): string {
const dirCount = directories.length
if (dirCount <= MAX_DIRS_TO_LIST) {
return directories.map(dir => `'${dir}'`).join(', ')
}
const firstDirs = directories
.slice(0, MAX_DIRS_TO_LIST)
.map(dir => `'${dir}'`)
.join(', ')
return `${firstDirs}, and ${dirCount - MAX_DIRS_TO_LIST} more`
}
/**
* Extracts the base directory from a glob pattern for validation.
* For example: "/path/to/*.txt" returns "/path/to"
*/
export function getGlobBaseDirectory(path: string): string {
const globMatch = path.match(GLOB_PATTERN_REGEX)
if (!globMatch || globMatch.index === undefined) {
return path
}
// Get everything before the first glob character
const beforeGlob = path.substring(0, globMatch.index)
// Find the last directory separator
const lastSepIndex =
getPlatform() === 'windows'
? Math.max(beforeGlob.lastIndexOf('/'), beforeGlob.lastIndexOf('\\'))
: beforeGlob.lastIndexOf('/')
if (lastSepIndex === -1) return '.'
return beforeGlob.substring(0, lastSepIndex) || '/'
}
/**
* Expands tilde (~) at the start of a path to the user's home directory.
* Note: ~username expansion is not supported for security reasons.
*/
export function expandTilde(path: string): string {
if (
path === '~' ||
path.startsWith('~/') ||
(process.platform === 'win32' && path.startsWith('~\\'))
) {
return homedir() + path.slice(1)
}
return path
}
/**
* Checks if a resolved path is writable according to the sandbox write allowlist.
* When the sandbox is enabled, the user has explicitly configured which directories
* are writable. We treat these as additional allowed write directories for path
* validation purposes, so commands like `echo foo > /tmp/claude/x.txt` don't
* prompt for permission when /tmp/claude/ is already in the sandbox allowlist.
*
* Respects the deny-within-allow list: paths in denyWithinAllow (like
* .claude/settings.json) are still blocked even if their parent is in allowOnly.
*/
export function isPathInSandboxWriteAllowlist(resolvedPath: string): boolean {
if (!SandboxManager.isSandboxingEnabled()) {
return false
}
const { allowOnly, denyWithinAllow } = SandboxManager.getFsWriteConfig()
// Resolve symlinks on both sides so comparisons are symmetric (matching
// pathInAllowedWorkingPath). Without this, an allowlist entry that is a
// symlink (e.g. /home/user/proj -> /data/proj) would not match a write to
// its resolved target, causing an unnecessary prompt. Over-conservative,
// not a security issue. All resolved input representations must be allowed
// and none may be denied. Config paths are session-stable, so memoize
// their resolution to avoid N × config.length redundant syscalls per
// command with N write targets (matching getResolvedWorkingDirPaths).
const pathsToCheck = getPathsForPermissionCheck(resolvedPath)
const resolvedAllow = allowOnly.flatMap(getResolvedSandboxConfigPath)
const resolvedDeny = denyWithinAllow.flatMap(getResolvedSandboxConfigPath)
return pathsToCheck.every(p => {
for (const denyPath of resolvedDeny) {
if (pathInWorkingPath(p, denyPath)) return false
}
return resolvedAllow.some(allowPath => pathInWorkingPath(p, allowPath))
})
}
// Sandbox config paths are session-stable; memoize their resolved forms to
// avoid repeated lstat/realpath syscalls on every write-target check.
// Matches the getResolvedWorkingDirPaths pattern in filesystem.ts.
const getResolvedSandboxConfigPath = memoize(getPathsForPermissionCheck)
/**
* Checks if a resolved path is allowed for the given operation type.
*
* @param precomputedPathsToCheck - Optional cached result of
* `getPathsForPermissionCheck(resolvedPath)`. When `resolvedPath` is the
* output of `realpathSync` (canonical path, all symlinks resolved), this
* is trivially `[resolvedPath]` and passing it here skips 5 redundant
* syscalls per inner check. Do NOT pass this for non-canonical paths
* (nonexistent files, UNC paths, etc.) — parent-directory symlink
* resolution is still required for those.
*/
export function isPathAllowed(
resolvedPath: string,
context: ToolPermissionContext,
operationType: FileOperationType,
precomputedPathsToCheck?: readonly string[],
): PathCheckResult {
// Determine which permission type to check based on operation
const permissionType = operationType === 'read' ? 'read' : 'edit'
// 1. Check deny rules first (they take precedence)
const denyRule = matchingRuleForInput(
resolvedPath,
context,
permissionType,
'deny',
)
if (denyRule !== null) {
return {
allowed: false,
decisionReason: { type: 'rule', rule: denyRule },
}
}
// 2. For write/create operations, check internal editable paths (plan files, scratchpad, agent memory, job dirs)
// This MUST come before checkPathSafetyForAutoEdit since .claude is a dangerous directory
// and internal editable paths live under ~/.claude/ — matching the ordering in
// checkWritePermissionForTool (filesystem.ts step 1.5)
if (operationType !== 'read') {
const internalEditResult = checkEditableInternalPath(resolvedPath, {})
if (internalEditResult.behavior === 'allow') {
return {
allowed: true,
decisionReason: internalEditResult.decisionReason,
}
}
}
// 2.5. For write/create operations, check comprehensive safety validations
// This MUST come before checking working directory to prevent bypass via acceptEdits mode
// Checks: Windows patterns, Claude config files, dangerous files (on original + symlink paths)
if (operationType !== 'read') {
const safetyCheck = checkPathSafetyForAutoEdit(
resolvedPath,
precomputedPathsToCheck,
)
if (!safetyCheck.safe) {
return {
allowed: false,
decisionReason: {
type: 'safetyCheck',
reason: safetyCheck.message,
classifierApprovable: safetyCheck.classifierApprovable,
},
}
}
}
// 3. Check if path is in allowed working directory
// For write/create operations, require acceptEdits mode to auto-allow
// This is consistent with checkWritePermissionForTool in filesystem.ts
const isInWorkingDir = pathInAllowedWorkingPath(
resolvedPath,
context,
precomputedPathsToCheck,
)
if (isInWorkingDir) {
if (operationType === 'read' || context.mode === 'acceptEdits') {
return { allowed: true }
}
// Write/create without acceptEdits mode falls through to check allow rules
}
// 3.5. For read operations, check internal readable paths (project temp dir, session memory, etc.)
// This allows reading agent output files without explicit permission
if (operationType === 'read') {
const internalReadResult = checkReadableInternalPath(resolvedPath, {})
if (internalReadResult.behavior === 'allow') {
return {
allowed: true,
decisionReason: internalReadResult.decisionReason,
}
}
}
// 3.7. For write/create operations to paths OUTSIDE the working directory,
// check the sandbox write allowlist. When the sandbox is enabled, users
// have explicitly configured writable directories (e.g. /tmp/claude/) —
// treat these as additional allowed write directories so redirects/touch/
// mkdir don't prompt unnecessarily. Safety checks (step 2) already ran.
// Paths IN the working directory are intentionally excluded: the sandbox
// allowlist always seeds '.' (cwd, see sandbox-adapter.ts), which would
// bypass the acceptEdits gate at step 3. Step 3 handles those.
if (
operationType !== 'read' &&
!isInWorkingDir &&
isPathInSandboxWriteAllowlist(resolvedPath)
) {
return {
allowed: true,
decisionReason: {
type: 'other',
reason: 'Path is in sandbox write allowlist',
},
}
}
// 4. Check allow rules for the operation type
const allowRule = matchingRuleForInput(
resolvedPath,
context,
permissionType,
'allow',
)
if (allowRule !== null) {
return {
allowed: true,
decisionReason: { type: 'rule', rule: allowRule },
}
}
// 5. Path is not allowed
return { allowed: false }
}
/**
* Validates a glob pattern by checking its base directory.
* Returns the validation result for the base path where the glob would expand.
*/
export function validateGlobPattern(
cleanPath: string,
cwd: string,
toolPermissionContext: ToolPermissionContext,
operationType: FileOperationType,
): ResolvedPathCheckResult {
if (containsPathTraversal(cleanPath)) {
// For patterns with path traversal, resolve the full path
const absolutePath = isAbsolute(cleanPath)
? cleanPath
: resolve(cwd, cleanPath)
const { resolvedPath, isCanonical } = safeResolvePath(
getFsImplementation(),
absolutePath,
)
const result = isPathAllowed(
resolvedPath,
toolPermissionContext,
operationType,
isCanonical ? [resolvedPath] : undefined,
)
return {
allowed: result.allowed,
resolvedPath,
decisionReason: result.decisionReason,
}
}
const basePath = getGlobBaseDirectory(cleanPath)
const absoluteBasePath = isAbsolute(basePath)
? basePath
: resolve(cwd, basePath)
const { resolvedPath, isCanonical } = safeResolvePath(
getFsImplementation(),
absoluteBasePath,
)
const result = isPathAllowed(
resolvedPath,
toolPermissionContext,
operationType,
isCanonical ? [resolvedPath] : undefined,
)
return {
allowed: result.allowed,
resolvedPath,
decisionReason: result.decisionReason,
}
}
const WINDOWS_DRIVE_ROOT_REGEX = /^[A-Za-z]:\/?$/
const WINDOWS_DRIVE_CHILD_REGEX = /^[A-Za-z]:\/[^/]+$/
/**
* Checks if a resolved path is dangerous for removal operations (rm/rmdir).
* Dangerous paths are:
* - Wildcard '*' (removes all files in directory)
* - Any path ending with '/*' or '\*' (e.g., /path/to/dir/*, C:\foo\*)
* - Root directory (/)
* - Home directory (~)
* - Direct children of root (/usr, /tmp, /etc, etc.)
* - Windows drive root (C:\, D:\) and direct children (C:\Windows, C:\Users)
*/
export function isDangerousRemovalPath(resolvedPath: string): boolean {
// Callers pass both slash forms; collapse runs so C:\\Windows (valid in
// PowerShell) doesn't bypass the drive-child check.
const forwardSlashed = resolvedPath.replace(/[\\/]+/g, '/')
if (forwardSlashed === '*' || forwardSlashed.endsWith('/*')) {
return true
}
const normalizedPath =
forwardSlashed === '/' ? forwardSlashed : forwardSlashed.replace(/\/$/, '')
if (normalizedPath === '/') {
return true
}
if (WINDOWS_DRIVE_ROOT_REGEX.test(normalizedPath)) {
return true
}
const normalizedHome = homedir().replace(/[\\/]+/g, '/')
if (normalizedPath === normalizedHome) {
return true
}
// Direct children of root: /usr, /tmp, /etc (but not /usr/local)
const parentDir = dirname(normalizedPath)
if (parentDir === '/') {
return true
}
if (WINDOWS_DRIVE_CHILD_REGEX.test(normalizedPath)) {
return true
}
return false
}
/**
* Validates a file system path, handling tilde expansion and glob patterns.
* Returns whether the path is allowed and the resolved path for error messages.
*/
export function validatePath(
path: string,
cwd: string,
toolPermissionContext: ToolPermissionContext,
operationType: FileOperationType,
): ResolvedPathCheckResult {
// Remove surrounding quotes if present
const cleanPath = expandTilde(path.replace(/^['"]|['"]$/g, ''))
// SECURITY: Block UNC paths that could leak credentials
if (containsVulnerableUncPath(cleanPath)) {
return {
allowed: false,
resolvedPath: cleanPath,
decisionReason: {
type: 'other',
reason: 'UNC network paths require manual approval',
},
}
}
// SECURITY: Reject tilde variants (~user, ~+, ~-, ~N) that expandTilde doesn't handle.
// expandTilde resolves ~ and ~/ to $HOME, but ~root, ~+, ~- etc. are left as literal
// text and resolved as relative paths (e.g., /cwd/~root/.ssh/id_rsa).
// The shell expands these differently (~root → /var/root, ~+ → $PWD, ~- → $OLDPWD),
// creating a TOCTOU gap: we validate /cwd/~root/... but bash reads /var/root/...
// This check is safe from false positives because expandTilde already converted
// ~ and ~/ to absolute paths starting with /, so only unexpanded variants remain.
if (cleanPath.startsWith('~')) {
return {
allowed: false,
resolvedPath: cleanPath,
decisionReason: {
type: 'other',
reason:
'Tilde expansion variants (~user, ~+, ~-) in paths require manual approval',
},
}
}
// SECURITY: Reject paths containing ANY shell expansion syntax ($ or % characters,
// or paths starting with = which triggers Zsh equals expansion)
// - $VAR (Unix/Linux environment variables like $HOME, $PWD)
// - ${VAR} (brace expansion)
// - $(cmd) (command substitution)
// - %VAR% (Windows environment variables like %TEMP%, %USERPROFILE%)
// - Nested combinations like $(echo $HOME)
// - =cmd (Zsh equals expansion, e.g. =rg expands to /usr/bin/rg)
// All of these are preserved as literal strings during validation but expanded
// by the shell during execution, creating a TOCTOU vulnerability
if (
cleanPath.includes('$') ||
cleanPath.includes('%') ||
cleanPath.startsWith('=')
) {
return {
allowed: false,
resolvedPath: cleanPath,
decisionReason: {
type: 'other',
reason: 'Shell expansion syntax in paths requires manual approval',
},
}
}
// SECURITY: Block glob patterns in write/create operations
// Write tools don't expand globs - they use paths literally.
// Allowing globs in write operations could bypass security checks.
// Example: /allowed/dir/*.txt would only validate /allowed/dir,
// but the actual write would use the literal path with the *
if (GLOB_PATTERN_REGEX.test(cleanPath)) {
if (operationType === 'write' || operationType === 'create') {
return {
allowed: false,
resolvedPath: cleanPath,
decisionReason: {
type: 'other',
reason:
'Glob patterns are not allowed in write operations. Please specify an exact file path.',
},
}
}
// For read operations, validate the base directory where the glob would expand
return validateGlobPattern(
cleanPath,
cwd,
toolPermissionContext,
operationType,
)
}
// Resolve path
const absolutePath = isAbsolute(cleanPath)
? cleanPath
: resolve(cwd, cleanPath)
const { resolvedPath, isCanonical } = safeResolvePath(
getFsImplementation(),
absolutePath,
)
const result = isPathAllowed(
resolvedPath,
toolPermissionContext,
operationType,
isCanonical ? [resolvedPath] : undefined,
)
return {
allowed: result.allowed,
resolvedPath,
decisionReason: result.decisionReason,
}
}

View File

@@ -0,0 +1,250 @@
import { z } from 'zod/v4'
import { logEvent } from '../../services/analytics/index.js'
import { sanitizeToolNameForAnalytics } from '../../services/analytics/metadata.js'
import type { AssistantMessage, Message } from '../../types/message.js'
import { getGlobalConfig } from '../config.js'
import { logForDebugging } from '../debug.js'
import { errorMessage } from '../errors.js'
import { lazySchema } from '../lazySchema.js'
import { logError } from '../log.js'
import { getMainLoopModel } from '../model/model.js'
import { sideQuery } from '../sideQuery.js'
import { jsonStringify } from '../slowOperations.js'
export type RiskLevel = 'LOW' | 'MEDIUM' | 'HIGH'
// Map risk levels to numeric values for analytics
const RISK_LEVEL_NUMERIC: Record<RiskLevel, number> = {
LOW: 1,
MEDIUM: 2,
HIGH: 3,
}
// Error type codes for analytics
const ERROR_TYPE_PARSE = 1
const ERROR_TYPE_NETWORK = 2
const ERROR_TYPE_UNKNOWN = 3
export type PermissionExplanation = {
riskLevel: RiskLevel
explanation: string
reasoning: string
risk: string
}
type GenerateExplanationParams = {
toolName: string
toolInput: unknown
toolDescription?: string
messages?: Message[]
signal: AbortSignal
}
const SYSTEM_PROMPT = `Analyze shell commands and explain what they do, why you're running them, and potential risks.`
// Tool definition for forced structured output (no beta required)
const EXPLAIN_COMMAND_TOOL = {
name: 'explain_command',
description: 'Provide an explanation of a shell command',
input_schema: {
type: 'object' as const,
properties: {
explanation: {
type: 'string',
description: 'What this command does (1-2 sentences)',
},
reasoning: {
type: 'string',
description:
'Why YOU are running this command. Start with "I" - e.g. "I need to check the file contents"',
},
risk: {
type: 'string',
description: 'What could go wrong, under 15 words',
},
riskLevel: {
type: 'string',
enum: ['LOW', 'MEDIUM', 'HIGH'],
description:
'LOW (safe dev workflows), MEDIUM (recoverable changes), HIGH (dangerous/irreversible)',
},
},
required: ['explanation', 'reasoning', 'risk', 'riskLevel'],
},
}
// Zod schema for parsing and validating the response
const RiskAssessmentSchema = lazySchema(() =>
z.object({
riskLevel: z.enum(['LOW', 'MEDIUM', 'HIGH']),
explanation: z.string(),
reasoning: z.string(),
risk: z.string(),
}),
)
function formatToolInput(input: unknown): string {
if (typeof input === 'string') {
return input
}
try {
return jsonStringify(input, null, 2)
} catch {
return String(input)
}
}
/**
* Extract recent conversation context from messages for the explainer.
* Returns a summary of recent assistant messages to provide context
* for "why" this command is being run.
*/
function extractConversationContext(
messages: Message[],
maxChars = 1000,
): string {
// Get recent assistant messages (they contain Claude's reasoning)
const assistantMessages = messages
.filter((m): m is AssistantMessage => m.type === 'assistant')
.slice(-3) // Last 3 assistant messages
const contextParts: string[] = []
let totalChars = 0
for (const msg of assistantMessages.reverse()) {
// Extract text content from assistant message
const textBlocks = msg.message.content
.filter(c => c.type === 'text')
.map(c => ('text' in c ? c.text : ''))
.join(' ')
if (textBlocks && totalChars < maxChars) {
const remaining = maxChars - totalChars
const truncated =
textBlocks.length > remaining
? textBlocks.slice(0, remaining) + '...'
: textBlocks
contextParts.unshift(truncated)
totalChars += truncated.length
}
}
return contextParts.join('\n\n')
}
/**
* Check if the permission explainer feature is enabled.
* Enabled by default; users can opt out via config.
*/
export function isPermissionExplainerEnabled(): boolean {
return getGlobalConfig().permissionExplainerEnabled !== false
}
/**
* Generate a permission explanation using Haiku with structured output.
* Returns null if the feature is disabled, request is aborted, or an error occurs.
*/
export async function generatePermissionExplanation({
toolName,
toolInput,
toolDescription,
messages,
signal,
}: GenerateExplanationParams): Promise<PermissionExplanation | null> {
// Check if feature is enabled
if (!isPermissionExplainerEnabled()) {
return null
}
const startTime = Date.now()
try {
const formattedInput = formatToolInput(toolInput)
const conversationContext = messages?.length
? extractConversationContext(messages)
: ''
const userPrompt = `Tool: ${toolName}
${toolDescription ? `Description: ${toolDescription}\n` : ''}
Input:
${formattedInput}
${conversationContext ? `\nRecent conversation context:\n${conversationContext}` : ''}
Explain this command in context.`
const model = getMainLoopModel()
// Use sideQuery with forced tool choice for guaranteed structured output
const response = await sideQuery({
model,
system: SYSTEM_PROMPT,
messages: [{ role: 'user', content: userPrompt }],
tools: [EXPLAIN_COMMAND_TOOL],
tool_choice: { type: 'tool', name: 'explain_command' },
signal,
querySource: 'permission_explainer',
})
const latencyMs = Date.now() - startTime
logForDebugging(
`Permission explainer: API returned in ${latencyMs}ms, stop_reason=${response.stop_reason}`,
)
// Extract structured data from tool use block
const toolUseBlock = response.content.find(c => c.type === 'tool_use')
if (toolUseBlock && toolUseBlock.type === 'tool_use') {
logForDebugging(
`Permission explainer: tool input: ${jsonStringify(toolUseBlock.input).slice(0, 500)}`,
)
const result = RiskAssessmentSchema().safeParse(toolUseBlock.input)
if (result.success) {
const explanation: PermissionExplanation = {
riskLevel: result.data.riskLevel,
explanation: result.data.explanation,
reasoning: result.data.reasoning,
risk: result.data.risk,
}
logEvent('tengu_permission_explainer_generated', {
tool_name: sanitizeToolNameForAnalytics(toolName),
risk_level: RISK_LEVEL_NUMERIC[explanation.riskLevel],
latency_ms: latencyMs,
})
logForDebugging(
`Permission explainer: ${explanation.riskLevel} risk for ${toolName} (${latencyMs}ms)`,
)
return explanation
}
}
// No valid JSON in response
logEvent('tengu_permission_explainer_error', {
tool_name: sanitizeToolNameForAnalytics(toolName),
error_type: ERROR_TYPE_PARSE,
latency_ms: latencyMs,
})
logForDebugging(`Permission explainer: no parsed output in response`)
return null
} catch (error) {
const latencyMs = Date.now() - startTime
// Don't log aborted requests as errors
if (signal.aborted) {
logForDebugging(`Permission explainer: request aborted for ${toolName}`)
return null
}
logForDebugging(`Permission explainer error: ${errorMessage(error)}`)
logError(error)
logEvent('tengu_permission_explainer_error', {
tool_name: sanitizeToolNameForAnalytics(toolName),
error_type:
error instanceof Error && error.name === 'AbortError'
? ERROR_TYPE_NETWORK
: ERROR_TYPE_UNKNOWN,
latency_ms: latencyMs,
})
return null
}
}

View File

@@ -0,0 +1,198 @@
import { feature } from 'bun:bundle'
import { AGENT_TOOL_NAME } from '../../tools/AgentTool/constants.js'
import { TASK_OUTPUT_TOOL_NAME } from '../../tools/TaskOutputTool/constants.js'
import { TASK_STOP_TOOL_NAME } from '../../tools/TaskStopTool/prompt.js'
import type { PermissionRuleValue } from './PermissionRule.js'
// Dead code elimination: ant-only tool names are conditionally required so
// their strings don't leak into external builds. Static imports always bundle.
/* eslint-disable @typescript-eslint/no-require-imports */
const BRIEF_TOOL_NAME: string | null =
feature('KAIROS') || feature('KAIROS_BRIEF')
? (
require('../../tools/BriefTool/prompt.js') as typeof import('../../tools/BriefTool/prompt.js')
).BRIEF_TOOL_NAME
: null
/* eslint-enable @typescript-eslint/no-require-imports */
// Maps legacy tool names to their current canonical names.
// When a tool is renamed, add old → new here so permission rules,
// hooks, and persisted wire names resolve to the canonical name.
const LEGACY_TOOL_NAME_ALIASES: Record<string, string> = {
Task: AGENT_TOOL_NAME,
KillShell: TASK_STOP_TOOL_NAME,
AgentOutputTool: TASK_OUTPUT_TOOL_NAME,
BashOutputTool: TASK_OUTPUT_TOOL_NAME,
...((feature('KAIROS') || feature('KAIROS_BRIEF')) && BRIEF_TOOL_NAME
? { Brief: BRIEF_TOOL_NAME }
: {}),
}
export function normalizeLegacyToolName(name: string): string {
return LEGACY_TOOL_NAME_ALIASES[name] ?? name
}
export function getLegacyToolNames(canonicalName: string): string[] {
const result: string[] = []
for (const [legacy, canonical] of Object.entries(LEGACY_TOOL_NAME_ALIASES)) {
if (canonical === canonicalName) result.push(legacy)
}
return result
}
/**
* Escapes special characters in rule content for safe storage in permission rules.
* Permission rules use the format "Tool(content)", so parentheses in content must be escaped.
*
* Escaping order matters:
* 1. Escape existing backslashes first (\ -> \\)
* 2. Then escape parentheses (( -> \(, ) -> \))
*
* @example
* escapeRuleContent('psycopg2.connect()') // => 'psycopg2.connect\\(\\)'
* escapeRuleContent('echo "test\\nvalue"') // => 'echo "test\\\\nvalue"'
*/
export function escapeRuleContent(content: string): string {
return content
.replace(/\\/g, '\\\\') // Escape backslashes first
.replace(/\(/g, '\\(') // Escape opening parentheses
.replace(/\)/g, '\\)') // Escape closing parentheses
}
/**
* Unescapes special characters in rule content after parsing from permission rules.
* This reverses the escaping done by escapeRuleContent.
*
* Unescaping order matters (reverse of escaping):
* 1. Unescape parentheses first (\( -> (, \) -> ))
* 2. Then unescape backslashes (\\ -> \)
*
* @example
* unescapeRuleContent('psycopg2.connect\\(\\)') // => 'psycopg2.connect()'
* unescapeRuleContent('echo "test\\\\nvalue"') // => 'echo "test\\nvalue"'
*/
export function unescapeRuleContent(content: string): string {
return content
.replace(/\\\(/g, '(') // Unescape opening parentheses
.replace(/\\\)/g, ')') // Unescape closing parentheses
.replace(/\\\\/g, '\\') // Unescape backslashes last
}
/**
* Parses a permission rule string into its components.
* Handles escaped parentheses in the content portion.
*
* Format: "ToolName" or "ToolName(content)"
* Content may contain escaped parentheses: \( and \)
*
* @example
* permissionRuleValueFromString('Bash') // => { toolName: 'Bash' }
* permissionRuleValueFromString('Bash(npm install)') // => { toolName: 'Bash', ruleContent: 'npm install' }
* permissionRuleValueFromString('Bash(python -c "print\\(1\\)")') // => { toolName: 'Bash', ruleContent: 'python -c "print(1)"' }
*/
export function permissionRuleValueFromString(
ruleString: string,
): PermissionRuleValue {
// Find the first unescaped opening parenthesis
const openParenIndex = findFirstUnescapedChar(ruleString, '(')
if (openParenIndex === -1) {
// No parenthesis found - this is just a tool name
return { toolName: normalizeLegacyToolName(ruleString) }
}
// Find the last unescaped closing parenthesis
const closeParenIndex = findLastUnescapedChar(ruleString, ')')
if (closeParenIndex === -1 || closeParenIndex <= openParenIndex) {
// No matching closing paren or malformed - treat as tool name
return { toolName: normalizeLegacyToolName(ruleString) }
}
// Ensure the closing paren is at the end
if (closeParenIndex !== ruleString.length - 1) {
// Content after closing paren - treat as tool name
return { toolName: normalizeLegacyToolName(ruleString) }
}
const toolName = ruleString.substring(0, openParenIndex)
const rawContent = ruleString.substring(openParenIndex + 1, closeParenIndex)
// Missing toolName (e.g., "(foo)") is malformed - treat whole string as tool name
if (!toolName) {
return { toolName: normalizeLegacyToolName(ruleString) }
}
// Empty content (e.g., "Bash()") or standalone wildcard (e.g., "Bash(*)")
// should be treated as just the tool name (tool-wide rule)
if (rawContent === '' || rawContent === '*') {
return { toolName: normalizeLegacyToolName(toolName) }
}
// Unescape the content
const ruleContent = unescapeRuleContent(rawContent)
return { toolName: normalizeLegacyToolName(toolName), ruleContent }
}
/**
* Converts a permission rule value to its string representation.
* Escapes parentheses in the content to prevent parsing issues.
*
* @example
* permissionRuleValueToString({ toolName: 'Bash' }) // => 'Bash'
* permissionRuleValueToString({ toolName: 'Bash', ruleContent: 'npm install' }) // => 'Bash(npm install)'
* permissionRuleValueToString({ toolName: 'Bash', ruleContent: 'python -c "print(1)"' }) // => 'Bash(python -c "print\\(1\\)")'
*/
export function permissionRuleValueToString(
ruleValue: PermissionRuleValue,
): string {
if (!ruleValue.ruleContent) {
return ruleValue.toolName
}
const escapedContent = escapeRuleContent(ruleValue.ruleContent)
return `${ruleValue.toolName}(${escapedContent})`
}
/**
* Find the index of the first unescaped occurrence of a character.
* A character is escaped if preceded by an odd number of backslashes.
*/
function findFirstUnescapedChar(str: string, char: string): number {
for (let i = 0; i < str.length; i++) {
if (str[i] === char) {
// Count preceding backslashes
let backslashCount = 0
let j = i - 1
while (j >= 0 && str[j] === '\\') {
backslashCount++
j--
}
// If even number of backslashes, the char is unescaped
if (backslashCount % 2 === 0) {
return i
}
}
}
return -1
}
/**
* Find the index of the last unescaped occurrence of a character.
* A character is escaped if preceded by an odd number of backslashes.
*/
function findLastUnescapedChar(str: string, char: string): number {
for (let i = str.length - 1; i >= 0; i--) {
if (str[i] === char) {
// Count preceding backslashes
let backslashCount = 0
let j = i - 1
while (j >= 0 && str[j] === '\\') {
backslashCount++
j--
}
// If even number of backslashes, the char is unescaped
if (backslashCount % 2 === 0) {
return i
}
}
}
return -1
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,296 @@
import { readFileSync } from '../fileRead.js'
import { getFsImplementation, safeResolvePath } from '../fsOperations.js'
import { safeParseJSON } from '../json.js'
import { logError } from '../log.js'
import {
type EditableSettingSource,
getEnabledSettingSources,
type SettingSource,
} from '../settings/constants.js'
import {
getSettingsFilePathForSource,
getSettingsForSource,
updateSettingsForSource,
} from '../settings/settings.js'
import type { SettingsJson } from '../settings/types.js'
import type {
PermissionBehavior,
PermissionRule,
PermissionRuleSource,
PermissionRuleValue,
} from './PermissionRule.js'
import {
permissionRuleValueFromString,
permissionRuleValueToString,
} from './permissionRuleParser.js'
/**
* Returns true if allowManagedPermissionRulesOnly is enabled in managed settings (policySettings).
* When enabled, only permission rules from managed settings are respected.
*/
export function shouldAllowManagedPermissionRulesOnly(): boolean {
return (
getSettingsForSource('policySettings')?.allowManagedPermissionRulesOnly ===
true
)
}
/**
* Returns true if "always allow" options should be shown in permission prompts.
* When allowManagedPermissionRulesOnly is enabled, these options are hidden.
*/
export function shouldShowAlwaysAllowOptions(): boolean {
return !shouldAllowManagedPermissionRulesOnly()
}
const SUPPORTED_RULE_BEHAVIORS = [
'allow',
'deny',
'ask',
] as const satisfies PermissionBehavior[]
/**
* Lenient version of getSettingsForSource that doesn't fail on ANY validation errors.
* Simply parses the JSON and returns it as-is without schema validation.
*
* Used when loading settings to append new rules (avoids losing existing rules
* due to validation failures in unrelated fields like hooks).
*
* FOR EDITING ONLY - do not use this for reading settings for execution.
*/
function getSettingsForSourceLenient_FOR_EDITING_ONLY_NOT_FOR_READING(
source: SettingSource,
): SettingsJson | null {
const filePath = getSettingsFilePathForSource(source)
if (!filePath) {
return null
}
try {
const { resolvedPath } = safeResolvePath(getFsImplementation(), filePath)
const content = readFileSync(resolvedPath)
if (content.trim() === '') {
return {}
}
const data = safeParseJSON(content, false)
// Return raw parsed JSON without validation to preserve all existing settings
// This is safe because we're only using this for reading/appending, not for execution
return data && typeof data === 'object' ? (data as SettingsJson) : null
} catch {
return null
}
}
/**
* Converts permissions JSON to an array of PermissionRule objects
* @param data The parsed permissions data
* @param source The source of these rules
* @returns Array of PermissionRule objects
*/
function settingsJsonToRules(
data: SettingsJson | null,
source: PermissionRuleSource,
): PermissionRule[] {
if (!data || !data.permissions) {
return []
}
const { permissions } = data
const rules: PermissionRule[] = []
for (const behavior of SUPPORTED_RULE_BEHAVIORS) {
const behaviorArray = permissions[behavior]
if (behaviorArray) {
for (const ruleString of behaviorArray) {
rules.push({
source,
ruleBehavior: behavior,
ruleValue: permissionRuleValueFromString(ruleString),
})
}
}
}
return rules
}
/**
* Loads all permission rules from all relevant sources (managed and project settings)
* @returns Array of all permission rules
*/
export function loadAllPermissionRulesFromDisk(): PermissionRule[] {
// If allowManagedPermissionRulesOnly is set, only use managed permission rules
if (shouldAllowManagedPermissionRulesOnly()) {
return getPermissionRulesForSource('policySettings')
}
// Otherwise, load from all enabled sources (backwards compatible)
const rules: PermissionRule[] = []
for (const source of getEnabledSettingSources()) {
rules.push(...getPermissionRulesForSource(source))
}
return rules
}
/**
* Loads permission rules from a specific source
* @param source The source to load from
* @returns Array of permission rules from that source
*/
export function getPermissionRulesForSource(
source: SettingSource,
): PermissionRule[] {
const settingsData = getSettingsForSource(source)
return settingsJsonToRules(settingsData, source)
}
export type PermissionRuleFromEditableSettings = PermissionRule & {
source: EditableSettingSource
}
// Editable sources that can be modified (excludes policySettings and flagSettings)
const EDITABLE_SOURCES: EditableSettingSource[] = [
'userSettings',
'projectSettings',
'localSettings',
]
/**
* Deletes a rule from the project permissions file
* @param rule The rule to delete
* @returns Promise resolving to a boolean indicating success
*/
export function deletePermissionRuleFromSettings(
rule: PermissionRuleFromEditableSettings,
): boolean {
// Runtime check to ensure source is actually editable
if (!EDITABLE_SOURCES.includes(rule.source as EditableSettingSource)) {
return false
}
const ruleString = permissionRuleValueToString(rule.ruleValue)
const settingsData = getSettingsForSource(rule.source)
// If there's no settings data or permissions, nothing to do
if (!settingsData || !settingsData.permissions) {
return false
}
const behaviorArray = settingsData.permissions[rule.ruleBehavior]
if (!behaviorArray) {
return false
}
// Normalize raw settings entries via roundtrip parse→serialize so legacy
// names (e.g. "KillShell") match their canonical form ("TaskStop").
const normalizeEntry = (raw: string): string =>
permissionRuleValueToString(permissionRuleValueFromString(raw))
if (!behaviorArray.some(raw => normalizeEntry(raw) === ruleString)) {
return false
}
try {
// Keep a copy of the original permissions data to preserve unrecognized keys
const updatedSettingsData = {
...settingsData,
permissions: {
...settingsData.permissions,
[rule.ruleBehavior]: behaviorArray.filter(
raw => normalizeEntry(raw) !== ruleString,
),
},
}
const { error } = updateSettingsForSource(rule.source, updatedSettingsData)
if (error) {
// Error already logged inside updateSettingsForSource
return false
}
return true
} catch (error) {
logError(error)
return false
}
}
function getEmptyPermissionSettingsJson(): SettingsJson {
return {
permissions: {},
}
}
/**
* Adds rules to the project permissions file
* @param ruleValues The rule values to add
* @returns Promise resolving to a boolean indicating success
*/
export function addPermissionRulesToSettings(
{
ruleValues,
ruleBehavior,
}: {
ruleValues: PermissionRuleValue[]
ruleBehavior: PermissionBehavior
},
source: EditableSettingSource,
): boolean {
// When allowManagedPermissionRulesOnly is enabled, don't persist new permission rules
if (shouldAllowManagedPermissionRulesOnly()) {
return false
}
if (ruleValues.length < 1) {
// No rules to add
return true
}
const ruleStrings = ruleValues.map(permissionRuleValueToString)
// First try the normal settings loader which validates the schema
// If validation fails, fall back to lenient loading to preserve existing rules
// even if some fields (like hooks) have validation errors
const settingsData =
getSettingsForSource(source) ||
getSettingsForSourceLenient_FOR_EDITING_ONLY_NOT_FOR_READING(source) ||
getEmptyPermissionSettingsJson()
try {
// Ensure permissions object exists
const existingPermissions = settingsData.permissions || {}
const existingRules = existingPermissions[ruleBehavior] || []
// Filter out duplicates - normalize existing entries via roundtrip
// parse→serialize so legacy names match their canonical form.
const existingRulesSet = new Set(
existingRules.map(raw =>
permissionRuleValueToString(permissionRuleValueFromString(raw)),
),
)
const newRules = ruleStrings.filter(rule => !existingRulesSet.has(rule))
// If no new rules to add, return success
if (newRules.length === 0) {
return true
}
// Keep a copy of the original settings data to preserve unrecognized keys
const updatedSettingsData = {
...settingsData,
permissions: {
...existingPermissions,
[ruleBehavior]: [...existingRules, ...newRules],
},
}
const result = updateSettingsForSource(source, updatedSettingsData)
if (result.error) {
throw result.error
}
return true
} catch (error) {
logError(error)
return false
}
}

View File

@@ -0,0 +1,234 @@
import type { ToolPermissionContext } from '../../Tool.js'
import { BASH_TOOL_NAME } from '../../tools/BashTool/toolName.js'
import type { PermissionRule, PermissionRuleSource } from './PermissionRule.js'
import {
getAllowRules,
getAskRules,
getDenyRules,
permissionRuleSourceDisplayString,
} from './permissions.js'
/**
* Type of shadowing that makes a rule unreachable
*/
export type ShadowType = 'ask' | 'deny'
/**
* Represents an unreachable permission rule with explanation
*/
export type UnreachableRule = {
rule: PermissionRule
reason: string
shadowedBy: PermissionRule
shadowType: ShadowType
fix: string
}
/**
* Options for detecting unreachable rules
*/
export type DetectUnreachableRulesOptions = {
/**
* Whether sandbox auto-allow is enabled for Bash commands.
* When true, tool-wide Bash ask rules from personal settings don't block
* specific Bash allow rules because sandboxed commands are auto-allowed.
*/
sandboxAutoAllowEnabled: boolean
}
/**
* Result of checking if a rule is shadowed.
* Uses discriminated union for type safety.
*/
type ShadowResult =
| { shadowed: false }
| { shadowed: true; shadowedBy: PermissionRule; shadowType: ShadowType }
/**
* Check if a permission rule source is shared (visible to other users).
* Shared settings include:
* - projectSettings: Committed to git, shared with team
* - policySettings: Enterprise-managed, pushed to all users
* - command: From slash command frontmatter, potentially shared
*
* Personal settings include:
* - userSettings: User's global ~/.claude settings
* - localSettings: Gitignored per-project settings
* - cliArg: Runtime CLI arguments
* - session: In-memory session rules
* - flagSettings: From --settings flag (runtime)
*/
export function isSharedSettingSource(source: PermissionRuleSource): boolean {
return (
source === 'projectSettings' ||
source === 'policySettings' ||
source === 'command'
)
}
/**
* Format a rule source for display in warning messages.
*/
function formatSource(source: PermissionRuleSource): string {
return permissionRuleSourceDisplayString(source)
}
/**
* Generate a fix suggestion based on the shadow type.
*/
function generateFixSuggestion(
shadowType: ShadowType,
shadowingRule: PermissionRule,
shadowedRule: PermissionRule,
): string {
const shadowingSource = formatSource(shadowingRule.source)
const shadowedSource = formatSource(shadowedRule.source)
const toolName = shadowingRule.ruleValue.toolName
if (shadowType === 'deny') {
return `Remove the "${toolName}" deny rule from ${shadowingSource}, or remove the specific allow rule from ${shadowedSource}`
}
return `Remove the "${toolName}" ask rule from ${shadowingSource}, or remove the specific allow rule from ${shadowedSource}`
}
/**
* Check if a specific allow rule is shadowed (unreachable) by an ask rule.
*
* An allow rule is unreachable when:
* 1. There's a tool-wide ask rule (e.g., "Bash" in ask list)
* 2. And a specific allow rule (e.g., "Bash(ls:*)" in allow list)
*
* The ask rule takes precedence, making the specific allow rule unreachable
* because the user will always be prompted first.
*
* Exception: For Bash with sandbox auto-allow enabled, tool-wide ask rules
* from PERSONAL settings don't shadow specific allow rules because:
* - Sandboxed commands are auto-allowed regardless of ask rules
* - This only applies to personal settings (userSettings, localSettings, etc.)
* - Shared settings (projectSettings, policySettings) always warn because
* other team members may not have sandbox enabled
*/
function isAllowRuleShadowedByAskRule(
allowRule: PermissionRule,
askRules: PermissionRule[],
options: DetectUnreachableRulesOptions,
): ShadowResult {
const { toolName, ruleContent } = allowRule.ruleValue
// Only check allow rules that have specific content (e.g., "Bash(ls:*)")
// Tool-wide allow rules cannot be shadowed by ask rules
if (ruleContent === undefined) {
return { shadowed: false }
}
// Find any tool-wide ask rule for the same tool
const shadowingAskRule = askRules.find(
askRule =>
askRule.ruleValue.toolName === toolName &&
askRule.ruleValue.ruleContent === undefined,
)
if (!shadowingAskRule) {
return { shadowed: false }
}
// Special case: Bash with sandbox auto-allow from personal settings
// The sandbox exception is based on the ASK rule's source, not the allow rule's source.
// If the ask rule is from personal settings, the user's own sandbox will auto-allow.
// If the ask rule is from shared settings, other team members may not have sandbox enabled.
if (toolName === BASH_TOOL_NAME && options.sandboxAutoAllowEnabled) {
if (!isSharedSettingSource(shadowingAskRule.source)) {
return { shadowed: false }
}
// Fall through to mark as shadowed - shared settings should always warn
}
return { shadowed: true, shadowedBy: shadowingAskRule, shadowType: 'ask' }
}
/**
* Check if an allow rule is shadowed (completely blocked) by a deny rule.
*
* An allow rule is unreachable when:
* 1. There's a tool-wide deny rule (e.g., "Bash" in deny list)
* 2. And a specific allow rule (e.g., "Bash(ls:*)" in allow list)
*
* Deny rules are checked first in the permission evaluation order,
* so the allow rule will never be reached - the tool is always denied.
* This is more severe than ask-shadowing because the rule is truly blocked.
*/
function isAllowRuleShadowedByDenyRule(
allowRule: PermissionRule,
denyRules: PermissionRule[],
): ShadowResult {
const { toolName, ruleContent } = allowRule.ruleValue
// Only check allow rules that have specific content (e.g., "Bash(ls:*)")
// Tool-wide allow rules conflict with tool-wide deny rules but are not "shadowed"
if (ruleContent === undefined) {
return { shadowed: false }
}
// Find any tool-wide deny rule for the same tool
const shadowingDenyRule = denyRules.find(
denyRule =>
denyRule.ruleValue.toolName === toolName &&
denyRule.ruleValue.ruleContent === undefined,
)
if (!shadowingDenyRule) {
return { shadowed: false }
}
return { shadowed: true, shadowedBy: shadowingDenyRule, shadowType: 'deny' }
}
/**
* Detect all unreachable permission rules in the given context.
*
* Currently detects:
* - Allow rules shadowed by tool-wide deny rules (more severe - completely blocked)
* - Allow rules shadowed by tool-wide ask rules (will always prompt)
*/
export function detectUnreachableRules(
context: ToolPermissionContext,
options: DetectUnreachableRulesOptions,
): UnreachableRule[] {
const unreachable: UnreachableRule[] = []
const allowRules = getAllowRules(context)
const askRules = getAskRules(context)
const denyRules = getDenyRules(context)
// Check each allow rule for shadowing
for (const allowRule of allowRules) {
// Check deny shadowing first (more severe)
const denyResult = isAllowRuleShadowedByDenyRule(allowRule, denyRules)
if (denyResult.shadowed) {
const shadowSource = formatSource(denyResult.shadowedBy.source)
unreachable.push({
rule: allowRule,
reason: `Blocked by "${denyResult.shadowedBy.ruleValue.toolName}" deny rule (from ${shadowSource})`,
shadowedBy: denyResult.shadowedBy,
shadowType: 'deny',
fix: generateFixSuggestion('deny', denyResult.shadowedBy, allowRule),
})
continue // Don't also report ask-shadowing if deny-shadowed
}
// Check ask shadowing
const askResult = isAllowRuleShadowedByAskRule(allowRule, askRules, options)
if (askResult.shadowed) {
const shadowSource = formatSource(askResult.shadowedBy.source)
unreachable.push({
rule: allowRule,
reason: `Shadowed by "${askResult.shadowedBy.ruleValue.toolName}" ask rule (from ${shadowSource})`,
shadowedBy: askResult.shadowedBy,
shadowType: 'ask',
fix: generateFixSuggestion('ask', askResult.shadowedBy, allowRule),
})
}
}
return unreachable
}

View File

@@ -0,0 +1,228 @@
/**
* Shared permission rule matching utilities for shell tools.
*
* Extracts common logic for:
* - Parsing permission rules (exact, prefix, wildcard)
* - Matching commands against rules
* - Generating permission suggestions
*/
import type { PermissionUpdate } from './PermissionUpdateSchema.js'
// Null-byte sentinel placeholders for wildcard pattern escaping — module-level
// so the RegExp objects are compiled once instead of per permission check.
const ESCAPED_STAR_PLACEHOLDER = '\x00ESCAPED_STAR\x00'
const ESCAPED_BACKSLASH_PLACEHOLDER = '\x00ESCAPED_BACKSLASH\x00'
const ESCAPED_STAR_PLACEHOLDER_RE = new RegExp(ESCAPED_STAR_PLACEHOLDER, 'g')
const ESCAPED_BACKSLASH_PLACEHOLDER_RE = new RegExp(
ESCAPED_BACKSLASH_PLACEHOLDER,
'g',
)
/**
* Parsed permission rule discriminated union.
*/
export type ShellPermissionRule =
| {
type: 'exact'
command: string
}
| {
type: 'prefix'
prefix: string
}
| {
type: 'wildcard'
pattern: string
}
/**
* Extract prefix from legacy :* syntax (e.g., "npm:*" -> "npm")
* This is maintained for backwards compatibility.
*/
export function permissionRuleExtractPrefix(
permissionRule: string,
): string | null {
const match = permissionRule.match(/^(.+):\*$/)
return match?.[1] ?? null
}
/**
* Check if a pattern contains unescaped wildcards (not legacy :* syntax).
* Returns true if the pattern contains * that are not escaped with \ or part of :* at the end.
*/
export function hasWildcards(pattern: string): boolean {
// If it ends with :*, it's legacy prefix syntax, not wildcard
if (pattern.endsWith(':*')) {
return false
}
// Check for unescaped * anywhere in the pattern
// An asterisk is unescaped if it's not preceded by a backslash,
// or if it's preceded by an even number of backslashes (escaped backslashes)
for (let i = 0; i < pattern.length; i++) {
if (pattern[i] === '*') {
// Count backslashes before this asterisk
let backslashCount = 0
let j = i - 1
while (j >= 0 && pattern[j] === '\\') {
backslashCount++
j--
}
// If even number of backslashes (including 0), the asterisk is unescaped
if (backslashCount % 2 === 0) {
return true
}
}
}
return false
}
/**
* Match a command against a wildcard pattern.
* Wildcards (*) match any sequence of characters.
* Use \* to match a literal asterisk character.
* Use \\ to match a literal backslash.
*
* @param pattern - The permission rule pattern with wildcards
* @param command - The command to match against
* @returns true if the command matches the pattern
*/
export function matchWildcardPattern(
pattern: string,
command: string,
caseInsensitive = false,
): boolean {
// Trim leading/trailing whitespace from pattern
const trimmedPattern = pattern.trim()
// Process the pattern to handle escape sequences: \* and \\
let processed = ''
let i = 0
while (i < trimmedPattern.length) {
const char = trimmedPattern[i]
// Handle escape sequences
if (char === '\\' && i + 1 < trimmedPattern.length) {
const nextChar = trimmedPattern[i + 1]
if (nextChar === '*') {
// \* -> literal asterisk placeholder
processed += ESCAPED_STAR_PLACEHOLDER
i += 2
continue
} else if (nextChar === '\\') {
// \\ -> literal backslash placeholder
processed += ESCAPED_BACKSLASH_PLACEHOLDER
i += 2
continue
}
}
processed += char
i++
}
// Escape regex special characters except *
const escaped = processed.replace(/[.+?^${}()|[\]\\'"]/g, '\\$&')
// Convert unescaped * to .* for wildcard matching
const withWildcards = escaped.replace(/\*/g, '.*')
// Convert placeholders back to escaped regex literals
let regexPattern = withWildcards
.replace(ESCAPED_STAR_PLACEHOLDER_RE, '\\*')
.replace(ESCAPED_BACKSLASH_PLACEHOLDER_RE, '\\\\')
// When a pattern ends with ' *' (space + unescaped wildcard) AND the trailing
// wildcard is the ONLY unescaped wildcard, make the trailing space-and-args
// optional so 'git *' matches both 'git add' and bare 'git'.
// This aligns wildcard matching with prefix rule semantics (git:*).
// Multi-wildcard patterns like '* run *' are excluded — making the last
// wildcard optional would incorrectly match 'npm run' (no trailing arg).
const unescapedStarCount = (processed.match(/\*/g) || []).length
if (regexPattern.endsWith(' .*') && unescapedStarCount === 1) {
regexPattern = regexPattern.slice(0, -3) + '( .*)?'
}
// Create regex that matches the entire string.
// The 's' (dotAll) flag makes '.' match newlines, so wildcards match
// commands containing embedded newlines (e.g. heredoc content after splitCommand_DEPRECATED).
const flags = 's' + (caseInsensitive ? 'i' : '')
const regex = new RegExp(`^${regexPattern}$`, flags)
return regex.test(command)
}
/**
* Parse a permission rule string into a structured rule object.
*/
export function parsePermissionRule(
permissionRule: string,
): ShellPermissionRule {
// Check for legacy :* prefix syntax first (backwards compatibility)
const prefix = permissionRuleExtractPrefix(permissionRule)
if (prefix !== null) {
return {
type: 'prefix',
prefix,
}
}
// Check for new wildcard syntax (contains * but not :* at end)
if (hasWildcards(permissionRule)) {
return {
type: 'wildcard',
pattern: permissionRule,
}
}
// Otherwise, it's an exact match
return {
type: 'exact',
command: permissionRule,
}
}
/**
* Generate permission update suggestion for an exact command match.
*/
export function suggestionForExactCommand(
toolName: string,
command: string,
): PermissionUpdate[] {
return [
{
type: 'addRules',
rules: [
{
toolName,
ruleContent: command,
},
],
behavior: 'allow',
destination: 'localSettings',
},
]
}
/**
* Generate permission update suggestion for a prefix match.
*/
export function suggestionForPrefix(
toolName: string,
prefix: string,
): PermissionUpdate[] {
return [
{
type: 'addRules',
rules: [
{
toolName,
ruleContent: `${prefix}:*`,
},
],
behavior: 'allow',
destination: 'localSettings',
},
]
}

File diff suppressed because it is too large Load Diff