diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 57a03727..56445821 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -11,7 +11,7 @@ import { MarkdownEditor, type MarkdownEditorHandle } from './components/markdown import { ChatSidebar } from './components/chat-sidebar'; import { ChatHeader } from './components/chat-header'; import { ChatEmptyState } from './components/chat-empty-state'; -import { ChatInputWithMentions, type StagedAttachment } from './components/chat-input-with-mentions'; +import { ChatInputWithMentions, type PermissionMode, type StagedAttachment } from './components/chat-input-with-mentions'; import { ChatMessageAttachments } from '@/components/chat-message-attachments' import { GraphView, type GraphEdge, type GraphNode } from '@/components/graph-view'; import { BasesView, type BaseConfig, DEFAULT_BASE_CONFIG } from '@/components/bases-view'; @@ -56,9 +56,10 @@ import { WebSearchResult } from '@/components/ai-elements/web-search-result'; import { AppActionCard } from '@/components/ai-elements/app-action-card'; import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card'; import { PermissionRequest } from '@/components/ai-elements/permission-request'; +import { AutoPermissionDecision } from '@/components/ai-elements/auto-permission-decision'; import { TerminalOutput } from '@/components/terminal-output'; import { AskHumanRequest } from '@/components/ai-elements/ask-human-request'; -import { ToolPermissionRequestEvent, AskHumanRequestEvent } from '@x/shared/src/runs.js'; +import { ToolPermissionAutoDecisionEvent, ToolPermissionRequestEvent, AskHumanRequestEvent } from '@x/shared/src/runs.js'; import { SidebarInset, SidebarProvider, @@ -961,7 +962,7 @@ function App() { voice.start() }, [voice]) - const handlePromptSubmitRef = useRef<((message: PromptInputMessage, mentions?: FileMention[], stagedAttachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex') => Promise) | null>(null) + const handlePromptSubmitRef = useRef<((message: PromptInputMessage, mentions?: FileMention[], stagedAttachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex', permissionMode?: PermissionMode) => Promise) | null>(null) const pendingVoiceInputRef = useRef(false) // Palette: per-tab editor handles for capturing cursor context on Cmd+K, and pending payload @@ -1180,6 +1181,7 @@ function App() { const [allPermissionRequests, setAllPermissionRequests] = useState>>(new Map()) // Track permission responses (toolCallId -> response) const [permissionResponses, setPermissionResponses] = useState>(new Map()) + const [autoPermissionDecisions, setAutoPermissionDecisions] = useState>>(new Map()) useEffect(() => { chatViewStateByTabRef.current = chatViewStateByTab @@ -1193,6 +1195,7 @@ function App() { pendingAskHumanRequests: new Map(pendingAskHumanRequests), allPermissionRequests: new Map(allPermissionRequests), permissionResponses: new Map(permissionResponses), + autoPermissionDecisions: new Map(autoPermissionDecisions), } setChatViewStateByTab((prev) => ({ ...prev, [activeChatTabId]: snapshot })) }, [ @@ -1203,6 +1206,7 @@ function App() { pendingAskHumanRequests, allPermissionRequests, permissionResponses, + autoPermissionDecisions, ]) useEffect(() => { @@ -2026,6 +2030,7 @@ function App() { // Track permission requests and responses from history const allPermissionRequests = new Map>() const permResponseMap = new Map() + const autoPermissionDecisions = new Map>() const askHumanRequests = new Map>() const respondedAskHumanIds = new Set() @@ -2034,6 +2039,8 @@ function App() { allPermissionRequests.set(event.toolCall.toolCallId, event) } else if (event.type === 'tool-permission-response') { permResponseMap.set(event.toolCallId, event.response) + } else if (event.type === 'tool-permission-auto-decision') { + autoPermissionDecisions.set(event.toolCallId, event) } else if (event.type === 'ask-human-request') { askHumanRequests.set(event.toolCallId, event) } else if (event.type === 'ask-human-response') { @@ -2066,6 +2073,7 @@ function App() { setPendingAskHumanRequests(pendingAsks) setAllPermissionRequests(allPermissionRequests) setPermissionResponses(permResponseMap) + setAutoPermissionDecisions(autoPermissionDecisions) // Restore the run's per-chat work directory into the tab it was loaded into. const tabId = activeChatTabIdRef.current @@ -2375,6 +2383,16 @@ function App() { break } + case 'tool-permission-auto-decision': { + if (!isActiveRun) return + setAutoPermissionDecisions(prev => { + const next = new Map(prev) + next.set(event.toolCallId, event) + return next + }) + break + } + case 'ask-human-request': { if (!isActiveRun) return const key = event.toolCallId @@ -2491,6 +2509,7 @@ function App() { stagedAttachments: StagedAttachment[] = [], searchEnabled?: boolean, codeMode?: 'claude' | 'codex', + permissionMode?: PermissionMode, ) => { if (isProcessing) return @@ -2530,6 +2549,7 @@ function App() { const run = await window.ipc.invoke('runs:create', { agentId, ...(selected ? { model: selected.model, provider: selected.provider } : {}), + permissionMode: permissionMode ?? 'manual', }) currentRunId = run.id newRunCreatedAt = run.createdAt @@ -2734,6 +2754,7 @@ function App() { setPendingAskHumanRequests(new Map()) setAllPermissionRequests(new Map()) setPermissionResponses(new Map()) + setAutoPermissionDecisions(new Map()) setSelectedBackgroundTask(null) setChatViewportAnchor(activeChatTabIdRef.current, null) setChatViewStateByTab(prev => ({ @@ -2760,6 +2781,7 @@ function App() { setPendingAskHumanRequests(new Map()) setAllPermissionRequests(new Map()) setPermissionResponses(new Map()) + setAutoPermissionDecisions(new Map()) setChatViewportAnchor(tab.id, null) } }, [loadRun, setChatViewportAnchor]) @@ -2785,6 +2807,7 @@ function App() { setPendingAskHumanRequests(new Map(cached.pendingAskHumanRequests)) setAllPermissionRequests(new Map(cached.allPermissionRequests)) setPermissionResponses(new Map(cached.permissionResponses)) + setAutoPermissionDecisions(new Map(cached.autoPermissionDecisions)) setIsProcessing(Boolean(resolvedRunId && processingRunIdsRef.current.has(resolvedRunId))) return true }, []) @@ -5057,7 +5080,11 @@ function App() { } }, [isGraphOpen, knowledgeFilePaths]) - const renderConversationItem = (item: ConversationItem, tabId: string) => { + const renderConversationItem = ( + item: ConversationItem, + tabId: string, + options?: { autoPermissionDetail?: { decision: 'allow'; reason: string } }, + ) => { if (isChatMessage(item)) { if (item.role === 'user') { if (item.attachments && item.attachments.length > 0) { @@ -5155,6 +5182,7 @@ function App() { key={item.id} open={isToolOpenForTab(tabId, item.id)} onOpenChange={(open) => setToolOpenForTab(tabId, item.id, open)} + autoPermissionDetail={options?.autoPermissionDetail} > (() => createEmptyChatTabViewState(), []) const getChatTabStateForRender = useCallback((tabId: string): ChatTabViewState => { @@ -5790,7 +5820,7 @@ function App() { <> {groupConversationItems( tabState.conversation, - (id) => !!tabState.allPermissionRequests.get(id) + (id) => !!tabState.allPermissionRequests.get(id) || !!tabState.autoPermissionDecisions.get(id) ).map(item => { if (isToolGroup(item)) { return ( @@ -5802,41 +5832,61 @@ function App() { /> ) } - const rendered = renderConversationItem(item, tab.id) + const autoDecision = isToolCall(item) + ? tabState.autoPermissionDecisions.get(item.id) + : undefined + const rendered = renderConversationItem( + item, + tab.id, + autoDecision?.decision === 'allow' + ? { autoPermissionDetail: { decision: 'allow', reason: autoDecision.reason } } + : undefined, + ) if (isToolCall(item)) { + const deniedAutoDecision = autoDecision?.decision === 'deny' ? autoDecision : null const permRequest = tabState.allPermissionRequests.get(item.id) - if (permRequest) { + if (deniedAutoDecision || permRequest) { const response = tabState.permissionResponses.get(item.id) || null return ( - handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')} - onApproveSession={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'session')} - onApproveAlways={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'always')} - onDeny={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')} - onSwitchAgent={async (newAgent) => { - const runIdForSwitch = tab.runId - await handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny') - window.dispatchEvent(new CustomEvent('code-mode-detected', { - detail: { runId: runIdForSwitch, agent: newAgent }, - })) - if (runIdForSwitch) { - try { - await window.ipc.invoke('runs:createMessage', { - runId: runIdForSwitch, - message: `Use ${newAgent === 'claude' ? 'Claude Code' : 'Codex'} instead — rerun the same task with the same prompt, just swap the agent binary to \`${newAgent}\`.`, - codeMode: newAgent, - }) - } catch (err) { - console.error('Failed to send swap-agent follow-up', err) + {deniedAutoDecision && ( + + )} + {permRequest && ( + handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')} + onApproveSession={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'session')} + onApproveAlways={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'always')} + onDeny={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')} + onSwitchAgent={async (newAgent) => { + const runIdForSwitch = tab.runId + await handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny') + window.dispatchEvent(new CustomEvent('code-mode-detected', { + detail: { runId: runIdForSwitch, agent: newAgent }, + })) + if (runIdForSwitch) { + try { + await window.ipc.invoke('runs:createMessage', { + runId: runIdForSwitch, + message: `Use ${newAgent === 'claude' ? 'Claude Code' : 'Codex'} instead — rerun the same task with the same prompt, just swap the agent binary to \`${newAgent}\`.`, + codeMode: newAgent, + }) + } catch (err) { + console.error('Failed to send swap-agent follow-up', err) + } } - } - }} - isProcessing={isActive && isProcessing} - response={response} - /> + }} + isProcessing={isActive && isProcessing} + response={response} + /> + )} {rendered} ) @@ -5989,6 +6039,7 @@ function App() { pendingAskHumanRequests={pendingAskHumanRequests} allPermissionRequests={allPermissionRequests} permissionResponses={permissionResponses} + autoPermissionDecisions={autoPermissionDecisions} onPermissionResponse={handlePermissionResponse} onAskHumanResponse={handleAskHumanResponse} isToolOpenForTab={isToolOpenForTab} diff --git a/apps/x/apps/renderer/src/components/ai-elements/auto-permission-decision.tsx b/apps/x/apps/renderer/src/components/ai-elements/auto-permission-decision.tsx new file mode 100644 index 00000000..3c34aaec --- /dev/null +++ b/apps/x/apps/renderer/src/components/ai-elements/auto-permission-decision.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; +import { CheckCircle2Icon, ShieldAlertIcon, Terminal } from "lucide-react"; +import type { ComponentProps } from "react"; +import { ToolCallPart } from "@x/shared/dist/message.js"; +import { ToolPermissionMetadata } from "@x/shared/dist/runs.js"; +import z from "zod"; + +export type AutoPermissionDecisionProps = ComponentProps<"div"> & { + toolCall: z.infer; + decision: "allow" | "deny"; + reason: string; + permission?: z.infer; +}; + +const fileActionLabels: Record = { + read: "Read file", + list: "List folder", + search: "Search files", + write: "Write files", + delete: "Delete path", +}; + +export function AutoPermissionDecision({ + className, + toolCall, + decision, + reason, + permission, + ...props +}: AutoPermissionDecisionProps) { + const command = permission?.kind === "command" || toolCall.toolName === "executeCommand" + ? (typeof toolCall.arguments === "object" && toolCall.arguments !== null && "command" in toolCall.arguments + ? String(toolCall.arguments.command) + : JSON.stringify(toolCall.arguments)) + : null; + const filePermission = permission?.kind === "file" ? permission : null; + const allowed = decision === "allow"; + + return ( +
+
+
+ {allowed ? ( + + ) : ( + + )} +
+
+

