/ src / utils / permissions / PermissionPromptToolResultSchema.ts
PermissionPromptToolResultSchema.ts
  1  import type { Tool, ToolUseContext } from 'src/Tool.js'
  2  import z from 'zod/v4'
  3  import { logForDebugging } from '../debug.js'
  4  import { lazySchema } from '../lazySchema.js'
  5  import type {
  6    PermissionDecision,
  7    PermissionDecisionReason,
  8  } from './PermissionResult.js'
  9  import {
 10    applyPermissionUpdates,
 11    persistPermissionUpdates,
 12  } from './PermissionUpdate.js'
 13  import { permissionUpdateSchema } from './PermissionUpdateSchema.js'
 14  
 15  export const inputSchema = lazySchema(() =>
 16    z.object({
 17      tool_name: z
 18        .string()
 19        .describe('The name of the tool requesting permission'),
 20      input: z.record(z.string(), z.unknown()).describe('The input for the tool'),
 21      tool_use_id: z
 22        .string()
 23        .optional()
 24        .describe('The unique tool use request ID'),
 25    }),
 26  )
 27  
 28  export type Input = z.infer<ReturnType<typeof inputSchema>>
 29  
 30  // Zod schema for permission results
 31  // This schema is used to validate the MCP permission prompt tool
 32  // so we maintain it as a subset of the real PermissionDecision type
 33  
 34  // Matches PermissionDecisionClassificationSchema in entrypoints/sdk/coreSchemas.ts.
 35  // Malformed values fall through to undefined (same pattern as updatedPermissions
 36  // below) so a bad string from the SDK host doesn't reject the whole decision.
 37  const decisionClassificationField = lazySchema(() =>
 38    z
 39      .enum(['user_temporary', 'user_permanent', 'user_reject'])
 40      .optional()
 41      .catch(undefined),
 42  )
 43  
 44  const PermissionAllowResultSchema = lazySchema(() =>
 45    z.object({
 46      behavior: z.literal('allow'),
 47      updatedInput: z.record(z.string(), z.unknown()),
 48      // SDK hosts may send malformed entries; fall back to undefined rather
 49      // than rejecting the entire allow decision (anthropics/claude-code#29440)
 50      updatedPermissions: z
 51        .array(permissionUpdateSchema())
 52        .optional()
 53        .catch(ctx => {
 54          logForDebugging(
 55            `Malformed updatedPermissions from SDK host ignored: ${ctx.error.issues[0]?.message ?? 'unknown'}`,
 56            { level: 'warn' },
 57          )
 58          return undefined
 59        }),
 60      toolUseID: z.string().optional(),
 61      decisionClassification: decisionClassificationField(),
 62    }),
 63  )
 64  
 65  const PermissionDenyResultSchema = lazySchema(() =>
 66    z.object({
 67      behavior: z.literal('deny'),
 68      message: z.string(),
 69      interrupt: z.boolean().optional(),
 70      toolUseID: z.string().optional(),
 71      decisionClassification: decisionClassificationField(),
 72    }),
 73  )
 74  
 75  export const outputSchema = lazySchema(() =>
 76    z.union([PermissionAllowResultSchema(), PermissionDenyResultSchema()]),
 77  )
 78  
 79  export type Output = z.infer<ReturnType<typeof outputSchema>>
 80  
 81  /**
 82   * Normalizes the result of a permission prompt tool to a PermissionDecision.
 83   */
 84  export function permissionPromptToolResultToPermissionDecision(
 85    result: Output,
 86    tool: Tool,
 87    input: { [key: string]: unknown },
 88    toolUseContext: ToolUseContext,
 89  ): PermissionDecision {
 90    const decisionReason: PermissionDecisionReason = {
 91      type: 'permissionPromptTool',
 92      permissionPromptToolName: tool.name,
 93      toolResult: result,
 94    }
 95    if (result.behavior === 'allow') {
 96      const updatedPermissions = result.updatedPermissions
 97      if (updatedPermissions) {
 98        toolUseContext.setAppState(prev => ({
 99          ...prev,
100          toolPermissionContext: applyPermissionUpdates(
101            prev.toolPermissionContext,
102            updatedPermissions,
103          ),
104        }))
105        persistPermissionUpdates(updatedPermissions)
106      }
107      // Mobile clients responding from a push notification don't have the
108      // original tool input, so they send `{}` to satisfy the schema. Treat an
109      // empty object as "use original" so the tool doesn't run with no args.
110      const updatedInput =
111        Object.keys(result.updatedInput).length > 0 ? result.updatedInput : input
112      return {
113        ...result,
114        updatedInput,
115        decisionReason,
116      }
117    } else if (result.behavior === 'deny' && result.interrupt) {
118      logForDebugging(
119        `SDK permission prompt deny+interrupt: tool=${tool.name} message=${result.message}`,
120      )
121      toolUseContext.abortController.abort()
122    }
123    return {
124      ...result,
125      decisionReason,
126    }
127  }