128 lines
4.0 KiB
TypeScript
128 lines
4.0 KiB
TypeScript
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,
|
|
}
|
|
}
|