fix: remove approval countdown timers and add system notifications#13281
fix: remove approval countdown timers and add system notifications#13281
Conversation
Remove auto-execute countdown (MCP 30s) and auto-expire countdown (Agent 60s) for tool approval requests. Tools now wait indefinitely for user action. - Remove countdown state and timer logic from useMcpToolApproval - Remove countdown display from ToolApprovalActions and ToolPermissionRequestCard - Add system notification via NotificationService when tool approval is needed - Fix streaming race condition with onToolPendingChange subscription - Add tests for userConfirmation module Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: suyao <sy20010504@gmail.com>
EurFelux
left a comment
There was a problem hiding this comment.
Review Summary
Overall this is a well-structured PR with a clear security motivation — removing auto-execution of tools without explicit user consent is the right call. The code changes are clean, the test coverage for userConfirmation is solid, and the onToolPendingChange subscription pattern elegantly bridges the non-React resolver Map with React component state.
Highlights
- Good security posture: Removing countdown-based auto-execution is the right approach
- Clean architecture: The subscription pattern (
onToolPendingChange) +useReducerforce-update is a well-known React pattern for external state synchronization - Race condition fix: Detecting pending state during
streamingstatus viaisToolPending()is a smart solution - Good test coverage: 9 tests covering the core
userConfirmationmodule
Issues Found
-
isExpireddead code (useAgentToolApproval): TheuseMemowithDate.now()and[request]dependency will never re-evaluate over time. Since the main process handles the timeout independently via IPC, this code is effectively dead. Consider simplifying toconst isExpired = falsewith a comment, matching the pattern inuseMcpToolApproval. -
PR description vs implementation (tool-permissions.ts): The description says "wait indefinitely" but the main process uses a 600s timeout. A clarifying comment would help.
-
Unrelated change (PluginDetailModal.tsx): CSS class reorder appears to be from auto-formatter.
None of these are blockers — the PR works correctly in practice. The main suggestion is to clean up the dead isExpired logic to avoid confusing future contributors.
src/main/services/agents/services/claudecode/tool-permissions.ts
Outdated
Show resolved
Hide resolved
src/renderer/src/pages/home/Messages/Tools/hooks/useAgentToolApproval.ts
Outdated
Show resolved
Hide resolved
...src/pages/settings/AgentSettings/components/PluginsSettings/components/PluginDetailModal.tsx
Show resolved
Hide resolved
…pired code - Add comment explaining 600s timeout vs "indefinite" UI behavior - Remove dead useMemo isExpired computation (main process handles timeout) - Simplify to const isExpired = false with explanatory comment Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: suyao <sy20010504@gmail.com>
The main process handles the 600s timeout internally — expiresAt was only used for the UI countdown which has been removed. Clean up: - Remove expiresAt from Redux type and IPC payload - Remove isExpired from ToolApprovalState interface and all hooks - Remove dead expired UI branch and ExpiredBadge styled component Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: suyao <sy20010504@gmail.com>
Remove the 600s timeout that auto-denied tool requests. The promise now waits indefinitely for user action (or abort signal). The timeout was our own setTimeout, not an SDK requirement. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: suyao <sy20010504@gmail.com>
DeJeune
left a comment
There was a problem hiding this comment.
Review Summary
Overall a well-motivated change — removing auto-execution countdown timers is the right security call, and the subscription mechanism to fix the streaming race condition is a clever solution. The PR is clean, well-documented, and includes good test coverage for the userConfirmation module.
Significant (2)
-
Empty
idguard inuseMcpToolApproval— WhentoolResponse?.idis undefined,idfalls back to'', which means theisToolPending('')check and theonToolPendingChangesubscription operate on an empty string. Should guard against this. -
Notification spam for rapid tool calls — Multiple tools firing simultaneously will each send a separate system notification. Consider debouncing or batching.
Minor / Nit (4)
-
Duplicated notification construction — The
NotificationService.send()call is nearly identical inmcp.tsanduseAppInit.ts. Could be extracted to a shared helper. -
NotificationServiceimport rename —initialStateis less self-documenting than the previousdefaultNotificationSettingsalias. -
useReducerforce-update — Well-documented pattern, just noting it's intentionally unbounded. -
Test cleanup for
toolIdToNameMap— TheconfirmSameNameToolstest doesn't clean up name mappings, which could leak across suites.
Positives
- Thorough removal of all countdown/expiration plumbing across both MCP and agent paths
- The
onToolPendingChangesubscription pattern is a clean solution to the React/non-React state bridge - Good test coverage (9 tests) for the
userConfirmationmodule, covering confirm, cancel, abort signal, subscriptions, andconfirmSameNameTools - i18n keys properly added with
[to be translated]markers for non-primary locales - CI is green
src/renderer/src/pages/home/Messages/Tools/hooks/useMcpToolApproval.ts
Outdated
Show resolved
Hide resolved
| // Force re-render when requestToolConfirmation() is called for this tool. | ||
| // The resolver Map is not React state, so we need this subscription | ||
| // to detect when the execution layer has registered a pending approval. | ||
| const [, forceUpdate] = useReducer((x: number) => x + 1, 0) |
There was a problem hiding this comment.
Minor: The useReducer force-update pattern is a reasonable bridge between the non-React resolver Map and React state. Worth noting in a brief comment that this counter is intentionally unbounded (it wraps safely due to JS number precision being fine for this use case). The existing comments explain the "why" well — nicely documented.
| expect(ids).not.toContain('_global') | ||
|
|
||
| confirmToolAction('tool-a') | ||
| confirmToolAction('tool-b') | ||
| await Promise.all([p1, p2]) | ||
| }) | ||
| }) | ||
|
|
||
| describe('confirmSameNameTools', () => { | ||
| it('confirms all pending tools with the same name', async () => { | ||
| setToolIdToNameMapping('id-1', 'search') | ||
| setToolIdToNameMapping('id-2', 'search') | ||
| setToolIdToNameMapping('id-3', 'query') | ||
|
|
||
| const p1 = requestToolConfirmation('id-1') | ||
| const p2 = requestToolConfirmation('id-2') | ||
| const p3 = requestToolConfirmation('id-3') | ||
|
|
||
| confirmSameNameTools('search') | ||
|
|
||
| expect(await p1).toBe(true) | ||
| expect(await p2).toBe(true) | ||
| expect(isToolPending('id-3')).toBe(true) | ||
|
|
||
| confirmToolAction('id-3') | ||
| await p3 | ||
| }) | ||
| }) | ||
| }) |
There was a problem hiding this comment.
Minor: The confirmSameNameTools test sets up mappings and requests but doesn't clean up toolIdToNameMap entries for id-3 after the test. Since the module-level Map persists across tests, this could leak into other test suites if more tests are added later. Consider adding a clearToolIdToNameMappings() utility or cleaning up in afterEach. Not blocking since the current test suite is self-contained.
…s, guard empty id - Extract sendToolApprovalNotification() with 500ms debounce to deduplicate rapid-fire notifications (mcp.ts, useAppInit.ts) - Fix memory leak: toolIdToNameMap entries were never cleaned up on confirm/cancel/abort — unified via cleanupTool() - Guard empty id in useMcpToolApproval to prevent no-op subscriptions - Restore aliased import in NotificationService for clarity - Add clearToolIdToNameMappings() and use in test afterEach - Update mcp.test.ts mock to include sendToolApprovalNotification Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: suyao <sy20010504@gmail.com>
EurFelux
left a comment
There was a problem hiding this comment.
Review Summary
Well-structured PR with a clear security motivation. The code is clean, well-tested, and the architecture decisions are sound.
Highlights
- Security improvement: Removing countdown-based auto-execution is the right call — no tool should execute without explicit user consent
- Clean race condition fix: The
onToolPendingChangesubscription +useReducerforce-update pattern is an elegant bridge between the non-React resolver Map and React component state - Good refactoring:
cleanupTool()helper eliminates the duplicated cleanup logic acrossconfirmToolAction,cancelToolAction, and the abort listener - Solid test coverage: 9 tests for the
userConfirmationmodule covering confirm, cancel, abort signal, subscriptions, andconfirmSameNameTools - Notification debouncing: Smart batching of rapid-fire tool approval requests into a single notification
- NotificationService bug fix: The
initialStatefallback was silently broken before this PR (see inline comment)
One Issue
- i18n plural hardcoding (inline comment) —
${tools.length} toolsbreaks localization. Needs a separate i18n key for the plural form.
Minor Notes
PluginDetailModal.tsxCSS class reorder looks like auto-formatter output — harmless but unrelated to the PR scope- The self-review comments (useReducer unbounded counter, test cleanup) have been addressed in code — those threads can be resolved
Overall a clean PR. The i18n issue is the only thing worth fixing before merge.
| const message = | ||
| tools.length === 1 | ||
| ? i18n.t('message.tools.approvalRequired', { tool: tools[0] }) | ||
| : i18n.t('message.tools.approvalRequired', { tool: `${tools.length} tools` }) |
There was a problem hiding this comment.
i18n issue: The plural form ${tools.length} tools hardcodes English. This will render as Tool "3 tools" requires approval which reads awkwardly even in English, and breaks i18n for other locales.
Consider adding a dedicated plural key:
// en-us.json
"approvalRequiredMultiple": "{{count}} tools require approval"
// userConfirmation.ts
const message =
tools.length === 1
? i18n.t('message.tools.approvalRequired', { tool: tools[0] })
: i18n.t('message.tools.approvalRequiredMultiple', { count: tools.length })Or use i18next's built-in pluralization with _one / _other suffix keys.
| */ | ||
| public async send(notification: Notification): Promise<void> { | ||
| const notificationSettings = store.getState().settings.notification || defaultNotificationSettings | ||
| const notificationSettings = store.getState().settings.notification || defaultSettings.notification |
There was a problem hiding this comment.
Good catch — the previous code used initialState (the entire settings object) as the fallback, aliased misleadingly as defaultNotificationSettings. So when store.getState().settings.notification was falsy, notificationSettings[notification.source] would look up e.g. initialState['assistant'] which is undefined, silently disabling all fallback notifications.
Now correctly falls back to initialState.notification. Nice fix 👍
GeorgeDong32
left a comment
There was a problem hiding this comment.
Review Summary
This is a well-structured PR with a clear security motivation — removing auto-execution of tools without explicit user consent is the right approach.
✅ Highlights
- Security improvement: Removing countdown-based auto-execution (30s MCP, 60s Agent) eliminates the risk of unauthorized tool execution
- Clean architecture: The
onToolPendingChangesubscription pattern elegantly bridges the non-React resolver Map with React component state - Race condition fix: Detecting pending state during
streamingstatus viaisToolPending()is a smart solution - Notification debouncing: 500ms debounce for
sendToolApprovalNotificationprevents notification spam - Unified cleanup:
cleanupTool()helper consolidates resource cleanup logic - Good test coverage: 9 tests covering the core
userConfirmationmodule
📝 Minor Suggestions (non-blocking)
- Consider adding tests for
sendToolApprovalNotificationdebounce behavior toolIdToNameMapcould have residual entries if tools are never confirmed/cancelled (low risk)
Metrics
| Dimension | Rating |
|---|---|
| Security | ⭐⭐⭐⭐⭐ |
| Maintainability | ⭐⭐⭐⭐ |
| Test Coverage | ⭐⭐⭐⭐ |
| Performance | ⭐⭐⭐⭐⭐ |
Recommendation: Ready to merge ✅
What this PR does
Before this PR:
requestToolConfirmation()could cause the approval card to never appearAfter this PR:
onToolPendingChange) that force-updates the component when the execution layer registers a pending approvalWhy we need it and why it was done in this way
The following tradeoffs were made:
useReducerforce-update pattern + listener subscription to bridge the gap between the non-React resolver Map and React component stateThe following alternatives were considered:
executecallbackBreaking changes
None. Tools that were previously auto-approved via server config remain auto-approved. Only the countdown-based auto-execution/expiration is removed.
Special notes for your reviewer
userConfirmation.ts: AddedonToolPendingChange()subscription andisToolPending()/getPendingToolIds()query functionsuseMcpToolApproval.ts: Subscribes to pending changes, handlesstreamingstatus as potentially pendinguseAgentToolApproval.ts: Simplified — removed countdown, waits indefinitelymcp.ts: Sends notification viaNotificationServicewhen tool requires approvaluseAppInit.ts: Sends notification for agent tool permission requestsuserConfirmationmoduleChecklist
/gh-pr-review,gh pr diff, or GitHub UI) before requesting review from othersRelease note