Skip to content
162 changes: 161 additions & 1 deletion src/components/ChatInterface.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]}:*)`;
}
Comment on lines +281 to +289
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Edge case: empty command token produces malformed permission entry.

If command contains only whitespace (e.g., ' '), split(/\s+/) returns ['']. The check tokens.length === 0 passes, but tokens[0] is an empty string, producing 'Bash(:*)' which is likely invalid.

🐛 Suggested fix
   const tokens = command.split(/\s+/);
-  if (tokens.length === 0) return toolName;
+  if (tokens.length === 0 || !tokens[0]) return toolName;
 
   // For Bash, allow the command family instead of every Bash invocation.
   if (tokens[0] === 'git' && tokens[1]) {
🤖 Prompt for AI Agents
In @src/components/ChatInterface.jsx around lines 290 - 298, The current logic
splits command with split(/\s+/) which yields [''] for a whitespace-only string
and leads to a malformed permission like 'Bash(:*)'; fix by trimming and
validating the command before splitting: compute a trimmed string (e.g., const
trimmed = command.trim()), if trimmed is empty return toolName, then build
tokens from trimmed.split(/\s+/) and proceed with the existing git and default
Bash(...) branches so tokens[0] is never an empty string.


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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Gate the inline “Grant permission” UI to permission-denied errors (right now it shows for any tool error).
getClaudePermissionSuggestion() only checks message.toolResult.isError, so the button/banner will appear on all tool failures (network errors, tool crashes, bad inputs), which is misleading and increases the chance of users granting overly broad permissions.

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
In @src/components/ChatInterface.jsx around lines 304 - 316, Update
getClaudePermissionSuggestion to only show the grant-permission UI when the tool
error is a permission-denied error: return null unless message.toolResult
indicates permission denial by checking common markers (e.g.
message.toolResult.permissionDenied === true, message.toolResult.errorCode ===
'PERMISSION_DENIED', message.toolResult.status === 403, or
message.toolResult.message includes "permission" / "not authorized"); also
handle missing toolName by falling back to message.toolResult?.toolName or
deriving it from message?.metadata or message.toolInput before calling
buildClaudeToolPermissionEntry; keep using buildClaudeToolPermissionEntry and
getClaudeSettings to compute entry/isAllowed, and apply the same guard to the
other occurrence referenced (around lines 1465-1517).


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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don’t report “Permission saved” unless the localStorage write actually stuck.
grantClaudeToolPermission() always returns { success: true } after calling safeLocalStorage.setItem(...), even if the write failed or was dropped; the UI then says “Permission saved”.

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
In @src/components/ChatInterface.jsx around lines 318 - 334,
grantClaudeToolPermission currently assumes the call to
safeLocalStorage.setItem(CLAUDE_SETTINGS_KEY, ...) always succeeds; change it to
attempt the write inside try/catch, then read back with
safeLocalStorage.getItem(CLAUDE_SETTINGS_KEY) and JSON.parse to verify the
stored settings match updatedSettings (comparing serialized or key-wise), and
only return { success: true, alreadyAllowed, updatedSettings } if the
verification passes; on parse errors, read mismatch, or thrown exceptions return
{ success: false, error: <brief message> } and do not report “Permission saved”;
apply the same read-back verification pattern to the analogous code around the
other occurrence (lines ~1506-1515) that writes CLAUDE_SETTINGS_KEY.


// Common markdown components to ensure consistent rendering (tables, inline code, links, etc.)
const markdownComponents = {
code: ({ node, inline, className, children, ...props }) => {
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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>
);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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}
/>
);
})}
Expand Down