+ {allowed ? "Auto Allowed" : "Auto Denied"} +

+ + + {toolCall.toolName} + +
+

{reason}

+
+
+ {command && ( +
+

Command

+
{command}
+
+ )} + {filePermission && ( +
+
+

Action

+

+ {fileActionLabels[filePermission.operation] ?? filePermission.operation} +

+
+
+

+ Path{filePermission.paths.length === 1 ? "" : "s"} +

+
+                {filePermission.paths.join("\n")}
+              
+
+
+ )} +
+
+ ); +} diff --git a/apps/x/apps/renderer/src/components/ai-elements/tool.tsx b/apps/x/apps/renderer/src/components/ai-elements/tool.tsx index 61ba6fbd..9635b244 100644 --- a/apps/x/apps/renderer/src/components/ai-elements/tool.tsx +++ b/apps/x/apps/renderer/src/components/ai-elements/tool.tsx @@ -5,12 +5,18 @@ import { CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; import type { ToolUIPart } from "ai"; import { ChevronDownIcon, CircleCheck, LoaderIcon, + ShieldCheckIcon, XCircleIcon, } from "lucide-react"; import { type ComponentProps, type ReactNode, isValidElement, useState } from "react"; @@ -45,17 +51,51 @@ const ToolCode = ({ ); -export type ToolProps = ComponentProps; +export type ToolAutoPermissionDetail = { + decision: "allow"; + reason: string; +}; -export const Tool = ({ className, ...props }: ToolProps) => ( - -); +export type ToolProps = ComponentProps & { + autoPermissionDetail?: ToolAutoPermissionDetail; +}; + +export const Tool = ({ className, children, autoPermissionDetail, ...props }: ToolProps) => { + const toolCard = ( + + {children} + + ); + + if (!autoPermissionDetail) return toolCard; + + return ( +
+ {toolCard} +
+ + + + + Auto-approved + + + + {autoPermissionDetail.reason} + + +
+
+ ); +}; export type ToolHeaderProps = { title?: string; diff --git a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx index 360a8657..8c62054c 100644 --- a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx +++ b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx @@ -17,6 +17,7 @@ import { LoaderIcon, Mic, Plus, + ShieldCheck, Square, Terminal, X, @@ -85,6 +86,8 @@ export interface SelectedModel { model: string } +export type PermissionMode = 'manual' | 'auto' + function getSelectedModelDisplayName(model: string) { return model.split('/').pop() || model } @@ -109,7 +112,7 @@ function getAttachmentIcon(kind: AttachmentIconKind) { } interface ChatInputInnerProps { - onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex') => void + onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex', permissionMode?: PermissionMode) => void onStop?: () => void isProcessing: boolean isStopping?: boolean @@ -182,11 +185,13 @@ function ChatInputInner({ const [codingAgent, setCodingAgent] = useState<'claude' | 'codex'>('claude') const [codeModeEnabled, setCodeModeEnabled] = useState(false) const [codeModeFeatureEnabled, setCodeModeFeatureEnabled] = useState(false) + const [permissionMode, setPermissionMode] = useState('auto') // When a run exists, freeze the dropdown to the run's resolved model+provider. useEffect(() => { if (!runId) { setLockedModel(null) + setPermissionMode('auto') return } let cancelled = false @@ -195,6 +200,7 @@ function ChatInputInner({ if (run.provider && run.model) { setLockedModel({ provider: run.provider, model: run.model }) } + setPermissionMode(run.permissionMode ?? 'manual') }).catch(() => { /* legacy run or fetch failure — leave unlocked */ }) return () => { cancelled = true } }, [runId]) @@ -482,13 +488,13 @@ function ChatInputInner({ if (!canSubmit) return // codeMode is sticky per conversation — don't reset after send. const effectiveCodeMode = codeModeEnabled ? codingAgent : undefined - onSubmit({ text: message.trim(), files: [] }, controller.mentions.mentions, attachments, searchEnabled || undefined, effectiveCodeMode) + onSubmit({ text: message.trim(), files: [] }, controller.mentions.mentions, attachments, searchEnabled || undefined, effectiveCodeMode, permissionMode) controller.textInput.clear() controller.mentions.clearMentions() setAttachments([]) // Web search toggle stays on for the rest of the chat session; the user // turns it off explicitly. (Not persisted across app restarts.) - }, [attachments, canSubmit, controller, message, onSubmit, searchEnabled, codeModeEnabled, codingAgent, workDir]) + }, [attachments, canSubmit, controller, message, onSubmit, searchEnabled, codeModeEnabled, codingAgent, permissionMode, workDir]) const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { @@ -709,6 +715,36 @@ function ChatInputInner({ )} + + + + + + {runId + ? `Permission mode is fixed for this run: ${permissionMode === 'auto' ? 'Auto' : 'Manual'}` + : permissionMode === 'auto' + ? 'Auto-permission on — click for manual approval prompts' + : 'Manual approval prompts — click for auto-permission'} + + {codeModeFeatureEnabled && (codeModeEnabled ? (
@@ -915,7 +951,7 @@ export interface ChatInputWithMentionsProps { knowledgeFiles: string[] recentFiles: string[] visibleFiles: string[] - onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex') => void + onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex', permissionMode?: PermissionMode) => void onStop?: () => void isProcessing: boolean isStopping?: boolean diff --git a/apps/x/apps/renderer/src/components/chat-sidebar.tsx b/apps/x/apps/renderer/src/components/chat-sidebar.tsx index 7987e6dd..f8923e4f 100644 --- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx +++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx @@ -28,6 +28,7 @@ import { Tool, ToolContent, ToolGroupComponent, ToolHeader, ToolTabbedContent } import { WebSearchResult } from '@/components/ai-elements/web-search-result' import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card' import { PermissionRequest } from '@/components/ai-elements/permission-request' +import { AutoPermissionDecision } from '@/components/ai-elements/auto-permission-decision' import { TerminalOutput } from '@/components/terminal-output' import { AskHumanRequest } from '@/components/ai-elements/ask-human-request' import { type PromptInputMessage, type FileMention } from '@/components/ai-elements/prompt-input' @@ -36,7 +37,7 @@ import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-over import { defaultRemarkPlugins } from 'streamdown' import remarkBreaks from 'remark-breaks' import { type ChatTab } from '@/components/tab-bar' -import { ChatInputWithMentions, type StagedAttachment, type SelectedModel } from '@/components/chat-input-with-mentions' +import { ChatInputWithMentions, type PermissionMode, type StagedAttachment, type SelectedModel } from '@/components/chat-input-with-mentions' import { ChatMessageAttachments } from '@/components/chat-message-attachments' import { useSidebar } from '@/components/ui/sidebar' import { wikiLabel } from '@/lib/wiki-links' @@ -139,7 +140,7 @@ interface ChatSidebarProps { isProcessing: boolean isStopping?: boolean onStop?: () => void - onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[]) => void + onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex', permissionMode?: PermissionMode) => void knowledgeFiles?: string[] recentFiles?: string[] visibleFiles?: string[] @@ -154,6 +155,7 @@ interface ChatSidebarProps { pendingAskHumanRequests?: ChatTabViewState['pendingAskHumanRequests'] allPermissionRequests?: ChatTabViewState['allPermissionRequests'] permissionResponses?: ChatTabViewState['permissionResponses'] + autoPermissionDecisions?: ChatTabViewState['autoPermissionDecisions'] onPermissionResponse?: (toolCallId: string, subflow: string[], response: PermissionResponse, scope?: 'once' | 'session' | 'always') => void onAskHumanResponse?: (toolCallId: string, subflow: string[], response: string) => void isToolOpenForTab?: (tabId: string, toolId: string) => boolean @@ -211,6 +213,7 @@ export function ChatSidebar({ pendingAskHumanRequests = new Map(), allPermissionRequests = new Map(), permissionResponses = new Map(), + autoPermissionDecisions = new Map(), onPermissionResponse, onAskHumanResponse, isToolOpenForTab, @@ -325,6 +328,7 @@ export function ChatSidebar({ pendingAskHumanRequests, allPermissionRequests, permissionResponses, + autoPermissionDecisions, }), [ runId, conversation, @@ -332,6 +336,7 @@ export function ChatSidebar({ pendingAskHumanRequests, allPermissionRequests, permissionResponses, + autoPermissionDecisions, ]) const emptyTabState = useMemo(() => createEmptyChatTabViewState(), []) const getTabState = useCallback((tabId: string): ChatTabViewState => { @@ -358,7 +363,11 @@ export function ChatSidebar({ } }, [activeRunId]) - const renderConversationItem = (item: ConversationItem, tabId: string) => { + const renderConversationItem = ( + item: ConversationItem, + tabId: string, + options?: { autoPermissionDetail?: { decision: 'allow'; reason: string } }, + ) => { if (isChatMessage(item)) { if (item.role === 'user') { if (item.attachments && item.attachments.length > 0) { @@ -451,6 +460,7 @@ export function ChatSidebar({ key={item.id} open={isToolOpenForTab?.(tabId, item.id) ?? false} onOpenChange={(open) => onToolOpenChangeForTab?.(tabId, item.id, open)} + autoPermissionDetail={options?.autoPermissionDetail} > @@ -626,7 +636,7 @@ export function ChatSidebar({ <> {groupConversationItems( tabState.conversation, - (id) => !!tabState.allPermissionRequests.get(id) + (id) => !!tabState.allPermissionRequests.get(id) || !!tabState.autoPermissionDecisions.get(id) ).map((item) => { if (isToolGroup(item)) { return ( @@ -638,22 +648,43 @@ export function ChatSidebar({ /> ) } - const rendered = renderConversationItem(item, tab.id) - if (isToolCall(item) && onPermissionResponse) { + const autoDecision = isToolCall(item) + ? tabState.autoPermissionDecisions.get(item.id) + : undefined + const rendered = renderConversationItem( + item, + tab.id, + autoDecision?.decision === 'allow' + ? { autoPermissionDetail: { decision: 'allow', reason: autoDecision.reason } } + : undefined, + ) + if (isToolCall(item)) { + const deniedAutoDecision = autoDecision?.decision === 'deny' ? autoDecision : null const permRequest = tabState.allPermissionRequests.get(item.id) - if (permRequest) { + if (deniedAutoDecision || (permRequest && onPermissionResponse)) { const response = tabState.permissionResponses.get(item.id) || null return ( - onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')} - onApproveSession={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'session')} - onApproveAlways={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'always')} - onDeny={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')} - isProcessing={isActive && isProcessing} - response={response} - /> + {deniedAutoDecision && ( + + )} + {permRequest && onPermissionResponse && ( + onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')} + onApproveSession={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'session')} + onApproveAlways={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'always')} + onDeny={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')} + isProcessing={isActive && isProcessing} + response={response} + /> + )} {rendered} ) diff --git a/apps/x/apps/renderer/src/components/settings-dialog.tsx b/apps/x/apps/renderer/src/components/settings-dialog.tsx index 37e0a930..74082664 100644 --- a/apps/x/apps/renderer/src/components/settings-dialog.tsx +++ b/apps/x/apps/renderer/src/components/settings-dialog.tsx @@ -277,17 +277,27 @@ const defaultBaseURLs: Partial> = { "openai-compatible": "http://localhost:1234/v1", } +type ProviderModelConfig = { + apiKey: string + baseURL: string + models: string[] + knowledgeGraphModel: string + meetingNotesModel: string + liveNoteAgentModel: string + autoPermissionDecisionModel: string +} + function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { const [provider, setProvider] = useState("openai") const [defaultProvider, setDefaultProvider] = useState(null) - const [providerConfigs, setProviderConfigs] = useState>({ - openai: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" }, - anthropic: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" }, - google: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" }, - openrouter: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" }, - aigateway: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" }, - ollama: { apiKey: "", baseURL: "http://localhost:11434", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" }, - "openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" }, + const [providerConfigs, setProviderConfigs] = useState>({ + openai: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" }, + anthropic: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" }, + google: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" }, + openrouter: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" }, + aigateway: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" }, + ollama: { apiKey: "", baseURL: "http://localhost:11434", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" }, + "openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" }, }) const [modelsCatalog, setModelsCatalog] = useState>({}) const [modelsLoading, setModelsLoading] = useState(false) @@ -313,7 +323,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { (!requiresBaseURL || activeConfig.baseURL.trim().length > 0) const updateConfig = useCallback( - (prov: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; models: string[]; knowledgeGraphModel: string; meetingNotesModel: string; liveNoteAgentModel: string }>) => { + (prov: LlmProviderFlavor, updates: Partial) => { setProviderConfigs(prev => ({ ...prev, [prov]: { ...prev[prov], ...updates }, @@ -388,6 +398,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { knowledgeGraphModel: e.knowledgeGraphModel || "", meetingNotesModel: e.meetingNotesModel || "", liveNoteAgentModel: e.liveNoteAgentModel || "", + autoPermissionDecisionModel: e.autoPermissionDecisionModel || "", }; } } @@ -406,6 +417,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { knowledgeGraphModel: parsed.knowledgeGraphModel || "", meetingNotesModel: parsed.meetingNotesModel || "", liveNoteAgentModel: parsed.liveNoteAgentModel || "", + autoPermissionDecisionModel: parsed.autoPermissionDecisionModel || "", }; } return next; @@ -481,6 +493,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { knowledgeGraphModel: activeConfig.knowledgeGraphModel.trim() || undefined, meetingNotesModel: activeConfig.meetingNotesModel.trim() || undefined, liveNoteAgentModel: activeConfig.liveNoteAgentModel.trim() || undefined, + autoPermissionDecisionModel: activeConfig.autoPermissionDecisionModel.trim() || undefined, } const result = await window.ipc.invoke("models:test", providerConfig) if (result.success) { @@ -515,6 +528,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { knowledgeGraphModel: config.knowledgeGraphModel.trim() || undefined, meetingNotesModel: config.meetingNotesModel.trim() || undefined, liveNoteAgentModel: config.liveNoteAgentModel.trim() || undefined, + autoPermissionDecisionModel: config.autoPermissionDecisionModel.trim() || undefined, }) setDefaultProvider(prov) window.dispatchEvent(new Event('models-config-changed')) @@ -546,6 +560,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { parsed.knowledgeGraphModel = defConfig.knowledgeGraphModel.trim() || undefined parsed.meetingNotesModel = defConfig.meetingNotesModel.trim() || undefined parsed.liveNoteAgentModel = defConfig.liveNoteAgentModel.trim() || undefined + parsed.autoPermissionDecisionModel = defConfig.autoPermissionDecisionModel.trim() || undefined } await window.ipc.invoke("workspace:writeFile", { path: "config/models.json", @@ -553,7 +568,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { }) setProviderConfigs(prev => ({ ...prev, - [prov]: { apiKey: "", baseURL: defaultBaseURLs[prov] || "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" }, + [prov]: { apiKey: "", baseURL: defaultBaseURLs[prov] || "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" }, })) setTestState({ status: "idle" }) window.dispatchEvent(new Event('models-config-changed')) @@ -811,6 +826,40 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { )}
+ + {/* Auto-permission model */} +
+ Auto-permission model + {modelsLoading ? ( +
+ + Loading... +
+ ) : showModelInput ? ( + updateConfig(provider, { autoPermissionDecisionModel: e.target.value })} + placeholder={primaryModel || "Enter model"} + /> + ) : ( + + )} +
{/* API Key */} diff --git a/apps/x/apps/renderer/src/lib/chat-conversation.ts b/apps/x/apps/renderer/src/lib/chat-conversation.ts index bbf1cde2..7b0c15c6 100644 --- a/apps/x/apps/renderer/src/lib/chat-conversation.ts +++ b/apps/x/apps/renderer/src/lib/chat-conversation.ts @@ -1,6 +1,6 @@ import type { ToolUIPart } from 'ai' import z from 'zod' -import { AskHumanRequestEvent, ToolPermissionRequestEvent } from '@x/shared/src/runs.js' +import { AskHumanRequestEvent, ToolPermissionAutoDecisionEvent, ToolPermissionRequestEvent } from '@x/shared/src/runs.js' import { COMPOSIO_DISPLAY_NAMES } from '@x/shared/src/composio.js' export interface MessageAttachment { @@ -46,6 +46,7 @@ export type ChatTabViewState = { pendingAskHumanRequests: Map> allPermissionRequests: Map> permissionResponses: Map + autoPermissionDecisions: Map> } export type ChatViewportAnchorState = { @@ -60,6 +61,7 @@ export const createEmptyChatTabViewState = (): ChatTabViewState => ({ pendingAskHumanRequests: new Map(), allPermissionRequests: new Map(), permissionResponses: new Map(), + autoPermissionDecisions: new Map(), }) export type ToolState = 'input-streaming' | 'input-available' | 'output-available' | 'output-error' diff --git a/apps/x/packages/core/src/agents/runtime.ts b/apps/x/packages/core/src/agents/runtime.ts index 84aa4092..f42fad72 100644 --- a/apps/x/packages/core/src/agents/runtime.ts +++ b/apps/x/packages/core/src/agents/runtime.ts @@ -36,6 +36,7 @@ import { getRaw as getLabelingAgentRaw } from "../knowledge/labeling_agent.js"; import { getRaw as getNoteTaggingAgentRaw } from "../knowledge/note_tagging_agent.js"; import { getRaw as getInlineTaskAgentRaw } from "../knowledge/inline_task_agent.js"; import { getRaw as getAgentNotesAgentRaw } from "../knowledge/agent_notes_agent.js"; +import { classifyToolPermissions, type AutoPermissionCandidate } from "../security/auto-permission-classifier.js"; const AGENT_NOTES_DIR = path.join(WorkDir, 'knowledge', 'Agent Notes'); @@ -901,6 +902,7 @@ export class AgentState { agentName: string | null = null; runModel: string | null = null; runProvider: string | null = null; + permissionMode: "manual" | "auto" = "manual"; runUseCase: UseCase | null = null; runSubUseCase: string | null = null; messages: z.infer = []; @@ -912,6 +914,8 @@ export class AgentState { pendingAskHumanRequests: Record> = {}; allowedToolCallIds: Record = {}; deniedToolCallIds: Record = {}; + autoAllowedToolCalls: Record = {}; + autoDeniedToolCalls: Record = {}; sessionAllowedCommands: Set = new Set(); sessionAllowedFileAccess: FileAccessGrant[] = []; @@ -1019,6 +1023,7 @@ export class AgentState { this.agentName = event.agentName; this.runModel = event.model; this.runProvider = event.provider; + this.permissionMode = event.permissionMode ?? "manual"; this.runUseCase = event.useCase ?? null; this.runSubUseCase = event.subUseCase ?? null; break; @@ -1031,6 +1036,7 @@ export class AgentState { this.subflowStates[event.toolCallId].agentName = event.agentName; this.subflowStates[event.toolCallId].runModel = this.runModel; this.subflowStates[event.toolCallId].runProvider = this.runProvider; + this.subflowStates[event.toolCallId].permissionMode = this.permissionMode; this.subflowStates[event.toolCallId].runUseCase = this.runUseCase; this.subflowStates[event.toolCallId].runSubUseCase = this.runSubUseCase; break; @@ -1081,10 +1087,22 @@ export class AgentState { break; case "deny": this.deniedToolCallIds[event.toolCallId] = true; + delete this.autoDeniedToolCalls[event.toolCallId]; break; } delete this.pendingToolPermissionRequests[event.toolCallId]; break; + case "tool-permission-auto-decision": + switch (event.decision) { + case "allow": + this.allowedToolCallIds[event.toolCallId] = true; + this.autoAllowedToolCalls[event.toolCallId] = { reason: event.reason }; + break; + case "deny": + this.autoDeniedToolCalls[event.toolCallId] = { reason: event.reason }; + break; + } + break; case "ask-human-request": this.pendingAskHumanRequests[event.toolCallId] = event; break; @@ -1190,13 +1208,19 @@ export async function* streamAgent({ // if tool has been denied, deny if (state.deniedToolCallIds[toolCallId]) { _logger.log('returning denied tool message, reason: tool has been denied'); + const autoDenied = state.autoDeniedToolCalls[toolCallId]; yield* processEvent({ runId, messageId: await idGenerator.next(), type: "message", message: { role: "tool", - content: "Unable to execute this tool: Permission was denied.", + content: autoDenied + ? JSON.stringify({ + success: false, + error: `Auto-permission denied: ${autoDenied.reason}`, + }) + : "Unable to execute this tool: Permission was denied.", toolCallId: toolCallId, toolName: toolCall.toolName, }, @@ -1493,6 +1517,7 @@ If the user's message is clearly NOT a coding request (small talk, an unrelated // if there were any ask-human calls, emit those events if (message.content instanceof Array) { + const permissionCandidates: AutoPermissionCandidate[] = []; for (const part of message.content) { if (part.type === "tool-call") { const underlyingTool = agent.tools![part.toolName]; @@ -1518,14 +1543,7 @@ If the user's message is clearly NOT a coding request (small talk, an unrelated state.sessionAllowedFileAccess, ); if (permission) { - loopLogger.log('emitting tool-permission-request, toolCallId:', part.toolCallId); - yield* processEvent({ - runId, - type: "tool-permission-request", - toolCall: part, - permission, - subflow: [], - }); + permissionCandidates.push({ toolCall: part, permission }); } if (underlyingTool.type === "agent" && underlyingTool.name) { loopLogger.log('emitting spawn-subflow, toolCallId:', part.toolCallId); @@ -1549,6 +1567,87 @@ If the user's message is clearly NOT a coding request (small talk, an unrelated } } } + + if (permissionCandidates.length > 0) { + if (state.permissionMode === "auto") { + let decisionsByToolCallId = new Map(); + try { + const decisions = await classifyToolPermissions({ + runId, + agentName: state.agentName, + messages: convertFromMessages(state.messages), + candidates: permissionCandidates, + useCase: state.runUseCase ?? "copilot_chat", + subUseCase: state.runSubUseCase, + }); + decisionsByToolCallId = new Map(decisions.map((decision) => [ + decision.toolCallId, + { decision: decision.decision, reason: decision.reason }, + ])); + } catch (error) { + loopLogger.log( + 'auto-permission classifier failed:', + error instanceof Error ? error.message : String(error), + ); + } + + for (const candidate of permissionCandidates) { + const decision = decisionsByToolCallId.get(candidate.toolCall.toolCallId); + if (!decision) { + loopLogger.log('auto-permission missing decision, falling back to prompt:', candidate.toolCall.toolCallId); + yield* processEvent({ + runId, + type: "tool-permission-request", + toolCall: candidate.toolCall, + permission: candidate.permission, + subflow: [], + }); + continue; + } + + loopLogger.log( + 'emitting tool-permission-auto-decision, toolCallId:', + candidate.toolCall.toolCallId, + 'decision:', + decision.decision, + ); + yield* processEvent({ + runId, + type: "tool-permission-auto-decision", + toolCallId: candidate.toolCall.toolCallId, + toolCall: candidate.toolCall, + permission: candidate.permission, + decision: decision.decision, + reason: decision.reason, + subflow: [], + }); + if (decision.decision === "deny") { + loopLogger.log( + 'auto-permission denied, falling back to prompt:', + candidate.toolCall.toolCallId, + ); + yield* processEvent({ + runId, + type: "tool-permission-request", + toolCall: candidate.toolCall, + permission: candidate.permission, + subflow: [], + }); + } + } + } else { + for (const candidate of permissionCandidates) { + loopLogger.log('emitting tool-permission-request, toolCallId:', candidate.toolCall.toolCallId); + yield* processEvent({ + runId, + type: "tool-permission-request", + toolCall: candidate.toolCall, + permission: candidate.permission, + subflow: [], + }); + } + } + } } } } diff --git a/apps/x/packages/core/src/models/defaults.ts b/apps/x/packages/core/src/models/defaults.ts index 3163438c..6d05e393 100644 --- a/apps/x/packages/core/src/models/defaults.ts +++ b/apps/x/packages/core/src/models/defaults.ts @@ -8,6 +8,7 @@ const SIGNED_IN_DEFAULT_MODEL = "gpt-5.4"; const SIGNED_IN_DEFAULT_PROVIDER = "rowboat"; const SIGNED_IN_KG_MODEL = "google/gemini-3.1-flash-lite"; const SIGNED_IN_LIVE_NOTE_AGENT_MODEL = "google/gemini-3.1-flash-lite"; +const SIGNED_IN_AUTO_PERMISSION_DECISION_MODEL = "google/gemini-3.1-flash-lite"; /** * The single source of truth for "what model+provider should we use when @@ -76,6 +77,17 @@ export async function getLiveNoteAgentModel(): Promise { return cfg.liveNoteAgentModel ?? cfg.model; } +/** + * Model used by the auto-permission classifier. + * Signed-in: curated default. BYOK: user override + * (`autoPermissionDecisionModel`) or assistant model. + */ +export async function getAutoPermissionDecisionModel(): Promise { + if (await isSignedIn()) return SIGNED_IN_AUTO_PERMISSION_DECISION_MODEL; + const cfg = await container.resolve("modelConfigRepo").getConfig(); + return cfg.autoPermissionDecisionModel ?? cfg.model; +} + /** * Model used by the meeting-notes summarizer. No special signed-in default — * historically meetings used the assistant model. BYOK: user override diff --git a/apps/x/packages/core/src/models/repo.ts b/apps/x/packages/core/src/models/repo.ts index 3f21e675..29febf4b 100644 --- a/apps/x/packages/core/src/models/repo.ts +++ b/apps/x/packages/core/src/models/repo.ts @@ -53,6 +53,7 @@ export class FSModelConfigRepo implements IModelConfigRepo { knowledgeGraphModel: config.knowledgeGraphModel, meetingNotesModel: config.meetingNotesModel, liveNoteAgentModel: config.liveNoteAgentModel, + autoPermissionDecisionModel: config.autoPermissionDecisionModel, }; const toWrite = { ...config, providers: existingProviders }; diff --git a/apps/x/packages/core/src/runs/repo.ts b/apps/x/packages/core/src/runs/repo.ts index addb4f35..8a1d3e85 100644 --- a/apps/x/packages/core/src/runs/repo.ts +++ b/apps/x/packages/core/src/runs/repo.ts @@ -35,6 +35,7 @@ export type CreateRunRepoOptions = { agentId: string; model: string; provider: string; + permissionMode: "manual" | "auto"; useCase: z.infer; subUseCase?: string; }; @@ -204,6 +205,7 @@ export class FSRunsRepo implements IRunsRepo { agentName: options.agentId, model: options.model, provider: options.provider, + permissionMode: options.permissionMode, useCase: options.useCase, ...(options.subUseCase ? { subUseCase: options.subUseCase } : {}), subflow: [], @@ -216,6 +218,7 @@ export class FSRunsRepo implements IRunsRepo { agentId: options.agentId, model: options.model, provider: options.provider, + permissionMode: options.permissionMode, useCase: options.useCase, ...(options.subUseCase ? { subUseCase: options.subUseCase } : {}), log: [start], @@ -251,6 +254,7 @@ export class FSRunsRepo implements IRunsRepo { agentId: start.agentName, model: start.model, provider: start.provider, + permissionMode: start.permissionMode ?? "manual", ...(start.useCase ? { useCase: start.useCase } : {}), ...(start.subUseCase ? { subUseCase: start.subUseCase } : {}), log: events, @@ -320,4 +324,4 @@ export class FSRunsRepo implements IRunsRepo { async delete(id: string): Promise { await fsp.unlink(runLogPath(id)); } -} \ No newline at end of file +} diff --git a/apps/x/packages/core/src/runs/runs.ts b/apps/x/packages/core/src/runs/runs.ts index c316cd3b..f832c00d 100644 --- a/apps/x/packages/core/src/runs/runs.ts +++ b/apps/x/packages/core/src/runs/runs.ts @@ -32,6 +32,7 @@ export async function createRun(opts: z.infer): Promise agentId: opts.agentId, model, provider, + permissionMode: opts.permissionMode ?? "manual", useCase, ...(opts.subUseCase ? { subUseCase: opts.subUseCase } : {}), }); diff --git a/apps/x/packages/core/src/security/auto-permission-classifier.ts b/apps/x/packages/core/src/security/auto-permission-classifier.ts new file mode 100644 index 00000000..352512be --- /dev/null +++ b/apps/x/packages/core/src/security/auto-permission-classifier.ts @@ -0,0 +1,112 @@ +import { generateObject, type ModelMessage } from "ai"; +import z from "zod"; +import { ToolPermissionMetadata } from "@x/shared/dist/runs.js"; +import { ToolCallPart } from "@x/shared/dist/message.js"; +import { captureLlmUsage } from "../analytics/usage.js"; +import { withUseCase, type UseCase } from "../analytics/use_case.js"; +import { getAutoPermissionDecisionModel, getDefaultModelAndProvider, resolveProviderConfig } from "../models/defaults.js"; +import { createProvider } from "../models/models.js"; + +const DecisionSchema = z.object({ + decisions: z.array(z.object({ + toolCallId: z.string(), + decision: z.enum(["allow", "deny"]), + reason: z.string().min(1), + })), +}); + +export type AutoPermissionCandidate = { + toolCall: z.infer; + permission: z.infer; +}; + +export type AutoPermissionDecision = { + toolCallId: string; + decision: "allow" | "deny"; + reason: string; +}; + +const SYSTEM_PROMPT = `You decide whether a personal productivity app may run tool calls without interrupting the user. + +You only receive tool calls that already require permission under deterministic rules. + +Allow a tool call only when it is clearly consistent with the user's request and low risk. +Deny tool calls that are destructive, credential-sensitive, privacy-sensitive, broad in scope, likely irreversible, or not clearly requested. + +Command examples to deny unless explicitly requested: deleting data, force pushing, deploying, running migrations, changing permissions, reading secrets, exfiltrating tokens, or modifying files outside the user's workspace. +File examples to deny unless explicitly requested: deleting paths, writing outside the workspace, reading secrets or credentials, or broad access to private directories. + +Return one decision for every toolCallId. Use the exact toolCallId values provided.`; + +function compact(value: unknown, max = 8_000): string { + const text = typeof value === "string" ? value : JSON.stringify(value, null, 2); + if (text.length <= max) return text; + return `${text.slice(0, max)}\n...`; +} + +function recentContext(messages: ModelMessage[]): unknown[] { + return messages.slice(-8).map((message) => { + if (typeof message.content === "string") { + return { role: message.role, content: compact(message.content, 2_000) }; + } + return { role: message.role, content: compact(message.content, 3_000) }; + }); +} + +function buildPrompt(input: { + agentName: string | null; + messages: ModelMessage[]; + candidates: AutoPermissionCandidate[]; +}) { + return compact({ + agentName: input.agentName, + recentConversation: recentContext(input.messages), + toolCalls: input.candidates.map(({ toolCall, permission }) => ({ + toolCallId: toolCall.toolCallId, + toolName: toolCall.toolName, + arguments: toolCall.arguments, + permission, + })), + }, 24_000); +} + +export async function classifyToolPermissions(input: { + runId: string; + agentName: string | null; + messages: ModelMessage[]; + candidates: AutoPermissionCandidate[]; + useCase: UseCase; + subUseCase?: string | null; +}): Promise { + if (input.candidates.length === 0) return []; + + const modelId = await getAutoPermissionDecisionModel(); + const { provider: providerName } = await getDefaultModelAndProvider(); + const providerConfig = await resolveProviderConfig(providerName); + const model = createProvider(providerConfig).languageModel(modelId); + + const result = await withUseCase( + { + useCase: input.useCase, + subUseCase: "auto_permission_classifier", + ...(input.agentName ? { agentName: input.agentName } : {}), + }, + () => generateObject({ + model, + system: SYSTEM_PROMPT, + prompt: buildPrompt(input), + schema: DecisionSchema, + }), + ); + + captureLlmUsage({ + useCase: input.useCase, + subUseCase: "auto_permission_classifier", + model: modelId, + provider: providerName, + usage: result.usage, + }); + + const allowedIds = new Set(input.candidates.map((candidate) => candidate.toolCall.toolCallId)); + return result.object.decisions.filter((decision) => allowedIds.has(decision.toolCallId)); +} diff --git a/apps/x/packages/shared/src/models.ts b/apps/x/packages/shared/src/models.ts index 3a24c217..4571de2c 100644 --- a/apps/x/packages/shared/src/models.ts +++ b/apps/x/packages/shared/src/models.ts @@ -17,10 +17,15 @@ export const LlmModelConfig = z.object({ headers: z.record(z.string(), z.string()).optional(), model: z.string().optional(), models: z.array(z.string()).optional(), + knowledgeGraphModel: z.string().optional(), + meetingNotesModel: z.string().optional(), + liveNoteAgentModel: z.string().optional(), + autoPermissionDecisionModel: z.string().optional(), })).optional(), // Per-category model overrides (BYOK only — signed-in users always get // the curated gateway defaults). Read by helpers in core/models/defaults.ts. knowledgeGraphModel: z.string().optional(), meetingNotesModel: z.string().optional(), liveNoteAgentModel: z.string().optional(), + autoPermissionDecisionModel: z.string().optional(), }); diff --git a/apps/x/packages/shared/src/runs.ts b/apps/x/packages/shared/src/runs.ts index a977db0b..a4deea9a 100644 --- a/apps/x/packages/shared/src/runs.ts +++ b/apps/x/packages/shared/src/runs.ts @@ -21,6 +21,7 @@ export const StartEvent = BaseRunEvent.extend({ agentName: z.string(), model: z.string(), provider: z.string(), + permissionMode: z.enum(["manual", "auto"]).optional(), // useCase/subUseCase tag the run for analytics. Optional on read so legacy // run files written before these fields existed still parse cleanly. useCase: z.enum([ @@ -110,6 +111,15 @@ export const ToolPermissionResponseEvent = BaseRunEvent.extend({ scope: z.enum(["once", "session", "always"]).optional(), }); +export const ToolPermissionAutoDecisionEvent = BaseRunEvent.extend({ + type: z.literal("tool-permission-auto-decision"), + toolCallId: z.string(), + toolCall: ToolCallPart, + permission: ToolPermissionMetadata.optional(), + decision: z.enum(["allow", "deny"]), + reason: z.string(), +}); + export const RunErrorEvent = BaseRunEvent.extend({ type: z.literal("error"), error: z.string(), @@ -134,6 +144,7 @@ export const RunEvent = z.union([ AskHumanResponseEvent, ToolPermissionRequestEvent, ToolPermissionResponseEvent, + ToolPermissionAutoDecisionEvent, RunErrorEvent, RunStoppedEvent, ]); @@ -166,6 +177,7 @@ export const Run = z.object({ agentId: z.string(), model: z.string(), provider: z.string(), + permissionMode: z.enum(["manual", "auto"]).optional(), useCase: UseCase.optional(), subUseCase: z.string().optional(), log: z.array(RunEvent), @@ -185,6 +197,7 @@ export const CreateRunOptions = z.object({ agentId: z.string(), model: z.string().optional(), provider: z.string().optional(), + permissionMode: z.enum(["manual", "auto"]).optional(), useCase: UseCase.optional(), subUseCase: z.string().optional(), });