chore: initialize recovered claude workspace
This commit is contained in:
141
src/utils/permissions/PermissionMode.ts
Normal file
141
src/utils/permissions/PermissionMode.ts
Normal 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
|
||||
}
|
||||
127
src/utils/permissions/PermissionPromptToolResultSchema.ts
Normal file
127
src/utils/permissions/PermissionPromptToolResultSchema.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
35
src/utils/permissions/PermissionResult.ts
Normal file
35
src/utils/permissions/PermissionResult.ts
Normal 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'
|
||||
}
|
||||
}
|
||||
40
src/utils/permissions/PermissionRule.ts
Normal file
40
src/utils/permissions/PermissionRule.ts
Normal 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(),
|
||||
}),
|
||||
)
|
||||
389
src/utils/permissions/PermissionUpdate.ts
Normal file
389
src/utils/permissions/PermissionUpdate.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
78
src/utils/permissions/PermissionUpdateSchema.ts
Normal file
78
src/utils/permissions/PermissionUpdateSchema.ts
Normal 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(),
|
||||
}),
|
||||
]),
|
||||
)
|
||||
39
src/utils/permissions/autoModeState.ts
Normal file
39
src/utils/permissions/autoModeState.ts
Normal 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
|
||||
}
|
||||
61
src/utils/permissions/bashClassifier.ts
Normal file
61
src/utils/permissions/bashClassifier.ts
Normal 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
|
||||
}
|
||||
155
src/utils/permissions/bypassPermissionsKillswitch.ts
Normal file
155
src/utils/permissions/bypassPermissionsKillswitch.ts
Normal 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])
|
||||
}
|
||||
98
src/utils/permissions/classifierDecision.ts
Normal file
98
src/utils/permissions/classifierDecision.ts
Normal 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)
|
||||
}
|
||||
39
src/utils/permissions/classifierShared.ts
Normal file
39
src/utils/permissions/classifierShared.ts
Normal 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
|
||||
}
|
||||
80
src/utils/permissions/dangerousPatterns.ts
Normal file
80
src/utils/permissions/dangerousPatterns.ts
Normal 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',
|
||||
]
|
||||
: []),
|
||||
]
|
||||
45
src/utils/permissions/denialTracking.ts
Normal file
45
src/utils/permissions/denialTracking.ts
Normal 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
|
||||
)
|
||||
}
|
||||
1777
src/utils/permissions/filesystem.ts
Normal file
1777
src/utils/permissions/filesystem.ts
Normal file
File diff suppressed because it is too large
Load Diff
101
src/utils/permissions/getNextPermissionMode.ts
Normal file
101
src/utils/permissions/getNextPermissionMode.ts
Normal 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,
|
||||
),
|
||||
}
|
||||
}
|
||||
485
src/utils/permissions/pathValidation.ts
Normal file
485
src/utils/permissions/pathValidation.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
250
src/utils/permissions/permissionExplainer.ts
Normal file
250
src/utils/permissions/permissionExplainer.ts
Normal 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
|
||||
}
|
||||
}
|
||||
198
src/utils/permissions/permissionRuleParser.ts
Normal file
198
src/utils/permissions/permissionRuleParser.ts
Normal 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
|
||||
}
|
||||
1532
src/utils/permissions/permissionSetup.ts
Normal file
1532
src/utils/permissions/permissionSetup.ts
Normal file
File diff suppressed because it is too large
Load Diff
1486
src/utils/permissions/permissions.ts
Normal file
1486
src/utils/permissions/permissions.ts
Normal file
File diff suppressed because it is too large
Load Diff
296
src/utils/permissions/permissionsLoader.ts
Normal file
296
src/utils/permissions/permissionsLoader.ts
Normal 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
|
||||
}
|
||||
}
|
||||
234
src/utils/permissions/shadowedRuleDetection.ts
Normal file
234
src/utils/permissions/shadowedRuleDetection.ts
Normal 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
|
||||
}
|
||||
228
src/utils/permissions/shellRuleMatching.ts
Normal file
228
src/utils/permissions/shellRuleMatching.ts
Normal 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',
|
||||
},
|
||||
]
|
||||
}
|
||||
1495
src/utils/permissions/yoloClassifier.ts
Normal file
1495
src/utils/permissions/yoloClassifier.ts
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user