export type PermissionDecisionState = | "approved" | "approved_for_session" | "approved_for_serving_session" | "denied" | "denied_with_reason"; export type PermissionPromptDecision = { approved: boolean; state: PermissionDecisionState; denialReason?: string; /** * True when the decision was made automatically by yolo mode rather than * by an interactive user prompt. Used by handlers to emit "auto_approved" * rather than "user_approved" in the permissions:decision broadcast. */ autoApproved?: true; /** * True when no live authority was reachable and the DenyingAuthorizer denied * this ask (a no-UI, non-subagent session). Consumed by deriveResolution (the * decision-event resolution), the gate (block reason), and PermissionPrompter * (review-entry resolution) to emit "confirmation_unavailable" rather than a * plain user denial. */ confirmationUnavailable?: true; }; export interface PermissionDecisionUi { select(title: string, options: string[]): Promise; input(title: string, placeholder?: string): Promise; } const APPROVE_OPTION = "Yes"; const APPROVE_FOR_SESSION_OPTION = "Yes, for this session"; const DENY_OPTION = "No"; const DENY_WITH_REASON_OPTION = "No, provide reason"; export function normalizePermissionDenialReason( value: unknown, ): string | undefined { if (typeof value !== "string") { return undefined; } const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : undefined; } export function createDeniedPermissionDecision( denialReason?: string, ): PermissionPromptDecision { const normalizedReason = normalizePermissionDenialReason(denialReason); return normalizedReason ? { approved: false, state: "denied_with_reason", denialReason: normalizedReason, } : { approved: false, state: "denied", }; } export function isPermissionDecisionState( value: unknown, ): value is PermissionDecisionState { return ( value === "approved" || value === "approved_for_session" || value === "approved_for_serving_session" || value === "denied" || value === "denied_with_reason" ); } export interface RequestPermissionOptions { /** Override the "for this session" option label (e.g. to show the suggested pattern). */ sessionLabel?: string; /** * Forwarded asks only: when set, choosing the "for this session" option opens * a second select asking whether the grant applies to the requesting subagent * only (the least-privilege default) or the whole serving session. */ sessionScope?: { subagentLabel: string; servingSessionLabel: string; }; } export async function requestPermissionDecisionFromUi( ui: PermissionDecisionUi, title: string, message: string, options?: RequestPermissionOptions, ): Promise { const sessionOption = options?.sessionLabel ?? APPROVE_FOR_SESSION_OPTION; const decisionOptions = [ APPROVE_OPTION, sessionOption, DENY_OPTION, DENY_WITH_REASON_OPTION, ] as const; const selected = await ui.select(`${title}\n${message}`, [ ...decisionOptions, ]); if (selected === APPROVE_OPTION) { return { approved: true, state: "approved", }; } if (selected === sessionOption) { if (options?.sessionScope) { const scope = await ui.select(`${title}\nApply this session grant to:`, [ options.sessionScope.subagentLabel, options.sessionScope.servingSessionLabel, ]); return { approved: true, // A cancelled scope select (undefined) falls back to the // least-privilege subagent scope. state: scope === options.sessionScope.servingSessionLabel ? "approved_for_serving_session" : "approved_for_session", }; } return { approved: true, state: "approved_for_session", }; } if (selected === DENY_WITH_REASON_OPTION) { const denialReason = normalizePermissionDenialReason( await ui.input( `${title}\nShare why this request was denied (optional).`, "Reason shown back to the agent", ), ); return createDeniedPermissionDecision(denialReason); } return createDeniedPermissionDecision(); }