import type { DecisionSource } from "#src/authority/decision-source"; import type { PromptDecisionNote } from "#src/presentation/prompt-payload"; 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 human ever ruled on this ask: either no live authority was * reachable at all (`DenyingAuthorizer`, a no-UI non-subagent session) or the * forwarding path gave up before reaching one (`ParentAuthorizer` — target * unresolvable, request undeliverable, target not serving, or no answer * within the timeout). 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 — a user who was never asked denied nothing (#719). */ confirmationUnavailable?: true; /** * What decided this request, stamped by the site that decided it. * * Required: every decision names its decider, and the type is what * guarantees it rather than a convention each producer has to remember — the * same discipline `PromptPermissionDetails.payload` carries (#726). */ decidedBy: DecisionSource; }; /** * A decision before its decider is known. * * The inner producers — the dialog's decision model, the `select`/`input` * fallback, the verdict mapper — state the outcome; which decider to attribute * it to is settled one layer up, at the site that chose the producer. The same * shape `GateBypass.decision` uses for the request id: a producer emits only * what it knows. */ export type UnattributedDecision = Omit; 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, ): UnattributedDecision { 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; }; /** * Why automatic review declined to approve, shown ahead of the ask facts. * * `origin` marks who wrote each note: a `model` reason is the reviewer's own * verdict text and renders marked as model-generated; a `system` note is a * deterministic, code-authored sentence. */ deferNotes?: readonly PromptDecisionNote[]; } 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(); }