-
Notifications
You must be signed in to change notification settings - Fork 705
Add inline permission grant for Claude tool errors #289
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 5 commits
7b63a68
ef44942
c654f48
3f66179
cdaff9d
64ebbaf
b707282
35e140b
d3c4821
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -236,6 +236,97 @@ const safeLocalStorage = { | |
| } | ||
| }; | ||
|
|
||
| const CLAUDE_SETTINGS_KEY = 'claude-settings'; | ||
|
|
||
| function safeJsonParse(value) { | ||
| if (!value || typeof value !== 'string') return null; | ||
| try { | ||
| return JSON.parse(value); | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| function getClaudeSettings() { | ||
| const raw = safeLocalStorage.getItem(CLAUDE_SETTINGS_KEY); | ||
| if (!raw) { | ||
| return { | ||
| allowedTools: [], | ||
| disallowedTools: [], | ||
| skipPermissions: false, | ||
| projectSortOrder: 'name' | ||
| }; | ||
| } | ||
|
|
||
| try { | ||
| const parsed = JSON.parse(raw); | ||
| return { | ||
| ...parsed, | ||
| allowedTools: Array.isArray(parsed.allowedTools) ? parsed.allowedTools : [], | ||
| disallowedTools: Array.isArray(parsed.disallowedTools) ? parsed.disallowedTools : [], | ||
| skipPermissions: Boolean(parsed.skipPermissions), | ||
| projectSortOrder: parsed.projectSortOrder || 'name' | ||
| }; | ||
| } catch { | ||
| return { | ||
| allowedTools: [], | ||
| disallowedTools: [], | ||
| skipPermissions: false, | ||
| projectSortOrder: 'name' | ||
| }; | ||
| } | ||
| } | ||
|
|
||
| function buildClaudeToolPermissionEntry(toolName, toolInput) { | ||
| if (!toolName) return null; | ||
| if (toolName !== 'Bash') return toolName; | ||
|
|
||
| const parsed = safeJsonParse(toolInput); | ||
| const command = typeof parsed?.command === 'string' ? parsed.command.trim() : ''; | ||
| if (!command) return toolName; | ||
|
|
||
| const tokens = command.split(/\s+/); | ||
| if (tokens.length === 0) return toolName; | ||
|
|
||
| // For Bash, allow the command family instead of every Bash invocation. | ||
| if (tokens[0] === 'git' && tokens[1]) { | ||
| return `Bash(${tokens[0]} ${tokens[1]}:*)`; | ||
| } | ||
| return `Bash(${tokens[0]}:*)`; | ||
| } | ||
|
|
||
| function getClaudePermissionSuggestion(message, provider) { | ||
| if (provider !== 'claude') return null; | ||
| if (!message?.toolResult?.isError) return null; | ||
|
|
||
| const toolName = message?.toolName; | ||
| const entry = buildClaudeToolPermissionEntry(toolName, message.toolInput); | ||
|
|
||
| if (!entry) return null; | ||
|
|
||
| const settings = getClaudeSettings(); | ||
| const isAllowed = settings.allowedTools.includes(entry); | ||
| return { toolName, entry, isAllowed }; | ||
| } | ||
|
Comment on lines
+304
to
+316
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Gate the inline “Grant permission” UI to permission-denied errors (right now it shows for any tool error). Proposed fix (add a permission-denied detector + optional toolName fallback)+function isClaudePermissionDeniedError(toolResultContent) {
+ const text = String(toolResultContent ?? '');
+ // TODO: tighten to Claude's exact denial wording/codes once confirmed.
+ return /permission|not allowed|denied|unauthorized/i.test(text);
+}
+
+function parseToolNameFromPermissionError(toolResultContent) {
+ const text = String(toolResultContent ?? '');
+ // Example fallback: "You don't have permission to use tool: WebSearch"
+ const match = text.match(/tool:\s*([A-Za-z0-9_:-]+)/i);
+ return match?.[1] || null;
+}
+
function getClaudePermissionSuggestion(message, provider) {
if (provider !== 'claude') return null;
if (!message?.toolResult?.isError) return null;
- const toolName = message?.toolName;
+ if (!isClaudePermissionDeniedError(message.toolResult?.content)) return null;
+
+ const toolName = message?.toolName || parseToolNameFromPermissionError(message.toolResult?.content);
const entry = buildClaudeToolPermissionEntry(toolName, message.toolInput);
if (!entry) return null;
const settings = getClaudeSettings();
const isAllowed = settings.allowedTools.includes(entry);
return { toolName, entry, isAllowed };
}Also applies to: 1465-1517 🤖 Prompt for AI Agents |
||
|
|
||
| function grantClaudeToolPermission(entry) { | ||
| if (!entry) return { success: false }; | ||
|
|
||
| const settings = getClaudeSettings(); | ||
| const alreadyAllowed = settings.allowedTools.includes(entry); | ||
| const nextAllowed = alreadyAllowed ? settings.allowedTools : [...settings.allowedTools, entry]; | ||
| const nextDisallowed = settings.disallowedTools.filter(tool => tool !== entry); | ||
| const updatedSettings = { | ||
| ...settings, | ||
| allowedTools: nextAllowed, | ||
| disallowedTools: nextDisallowed, | ||
| lastUpdated: new Date().toISOString() | ||
| }; | ||
|
|
||
| safeLocalStorage.setItem(CLAUDE_SETTINGS_KEY, JSON.stringify(updatedSettings)); | ||
| return { success: true, alreadyAllowed, updatedSettings }; | ||
| } | ||
|
Comment on lines
+318
to
+334
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Don’t report “Permission saved” unless the localStorage write actually stuck. Proposed fix (read-back verification) function grantClaudeToolPermission(entry) {
if (!entry) return { success: false };
const settings = getClaudeSettings();
const alreadyAllowed = settings.allowedTools.includes(entry);
const nextAllowed = alreadyAllowed ? settings.allowedTools : [...settings.allowedTools, entry];
const nextDisallowed = settings.disallowedTools.filter(tool => tool !== entry);
const updatedSettings = {
...settings,
allowedTools: nextAllowed,
disallowedTools: nextDisallowed,
lastUpdated: new Date().toISOString()
};
safeLocalStorage.setItem(CLAUDE_SETTINGS_KEY, JSON.stringify(updatedSettings));
- return { success: true, alreadyAllowed, updatedSettings };
+ const verify = getClaudeSettings();
+ const persisted = Array.isArray(verify.allowedTools) && verify.allowedTools.includes(entry);
+ return { success: persisted, alreadyAllowed, updatedSettings };
}Also applies to: 1506-1515 🤖 Prompt for AI Agents |
||
|
|
||
| // Common markdown components to ensure consistent rendering (tables, inline code, links, etc.) | ||
| const markdownComponents = { | ||
| code: ({ node, inline, className, children, ...props }) => { | ||
|
|
@@ -356,14 +447,21 @@ const markdownComponents = { | |
| }; | ||
|
|
||
| // Memoized message component to prevent unnecessary re-renders | ||
| const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFileOpen, onShowSettings, autoExpandTools, showRawParameters, showThinking, selectedProject }) => { | ||
| const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFileOpen, onShowSettings, onGrantToolPermission, autoExpandTools, showRawParameters, showThinking, selectedProject, provider }) => { | ||
| const isGrouped = prevMessage && prevMessage.type === message.type && | ||
| ((prevMessage.type === 'assistant') || | ||
| (prevMessage.type === 'user') || | ||
| (prevMessage.type === 'tool') || | ||
| (prevMessage.type === 'error')); | ||
| const messageRef = React.useRef(null); | ||
| const [isExpanded, setIsExpanded] = React.useState(false); | ||
| const permissionSuggestion = getClaudePermissionSuggestion(message, provider); | ||
| const [permissionGrantState, setPermissionGrantState] = React.useState('idle'); | ||
|
|
||
| React.useEffect(() => { | ||
| setPermissionGrantState('idle'); | ||
| }, [permissionSuggestion?.entry, message.toolId]); | ||
|
|
||
| React.useEffect(() => { | ||
| if (!autoExpandTools || !messageRef.current || !message.isToolUse) return; | ||
|
|
||
|
|
@@ -1358,6 +1456,59 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile | |
| </Markdown> | ||
| ); | ||
| })()} | ||
| {permissionSuggestion && ( | ||
| <div className="mt-4 border-t border-red-200/60 dark:border-red-800/60 pt-3"> | ||
| <div className="flex flex-wrap items-center gap-2"> | ||
| <button | ||
| type="button" | ||
| onClick={() => { | ||
| if (!onGrantToolPermission) return; | ||
| const result = onGrantToolPermission(permissionSuggestion); | ||
| if (result?.success) { | ||
| setPermissionGrantState('granted'); | ||
| } else { | ||
| setPermissionGrantState('error'); | ||
| } | ||
| }} | ||
| disabled={permissionSuggestion.isAllowed || permissionGrantState === 'granted'} | ||
| className={`inline-flex items-center gap-2 px-3 py-1.5 rounded-md text-xs font-medium border transition-colors ${ | ||
| permissionSuggestion.isAllowed || permissionGrantState === 'granted' | ||
| ? 'bg-green-100 dark:bg-green-900/30 border-green-300/70 dark:border-green-800/60 text-green-800 dark:text-green-200 cursor-default' | ||
| : 'bg-white/80 dark:bg-gray-900/40 border-red-300/70 dark:border-red-800/60 text-red-700 dark:text-red-200 hover:bg-white dark:hover:bg-gray-900/70' | ||
| }`} | ||
| > | ||
| {permissionSuggestion.isAllowed || permissionGrantState === 'granted' | ||
| ? 'Permission added' | ||
| : `Grant permission for ${permissionSuggestion.toolName}`} | ||
| </button> | ||
| {onShowSettings && ( | ||
| <button | ||
| type="button" | ||
| onClick={(e) => { | ||
| e.stopPropagation(); | ||
| onShowSettings(); | ||
| }} | ||
| className="text-xs text-red-700 dark:text-red-200 underline hover:text-red-800 dark:hover:text-red-100" | ||
| > | ||
| Open settings | ||
| </button> | ||
| )} | ||
| </div> | ||
| <div className="mt-2 text-xs text-red-700/90 dark:text-red-200/80"> | ||
| Adds <span className="font-mono">{permissionSuggestion.entry}</span> to Allowed Tools. | ||
| </div> | ||
| {permissionGrantState === 'error' && ( | ||
| <div className="mt-2 text-xs text-red-700 dark:text-red-200"> | ||
| Unable to update permissions. Please try again. | ||
| </div> | ||
| )} | ||
| {(permissionSuggestion.isAllowed || permissionGrantState === 'granted') && ( | ||
| <div className="mt-2 text-xs text-green-700 dark:text-green-200"> | ||
| Permission saved. Retry the request to use the tool. | ||
| </div> | ||
| )} | ||
| </div> | ||
| )} | ||
| </div> | ||
| </div> | ||
| ); | ||
|
|
@@ -4133,6 +4284,13 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess | |
| } | ||
| }, [input, isLoading, selectedProject, attachedImages, currentSessionId, selectedSession, provider, permissionMode, onSessionActive, cursorModel, claudeModel, codexModel, sendMessage, setInput, setAttachedImages, setUploadingImages, setImageErrors, setIsTextareaExpanded, textareaRef, setChatMessages, setIsLoading, setCanAbortSession, setClaudeStatus, setIsUserScrolledUp, scrollToBottom]); | ||
|
|
||
| const handleGrantToolPermission = useCallback((suggestion) => { | ||
| if (!suggestion || provider !== 'claude') { | ||
| return { success: false }; | ||
| } | ||
| return grantClaudeToolPermission(suggestion.entry); | ||
| }, [provider]); | ||
|
|
||
| // Store handleSubmit in ref so handleCustomCommand can access it | ||
| useEffect(() => { | ||
| handleSubmitRef.current = handleSubmit; | ||
|
|
@@ -4711,10 +4869,12 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess | |
| createDiff={createDiff} | ||
| onFileOpen={onFileOpen} | ||
| onShowSettings={onShowSettings} | ||
| onGrantToolPermission={handleGrantToolPermission} | ||
| autoExpandTools={autoExpandTools} | ||
| showRawParameters={showRawParameters} | ||
| showThinking={showThinking} | ||
| selectedProject={selectedProject} | ||
| provider={provider} | ||
| /> | ||
| ); | ||
| })} | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Edge case: empty command token produces malformed permission entry.
If
commandcontains only whitespace (e.g.,' '),split(/\s+/)returns['']. The checktokens.length === 0passes, buttokens[0]is an empty string, producing'Bash(:*)'which is likely invalid.🐛 Suggested fix
🤖 Prompt for AI Agents