File size: 4,108 Bytes
064bfd6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 | 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,
}
}
|