/ utils / permissions / shadowedRuleDetection.ts
shadowedRuleDetection.ts
  1  import type { ToolPermissionContext } from '../../Tool.js'
  2  import { BASH_TOOL_NAME } from '../../tools/BashTool/toolName.js'
  3  import type { PermissionRule, PermissionRuleSource } from './PermissionRule.js'
  4  import {
  5    getAllowRules,
  6    getAskRules,
  7    getDenyRules,
  8    permissionRuleSourceDisplayString,
  9  } from './permissions.js'
 10  
 11  /**
 12   * Type of shadowing that makes a rule unreachable
 13   */
 14  export type ShadowType = 'ask' | 'deny'
 15  
 16  /**
 17   * Represents an unreachable permission rule with explanation
 18   */
 19  export type UnreachableRule = {
 20    rule: PermissionRule
 21    reason: string
 22    shadowedBy: PermissionRule
 23    shadowType: ShadowType
 24    fix: string
 25  }
 26  
 27  /**
 28   * Options for detecting unreachable rules
 29   */
 30  export type DetectUnreachableRulesOptions = {
 31    /**
 32     * Whether sandbox auto-allow is enabled for Bash commands.
 33     * When true, tool-wide Bash ask rules from personal settings don't block
 34     * specific Bash allow rules because sandboxed commands are auto-allowed.
 35     */
 36    sandboxAutoAllowEnabled: boolean
 37  }
 38  
 39  /**
 40   * Result of checking if a rule is shadowed.
 41   * Uses discriminated union for type safety.
 42   */
 43  type ShadowResult =
 44    | { shadowed: false }
 45    | { shadowed: true; shadowedBy: PermissionRule; shadowType: ShadowType }
 46  
 47  /**
 48   * Check if a permission rule source is shared (visible to other users).
 49   * Shared settings include:
 50   * - projectSettings: Committed to git, shared with team
 51   * - policySettings: Enterprise-managed, pushed to all users
 52   * - command: From slash command frontmatter, potentially shared
 53   *
 54   * Personal settings include:
 55   * - userSettings: User's global ~/.claude settings
 56   * - localSettings: Gitignored per-project settings
 57   * - cliArg: Runtime CLI arguments
 58   * - session: In-memory session rules
 59   * - flagSettings: From --settings flag (runtime)
 60   */
 61  export function isSharedSettingSource(source: PermissionRuleSource): boolean {
 62    return (
 63      source === 'projectSettings' ||
 64      source === 'policySettings' ||
 65      source === 'command'
 66    )
 67  }
 68  
 69  /**
 70   * Format a rule source for display in warning messages.
 71   */
 72  function formatSource(source: PermissionRuleSource): string {
 73    return permissionRuleSourceDisplayString(source)
 74  }
 75  
 76  /**
 77   * Generate a fix suggestion based on the shadow type.
 78   */
 79  function generateFixSuggestion(
 80    shadowType: ShadowType,
 81    shadowingRule: PermissionRule,
 82    shadowedRule: PermissionRule,
 83  ): string {
 84    const shadowingSource = formatSource(shadowingRule.source)
 85    const shadowedSource = formatSource(shadowedRule.source)
 86    const toolName = shadowingRule.ruleValue.toolName
 87  
 88    if (shadowType === 'deny') {
 89      return `Remove the "${toolName}" deny rule from ${shadowingSource}, or remove the specific allow rule from ${shadowedSource}`
 90    }
 91    return `Remove the "${toolName}" ask rule from ${shadowingSource}, or remove the specific allow rule from ${shadowedSource}`
 92  }
 93  
 94  /**
 95   * Check if a specific allow rule is shadowed (unreachable) by an ask rule.
 96   *
 97   * An allow rule is unreachable when:
 98   * 1. There's a tool-wide ask rule (e.g., "Bash" in ask list)
 99   * 2. And a specific allow rule (e.g., "Bash(ls:*)" in allow list)
100   *
101   * The ask rule takes precedence, making the specific allow rule unreachable
102   * because the user will always be prompted first.
103   *
104   * Exception: For Bash with sandbox auto-allow enabled, tool-wide ask rules
105   * from PERSONAL settings don't shadow specific allow rules because:
106   * - Sandboxed commands are auto-allowed regardless of ask rules
107   * - This only applies to personal settings (userSettings, localSettings, etc.)
108   * - Shared settings (projectSettings, policySettings) always warn because
109   *   other team members may not have sandbox enabled
110   */
111  function isAllowRuleShadowedByAskRule(
112    allowRule: PermissionRule,
113    askRules: PermissionRule[],
114    options: DetectUnreachableRulesOptions,
115  ): ShadowResult {
116    const { toolName, ruleContent } = allowRule.ruleValue
117  
118    // Only check allow rules that have specific content (e.g., "Bash(ls:*)")
119    // Tool-wide allow rules cannot be shadowed by ask rules
120    if (ruleContent === undefined) {
121      return { shadowed: false }
122    }
123  
124    // Find any tool-wide ask rule for the same tool
125    const shadowingAskRule = askRules.find(
126      askRule =>
127        askRule.ruleValue.toolName === toolName &&
128        askRule.ruleValue.ruleContent === undefined,
129    )
130  
131    if (!shadowingAskRule) {
132      return { shadowed: false }
133    }
134  
135    // Special case: Bash with sandbox auto-allow from personal settings
136    // The sandbox exception is based on the ASK rule's source, not the allow rule's source.
137    // If the ask rule is from personal settings, the user's own sandbox will auto-allow.
138    // If the ask rule is from shared settings, other team members may not have sandbox enabled.
139    if (toolName === BASH_TOOL_NAME && options.sandboxAutoAllowEnabled) {
140      if (!isSharedSettingSource(shadowingAskRule.source)) {
141        return { shadowed: false }
142      }
143      // Fall through to mark as shadowed - shared settings should always warn
144    }
145  
146    return { shadowed: true, shadowedBy: shadowingAskRule, shadowType: 'ask' }
147  }
148  
149  /**
150   * Check if an allow rule is shadowed (completely blocked) by a deny rule.
151   *
152   * An allow rule is unreachable when:
153   * 1. There's a tool-wide deny rule (e.g., "Bash" in deny list)
154   * 2. And a specific allow rule (e.g., "Bash(ls:*)" in allow list)
155   *
156   * Deny rules are checked first in the permission evaluation order,
157   * so the allow rule will never be reached - the tool is always denied.
158   * This is more severe than ask-shadowing because the rule is truly blocked.
159   */
160  function isAllowRuleShadowedByDenyRule(
161    allowRule: PermissionRule,
162    denyRules: PermissionRule[],
163  ): ShadowResult {
164    const { toolName, ruleContent } = allowRule.ruleValue
165  
166    // Only check allow rules that have specific content (e.g., "Bash(ls:*)")
167    // Tool-wide allow rules conflict with tool-wide deny rules but are not "shadowed"
168    if (ruleContent === undefined) {
169      return { shadowed: false }
170    }
171  
172    // Find any tool-wide deny rule for the same tool
173    const shadowingDenyRule = denyRules.find(
174      denyRule =>
175        denyRule.ruleValue.toolName === toolName &&
176        denyRule.ruleValue.ruleContent === undefined,
177    )
178  
179    if (!shadowingDenyRule) {
180      return { shadowed: false }
181    }
182  
183    return { shadowed: true, shadowedBy: shadowingDenyRule, shadowType: 'deny' }
184  }
185  
186  /**
187   * Detect all unreachable permission rules in the given context.
188   *
189   * Currently detects:
190   * - Allow rules shadowed by tool-wide deny rules (more severe - completely blocked)
191   * - Allow rules shadowed by tool-wide ask rules (will always prompt)
192   */
193  export function detectUnreachableRules(
194    context: ToolPermissionContext,
195    options: DetectUnreachableRulesOptions,
196  ): UnreachableRule[] {
197    const unreachable: UnreachableRule[] = []
198  
199    const allowRules = getAllowRules(context)
200    const askRules = getAskRules(context)
201    const denyRules = getDenyRules(context)
202  
203    // Check each allow rule for shadowing
204    for (const allowRule of allowRules) {
205      // Check deny shadowing first (more severe)
206      const denyResult = isAllowRuleShadowedByDenyRule(allowRule, denyRules)
207      if (denyResult.shadowed) {
208        const shadowSource = formatSource(denyResult.shadowedBy.source)
209        unreachable.push({
210          rule: allowRule,
211          reason: `Blocked by "${denyResult.shadowedBy.ruleValue.toolName}" deny rule (from ${shadowSource})`,
212          shadowedBy: denyResult.shadowedBy,
213          shadowType: 'deny',
214          fix: generateFixSuggestion('deny', denyResult.shadowedBy, allowRule),
215        })
216        continue // Don't also report ask-shadowing if deny-shadowed
217      }
218  
219      // Check ask shadowing
220      const askResult = isAllowRuleShadowedByAskRule(allowRule, askRules, options)
221      if (askResult.shadowed) {
222        const shadowSource = formatSource(askResult.shadowedBy.source)
223        unreachable.push({
224          rule: allowRule,
225          reason: `Shadowed by "${askResult.shadowedBy.ruleValue.toolName}" ask rule (from ${shadowSource})`,
226          shadowedBy: askResult.shadowedBy,
227          shadowType: 'ask',
228          fix: generateFixSuggestion('ask', askResult.shadowedBy, allowRule),
229        })
230      }
231    }
232  
233    return unreachable
234  }