diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/Constants.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/Constants.java index 63b7b7fa..6bf40df3 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/Constants.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/Constants.java @@ -56,6 +56,10 @@ private Constants() { public static final String AUTO_APPROVE_UNMATCHED_TERMINAL = "autoApproveUnmatchedTerminal"; public static final String AUTO_APPROVE_FILE_OP_RULES = "autoApproveEditRules"; public static final String AUTO_APPROVE_UNMATCHED_FILE_OP = "autoApproveUnmatchedFileOp"; + public static final String AUTO_APPROVE_MCP_SERVERS = "autoApproveMcpServers"; + public static final String AUTO_APPROVE_MCP_TOOLS = "autoApproveMcpTools"; + public static final String AUTO_APPROVE_TRUST_TOOL_ANNOTATIONS = "autoApproveTrustToolAnnotations"; + public static final String AUTO_APPROVE_YOLO_MODE = "autoApproveYoloMode"; // Base excluded file types shared by both // Copied from InelliJ, excluded file extension list diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/FeatureFlags.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/FeatureFlags.java index 1e9a6bf0..f54e0bfa 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/FeatureFlags.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/FeatureFlags.java @@ -25,6 +25,10 @@ public class FeatureFlags { private boolean customAgentPolicyEnabled = true; + private boolean autoApprovalTokenEnabled = true; + + private boolean autoApprovalPolicyEnabled = true; + public boolean isAgentModeEnabled() { return agentModeEnabled; } @@ -84,6 +88,25 @@ public void setCustomAgentPolicyEnabled(boolean customAgentPolicyEnabled) { this.customAgentPolicyEnabled = customAgentPolicyEnabled; } + /** + * Returns true if the auto-approval feature is available. + * Requires both the server token ({@code agent_mode_auto_approval}) and + * the organization policy ({@code agentMode.autoApproval.enabled}) to permit it. + * + * @return true if auto-approval is permitted + */ + public boolean isAutoApprovalEnabled() { + return autoApprovalTokenEnabled && autoApprovalPolicyEnabled; + } + + public void setAutoApprovalTokenEnabled(boolean autoApprovalTokenEnabled) { + this.autoApprovalTokenEnabled = autoApprovalTokenEnabled; + } + + public void setAutoApprovalPolicyEnabled(boolean autoApprovalPolicyEnabled) { + this.autoApprovalPolicyEnabled = autoApprovalPolicyEnabled; + } + public boolean isClientPreviewFeatureEnabled() { return clientPreviewFeatureEnabled; } diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageClient.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageClient.java index df5ebb4a..9b2ae40d 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageClient.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageClient.java @@ -309,6 +309,7 @@ public void onDidChangeFeatureFlags(DidChangeFeatureFlagsParams params) { flags.setMcpEnabled(params.isMcpEnabled()); flags.setByokEnabled(params.isByokEnabled()); flags.setClientPreviewFeatureEnabled(params.isClientPreviewFeaturesEnabled()); + flags.setAutoApprovalTokenEnabled(params.isAutoApprovalEnabled()); } if (eventBroker != null) { @@ -336,6 +337,7 @@ public void onDidChangePolicy(DidChangePolicyParams params) { flags.setCustomAgentPolicyEnabled(params.isCustomAgentEnabled()); eventBroker.post(CopilotEventConstants.TOPIC_DID_CHANGE_CUSTOM_AGENT_POLICY, params.isCustomAgentEnabled()); } + flags.setAutoApprovalPolicyEnabled(params.isAutoApprovalPolicyEnabled()); } } diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/DidChangeFeatureFlagsParams.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/DidChangeFeatureFlagsParams.java index 44dd3e4c..cdd0e839 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/DidChangeFeatureFlagsParams.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/DidChangeFeatureFlagsParams.java @@ -76,6 +76,15 @@ public boolean isClientPreviewFeaturesEnabled() { return !disabled; } + /** + * Checks if the auto-approval feature is enabled. + * Disabled only when the feature flag "agent_mode_auto_approval" is set to "0". + */ + public boolean isAutoApprovalEnabled() { + boolean disabled = featureFlags != null && "0".equals(featureFlags.get("agent_mode_auto_approval")); + return !disabled; + } + @Override public int hashCode() { return Objects.hash(activeExps, featureFlags, envelope, byokEnabled); diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/policy/DidChangePolicyParams.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/policy/DidChangePolicyParams.java index b5087205..3d21772a 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/policy/DidChangePolicyParams.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/policy/DidChangePolicyParams.java @@ -22,6 +22,9 @@ public class DidChangePolicyParams { @SerializedName("customAgent.enabled") private boolean customAgentEnabled = true; + @SerializedName("agentMode.autoApproval.enabled") + private boolean autoApprovalPolicyEnabled = true; + public boolean isMcpContributionPointEnabled() { return mcpContributionPointEnabled; } @@ -46,9 +49,18 @@ public void setCustomAgentEnabled(boolean customAgentEnabled) { this.customAgentEnabled = customAgentEnabled; } + public boolean isAutoApprovalPolicyEnabled() { + return autoApprovalPolicyEnabled; + } + + public void setAutoApprovalPolicyEnabled(boolean autoApprovalPolicyEnabled) { + this.autoApprovalPolicyEnabled = autoApprovalPolicyEnabled; + } + @Override public int hashCode() { - return Objects.hash(mcpContributionPointEnabled, subAgentEnabled, customAgentEnabled); + return Objects.hash(mcpContributionPointEnabled, subAgentEnabled, customAgentEnabled, + autoApprovalPolicyEnabled); } @Override @@ -65,7 +77,8 @@ public boolean equals(Object obj) { DidChangePolicyParams other = (DidChangePolicyParams) obj; return mcpContributionPointEnabled == other.mcpContributionPointEnabled && subAgentEnabled == other.subAgentEnabled - && customAgentEnabled == other.customAgentEnabled; + && customAgentEnabled == other.customAgentEnabled + && autoApprovalPolicyEnabled == other.autoApprovalPolicyEnabled; } @Override @@ -74,6 +87,7 @@ public String toString() { builder.append("mcpContributionPointEnabled", mcpContributionPointEnabled); builder.append("subAgentEnabled", subAgentEnabled); builder.append("customAgentEnabled", customAgentEnabled); + builder.append("autoApprovalPolicyEnabled", autoApprovalPolicyEnabled); return builder.toString(); } } diff --git a/com.microsoft.copilot.eclipse.swtbot.test/test-plans/global-auto-approve/global-auto-approve.md b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/global-auto-approve/global-auto-approve.md new file mode 100644 index 00000000..09cf4171 --- /dev/null +++ b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/global-auto-approve/global-auto-approve.md @@ -0,0 +1,125 @@ +# Global Auto-Approve + +## Overview + +Tests the Global Auto-Approve (YOLO) feature: enabling/disabling it via the +preference page and verifying that all tool confirmations are bypassed when +active. + +Entry points exercised: +- **Preferences → GitHub Copilot → Tool Auto Approve → Global Auto-Approve** — + the "Automatically approve ALL tool invocations" checkbox with its + confirmation dialog. +- **Agent Mode chat** — any tool call (terminal, file operation, MCP) to + observe confirmation bypass. + +--- + +## Prerequisites + +- Eclipse IDE with the GitHub Copilot for Eclipse plugin installed and + activated. +- A signed-in Copilot account on the host machine. +- Network access to `api.githubcopilot.com`. +- **Agent Mode** selected in the chat mode dropdown. +- Global Auto-Approve is **disabled** at the start of each scenario. + +--- + +## 1. Enable Global Auto-Approve — confirmation dialog required + +### TC-001: Enable Global Auto-Approve → confirmation dialog required +→ all tools skip confirmation + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Steps +1. Open **Preferences → Tool Auto Approve → Global Auto-Approve** section. +2. Click the **"Automatically approve ALL tool invocations"** checkbox. +3. Observe that a **confirmation dialog immediately appears** asking the user + to confirm this dangerous setting. +4. Verify the dialog title and message warn about the risk. +5. Click **Cancel** — verify the checkbox remains **unchecked**. +6. Click the checkbox again, then click **OK** in the confirmation dialog. +7. Verify the checkbox is now **checked**. +8. Click **"Apply and Close"**. +9. In Agent Mode, send a prompt that would normally trigger a confirmation + (e.g., an MCP tool call or a terminal command). +10. Observe that **no confirmation dialog appears** — all tools auto-approve. + +#### Expected Result +- Enabling YOLO mode requires an explicit confirmation dialog. +- Cancelling the dialog keeps the checkbox unchecked. +- When enabled, all tool confirmations (terminal, file operations, MCP) + are bypassed. + +#### 📸 Key Screenshots +- [ ] Confirmation dialog when enabling YOLO mode. +- [ ] Checkbox unchecked after Cancel. +- [ ] Checkbox checked after OK. +- [ ] Agent Mode: tool runs without any confirmation dialog. + +--- + +## 2. Disable Global Auto-Approve — no confirmation needed + +### TC-002: Disable Global Auto-Approve → no dialog → tools require +confirmation again + +**Type:** `Happy Path` +**Priority:** `P1` + +#### Preconditions +- Global Auto-Approve is **enabled**. + +#### Steps +1. Open **Preferences → Tool Auto Approve → Global Auto-Approve** section. +2. Click the **"Automatically approve ALL tool invocations"** checkbox to + uncheck it. +3. Observe that **no confirmation dialog appears** — turning it off is safe + and does not require confirmation. +4. Verify the checkbox is now **unchecked**. +5. Click **"Apply and Close"**. +6. In Agent Mode, trigger any tool. +7. Observe that the **confirmation dialog appears** — YOLO mode is off. + +#### Expected Result +- Disabling YOLO mode does not require a confirmation dialog. +- Tools require confirmation again after disabling. + +#### 📸 Key Screenshots +- [ ] Checkbox unchecked without any dialog. +- [ ] Tool shows confirmation dialog again. + +--- + +## 3. Global Auto-Approve overrides all tool categories + +### TC-003: Global Auto-Approve bypasses terminal deny rules, MCP +rules, and file operation rules + +**Type:** `Edge Case` +**Priority:** `P1` + +#### Preconditions +- Global Auto-Approve is **enabled**. +- Terminal has a custom **Deny** rule for `curl`. + +#### Steps +1. In Agent Mode, trigger a `curl` terminal command (normally blocked by the + deny rule). +2. Observe **auto-approved** — YOLO mode bypasses the deny rule. +3. Trigger an MCP tool call with no prior MCP approval. +4. Observe **auto-approved** — YOLO mode bypasses MCP confirmation. +5. Trigger a file operation on a file not in the attached context. +6. Observe **auto-approved** — YOLO mode bypasses file operation confirmation. + +#### Expected Result +- Global Auto-Approve bypasses ALL tool categories regardless of individual + rules or approval lists. + +#### 📸 Key Screenshots +- [ ] `curl` auto-approved despite deny rule. +- [ ] MCP tool auto-approved without prior approval. +- [ ] File operation auto-approved. diff --git a/com.microsoft.copilot.eclipse.swtbot.test/test-plans/mcp-auto-approve/mcp-auto-approve.md b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/mcp-auto-approve/mcp-auto-approve.md new file mode 100644 index 00000000..0659ec51 --- /dev/null +++ b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/mcp-auto-approve/mcp-auto-approve.md @@ -0,0 +1,265 @@ +# MCP Auto-Approve + +## Overview + +Tests the MCP tool auto-approve feature end-to-end: configuring rules in the +preference page, then triggering Agent Mode tool calls and observing whether +the confirmation dialog appears or the tool runs automatically. + +Each test case exercises the full stack: preference store → +`McpConfirmationHandler` → dialog (or auto-approve) → tool execution. This +mirrors the real user workflow: tweak settings, chat with Copilot via an MCP +tool, observe behavior. + +Entry points exercised: +- **Preferences → GitHub Copilot → Tool Auto Approve → MCP Configuration** — + the "Trust MCP tool annotations" checkbox and the server/tool tree. +- **Agent Mode chat** — sending prompts that trigger MCP tool calls. +- **Confirmation dialog** — the split-dropdown button with session/global + allow actions for tool and server scope. + +--- + +## Prerequisites + +- Eclipse IDE with the GitHub Copilot for Eclipse plugin installed and + activated. +- A signed-in Copilot account on the host machine. +- Network access to `api.githubcopilot.com`. +- **At least one MCP server configured** in the Copilot MCP settings, with + at least one tool available. Note the server name and a tool name for use + in the prompts below. +- **Agent Mode** selected in the chat mode dropdown. +- All MCP auto-approve preferences at their defaults before each scenario: + no globally approved servers/tools, "Trust MCP tool annotations" unchecked. + +--- + +## 1. Default behavior: confirmation dialog appears for MCP tools + +### TC-001: MCP tool call with no rules → confirmation dialog + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- No global approved servers or tools. +- "Trust MCP tool annotations" is **unchecked**. + +#### Steps +1. Open **Preferences → GitHub Copilot → Tool Auto Approve**. +2. Navigate to the **MCP Configuration** section. +3. Verify the server/tool tree shows no tools checked. +4. Confirm "Trust MCP tool annotations" is unchecked. +5. Close preferences. +6. Open the **Copilot Chat** view and select **Agent** mode. +7. Send a prompt that triggers the known MCP tool (e.g., `use to + `). +8. Wait for the Copilot turn — the agent should invoke the MCP tool. +9. Observe the **confirmation dialog** that appears in the chat panel. +10. Verify the dialog shows: + - Bold title: `Run '' tool from '' MCP server`. + - A description of the MCP tool call. + - A blue **"Allow Once ▾"** split-dropdown button and a **"Skip"** button. +11. Click the dropdown arrow on "Allow Once ▾". +12. Verify the dropdown contains: + - "Allow '' in this Session" + - "Always Allow ''" + - "Allow tools from '' in this Session" + - "Always Allow tools from ''" +13. Click **"Skip"**. +14. Verify the tool was **NOT** executed. + +#### Expected Result +- Confirmation dialog appears for MCP tools with no auto-approve rules. +- Dropdown shows tool-level and server-level scoped actions. +- Skipping prevents execution. + +#### 📸 Key Screenshots +- [ ] MCP preference section with no checked tools. +- [ ] Confirmation dialog with dropdown expanded showing all actions. +- [ ] Agent turn after skip — no tool output. + +--- + +## 2. Session allow for a specific tool + +### TC-002: "Allow tool in Session" → same tool auto-approves → new +conversation resets + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- No global approved servers or tools. +- "Trust MCP tool annotations" is **unchecked**. + +#### Steps +1. In Agent Mode, send a prompt that triggers the MCP tool. +2. Confirmation dialog appears. +3. Click the dropdown arrow and select **"Allow '' in this + Session"**. +4. The tool executes. +5. In the **same conversation**, send another prompt that triggers the same + tool. +6. Observe that **no confirmation dialog appears** — the tool is + session-approved. +7. Start a **new conversation** (click "New Chat" or equivalent). +8. Send a prompt that triggers the same MCP tool. +9. Observe that the **confirmation dialog appears again** — session approvals + do not carry over. + +#### Expected Result +- Session approval auto-approves the same tool within the conversation. +- New conversation resets session approvals. + +#### 📸 Key Screenshots +- [ ] First dialog: selecting "Allow '' in this Session". +- [ ] Second invocation (same conversation): auto-approved, no dialog. +- [ ] New conversation: dialog reappears. + +--- + +## 3. Session allow for an entire server + +### TC-003: "Allow all tools from server in Session" → all tools from +that server auto-approve + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- No global approved servers or tools. +- "Trust MCP tool annotations" is **unchecked**. + +#### Steps +1. In Agent Mode, send a prompt that triggers any tool from the target + MCP server. +2. Confirmation dialog appears. +3. Click the dropdown and select **"Allow all tools from '' + in this Session"**. +4. The tool executes. +5. In the **same conversation**, send prompts that trigger **different tools** + from the same server. +6. Observe that **none of them** show a confirmation dialog. +7. Start a **new conversation**. +8. Trigger any tool from the same server. +9. Observe that the **confirmation dialog appears again**. + +#### Expected Result +- Server-level session approval covers all tools from that server. +- New conversation resets the session approval. + +#### 📸 Key Screenshots +- [ ] Dropdown: selecting "Allow tools from '' in this Session". +- [ ] Second tool from same server: auto-approved. +- [ ] New conversation: dialog reappears. + +--- + +## 4. "Always Allow" for a specific tool — global persistence + +### TC-004: "Always Allow ''" → persists across conversations → +visible in preferences tree + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- No global approved servers or tools. +- "Trust MCP tool annotations" is **unchecked**. + +#### Steps +1. In Agent Mode, trigger the MCP tool. +2. Confirmation dialog appears. +3. Click the dropdown and select **"Always Allow ''"**. +4. The tool executes. +5. Open **Preferences → Tool Auto Approve → MCP Configuration**. +6. Verify the specific tool is **checked** in the server/tool tree. +7. Close preferences. +8. Start a **new conversation**. +9. Send a prompt that triggers the same MCP tool. +10. Observe that **no confirmation dialog appears** — the global rule persists. + +#### Expected Result +- "Always Allow" writes the tool key to the global preference store. +- The tool appears checked in the preference tree. +- The approval persists across conversations. + +#### 📸 Key Screenshots +- [ ] Dropdown: selecting "Always Allow ''". +- [ ] Preference tree: tool checked. +- [ ] New conversation: tool auto-approved without dialog. + +--- + +## 5. "Always Allow" for an entire server — global persistence + +### TC-005: "Always Allow all tools from ''" → server shown +checked in preferences + +**Type:** `Happy Path` +**Priority:** `P1` + +#### Preconditions +- No global approved servers or tools (clear any rules written by TC-004 + if running in sequence). +- "Trust MCP tool annotations" is **unchecked**. + +#### Steps +1. In Agent Mode, trigger any MCP tool. +2. Confirmation dialog appears. +3. Click the dropdown and select **"Always Allow all tools from + ''"**. +4. The tool executes. +5. Open **Preferences → Tool Auto Approve → MCP Configuration**. +6. Verify the server node is **checked** in the tree. +7. Close preferences. +8. Start a **new conversation** and trigger different tools from the same + server. +9. Observe all tools **auto-approve without dialog**. + +#### Expected Result +- "Always Allow" for server writes to the global servers list. +- The server row appears checked in the preference tree. +- All tools from the server auto-approve in new conversations. + +#### 📸 Key Screenshots +- [ ] Dropdown: "Always Allow all tools from ''". +- [ ] Preference tree: server node checked. +- [ ] New conversation: all server tools auto-approved. + +--- + +## 6. Trust MCP tool annotations — read-only tools auto-approve + +### TC-006: Enable "Trust MCP tool annotations" → tools with +readOnlyHint=true auto-approve + +**Type:** `Happy Path` +**Priority:** `P1` + +#### Preconditions +- An MCP tool is available with `readOnlyHint=true` and `openWorldHint=false` + in its annotations. + +#### Steps +1. Open **Preferences → Tool Auto Approve → MCP Configuration**. +2. Check **"Trust MCP tool annotations"**. +3. Click **"Apply and Close"**. +4. In Agent Mode, send a prompt that triggers the read-only MCP tool. +5. Observe that **no confirmation dialog appears** — the tool auto-approves + because it is annotated as read-only and closed-world. +6. Send a prompt that triggers an MCP tool that does NOT have + `readOnlyHint=true` (or has `openWorldHint=true`). +7. Observe that the **confirmation dialog appears** — only strictly + read-only + closed-world tools bypass confirmation. + +#### Expected Result +- Tools with `readOnlyHint=true` AND `openWorldHint=false` auto-approve. +- All other tools still show the confirmation dialog. + +#### 📸 Key Screenshots +- [ ] Preference: "Trust MCP tool annotations" checked. +- [ ] Read-only tool: auto-approved. +- [ ] Non-read-only tool: confirmation dialog shown. diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/FileOperationConfirmationHandlerTests.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/FileOperationConfirmationHandlerTests.java index beaa4d2a..5d9b9573 100644 --- a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/FileOperationConfirmationHandlerTests.java +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/FileOperationConfirmationHandlerTests.java @@ -62,7 +62,7 @@ void evaluate_globExactPathMatchCaseInsensitive() { new FileOperationAutoApproveRule("C:/Users/test.java", "", true))); stubUnmatched(false); - assertTrue(handler.evaluate( + assertTrue(evaluate( buildParams("C:\\Users\\test.java", false), CONV_ID).isAutoApproved()); } @@ -72,7 +72,7 @@ void evaluate_globStarStarPatternMatches() { new FileOperationAutoApproveRule("**/*.java", "", true))); stubUnmatched(false); - assertTrue(handler.evaluate( + assertTrue(evaluate( buildParams("/workspace/src/Main.java", false), CONV_ID).isAutoApproved()); } @@ -82,7 +82,7 @@ void evaluate_globPatternNoMatch() { new FileOperationAutoApproveRule("**/*.py", "", true))); stubUnmatched(false); - assertFalse(handler.evaluate( + assertFalse(evaluate( buildParams("/workspace/src/Main.java", false), CONV_ID).isAutoApproved()); } @@ -93,7 +93,7 @@ void evaluate_globBackslashPathNormalized() { new FileOperationAutoApproveRule("**/.github/instructions/*", "", true))); stubUnmatched(false); - assertTrue(handler.evaluate( + assertTrue(evaluate( buildParams("C:\\project\\.github\\instructions\\file.md", false), CONV_ID).isAutoApproved()); } @@ -105,7 +105,7 @@ void evaluate_invalidGlobRuleFallsThrough() { new FileOperationAutoApproveRule("[invalid", "", true))); stubUnmatched(true); - assertTrue(handler.evaluate( + assertTrue(evaluate( buildParams("/a/b.java", false), CONV_ID).isAutoApproved()); } @@ -117,7 +117,7 @@ void evaluate_autoApprovedWhenFileAttachedViaPending() { InvokeClientToolConfirmationParams params = buildParams("/workspace/src/Main.java", false); - assertTrue(handler.evaluate(params, CONV_ID).isAutoApproved()); + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); } @Test @@ -127,7 +127,7 @@ void evaluate_autoApprovedWhenFileAttachedToConversation() { InvokeClientToolConfirmationParams params = buildParams("/workspace/src/Main.java", false); - assertTrue(handler.evaluate(params, CONV_ID).isAutoApproved()); + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); } @Test @@ -139,7 +139,7 @@ void evaluate_attachedFilePathNormalized() { // Evaluate with forward slashes + lowercase InvokeClientToolConfirmationParams params = buildParams("c:/workspace/src/Main.java", false); - assertTrue(handler.evaluate(params, CONV_ID).isAutoApproved()); + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); } // --- evaluate: session overrides --- @@ -155,7 +155,7 @@ void evaluate_autoApprovedBySessionFileApproval() { buildParams("/workspace/src/Main.java", false); handler.cacheDecision(action, params, CONV_ID); - assertTrue(handler.evaluate(params, CONV_ID).isAutoApproved()); + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); } @Test @@ -171,7 +171,7 @@ void evaluate_sessionFileApprovalNormalizesPath() { // Evaluate with forward slash + lowercase InvokeClientToolConfirmationParams params = buildParams("c:/workspace/Main.java", false); - assertTrue(handler.evaluate(params, CONV_ID).isAutoApproved()); + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); } @Test @@ -185,7 +185,7 @@ void evaluate_autoApprovedBySessionFolderApproval() { InvokeClientToolConfirmationParams params = buildParams("/home/user/external/data.csv", false); - assertTrue(handler.evaluate(params, CONV_ID).isAutoApproved()); + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); } @Test @@ -203,7 +203,7 @@ void evaluate_sessionFolderDoesNotMatchParentPath() { // File in a different folder (prefix but not under the folder) InvokeClientToolConfirmationParams params = buildParams("/home/user/external-other/file.txt", false); - assertFalse(handler.evaluate(params, CONV_ID).isAutoApproved()); + assertFalse(evaluate(params, CONV_ID).isAutoApproved()); } @Test @@ -220,7 +220,7 @@ void evaluate_sessionApprovalDoesNotAffectOtherConversation() { InvokeClientToolConfirmationParams params = buildParams("/workspace/src/Main.java", false); - assertFalse(handler.evaluate(params, "other-conv").isAutoApproved()); + assertFalse(evaluate(params, "other-conv").isAutoApproved()); } // --- evaluate: outside workspace --- @@ -229,7 +229,7 @@ void evaluate_sessionApprovalDoesNotAffectOtherConversation() { void evaluate_outsideWorkspaceAlwaysRequiresConfirmation() { InvokeClientToolConfirmationParams params = buildParams("/tmp/secret.txt", true); - assertFalse(handler.evaluate(params, CONV_ID).isAutoApproved()); + assertFalse(evaluate(params, CONV_ID).isAutoApproved()); } @Test @@ -244,7 +244,7 @@ void evaluate_outsideWorkspaceStillAutoApprovedBySessionFolder() { InvokeClientToolConfirmationParams params = buildParams("/tmp/other.txt", true); - assertTrue(handler.evaluate(params, CONV_ID).isAutoApproved()); + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); } // --- evaluate: rule matching --- @@ -257,7 +257,7 @@ void evaluate_autoApprovedByAllowRule() { InvokeClientToolConfirmationParams params = buildParams("/workspace/src/Main.java", false); - assertTrue(handler.evaluate(params, CONV_ID).isAutoApproved()); + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); } @Test @@ -267,7 +267,7 @@ void evaluate_needsConfirmationByDenyRule() { InvokeClientToolConfirmationParams params = buildParams("/workspace/.github/instructions/rules.md", false); - ConfirmationResult result = handler.evaluate(params, CONV_ID); + ConfirmationResult result = evaluate(params, CONV_ID); assertFalse(result.isAutoApproved()); assertNotNull(result.getContent()); @@ -283,7 +283,7 @@ void evaluate_firstMatchingRuleWins() { InvokeClientToolConfirmationParams params = buildParams("/workspace/src/Main.java", false); // The first rule (deny .java) should win - assertFalse(handler.evaluate(params, CONV_ID).isAutoApproved()); + assertFalse(evaluate(params, CONV_ID).isAutoApproved()); } // --- evaluate: unmatched fallback --- @@ -296,7 +296,7 @@ void evaluate_unmatchedAutoApprovedWhenCheckboxTrue() { InvokeClientToolConfirmationParams params = buildParams("/workspace/src/Main.java", false); - assertTrue(handler.evaluate(params, CONV_ID).isAutoApproved()); + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); } @Test @@ -307,7 +307,7 @@ void evaluate_unmatchedNeedsConfirmationWhenCheckboxFalse() { InvokeClientToolConfirmationParams params = buildParams("/workspace/src/Main.java", false); - assertFalse(handler.evaluate(params, CONV_ID).isAutoApproved()); + assertFalse(evaluate(params, CONV_ID).isAutoApproved()); } @Test @@ -317,7 +317,7 @@ void evaluate_emptyRulesUsesUnmatchedSetting() { InvokeClientToolConfirmationParams params = buildParams("/workspace/src/Main.java", false); - assertTrue(handler.evaluate(params, CONV_ID).isAutoApproved()); + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); } // --- evaluate: blank file path --- @@ -329,7 +329,7 @@ void evaluate_blankFilePathNeedsConfirmation() { InvokeClientToolConfirmationParams params = buildParams(null, false); - assertFalse(handler.evaluate(params, CONV_ID).isAutoApproved()); + assertFalse(evaluate(params, CONV_ID).isAutoApproved()); } // --- evaluate: file path extraction --- @@ -343,7 +343,7 @@ void evaluate_extractsFilePathFromSensitiveFileData() { // Path set via sensitiveFileData (toolMetadata), not input map InvokeClientToolConfirmationParams params = buildParams("/workspace/src/Main.java", false); - assertTrue(handler.evaluate(params, CONV_ID).isAutoApproved()); + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); } @Test @@ -361,7 +361,7 @@ void evaluate_extractsFilePathFromInputMapFallback() { input.put("toolType", "file_write"); params.setInput(input); - assertTrue(handler.evaluate(params, CONV_ID).isAutoApproved()); + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); } @Test @@ -378,7 +378,7 @@ void evaluate_extractsPathKeyFromInputMapFallback() { input.put("toolType", "file_write"); params.setInput(input); - assertTrue(handler.evaluate(params, CONV_ID).isAutoApproved()); + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); } // --- cacheDecision: global rule --- @@ -465,7 +465,7 @@ void clearSession_removesFileAndFolderApprovals() { InvokeClientToolConfirmationParams params = buildParams("/workspace/src/Main.java", false); - assertFalse(handler.evaluate(params, CONV_ID).isAutoApproved()); + assertFalse(evaluate(params, CONV_ID).isAutoApproved()); } @Test @@ -484,7 +484,7 @@ void clearSession_doesNotAffectOtherConversation() { InvokeClientToolConfirmationParams params = buildParams("/workspace/src/Main.java", false); - assertTrue(handler.evaluate(params, CONV_ID).isAutoApproved()); + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); } @Test @@ -499,7 +499,7 @@ void clearSession_clearsAttachedFileRegistry() { InvokeClientToolConfirmationParams params = buildParams("/workspace/src/Main.java", false); - assertFalse(handler.evaluate(params, CONV_ID).isAutoApproved()); + assertFalse(evaluate(params, CONV_ID).isAutoApproved()); } // --- buildContent: in-workspace actions --- @@ -511,7 +511,7 @@ void buildContent_inWorkspaceHasAllowOnceAsPrimary() { InvokeClientToolConfirmationParams params = buildParams("/workspace/src/Main.java", false); - ConfirmationResult result = handler.evaluate(params, CONV_ID); + ConfirmationResult result = evaluate(params, CONV_ID); ConfirmationContent content = result.getContent(); assertNotNull(content); @@ -529,7 +529,7 @@ void buildContent_inWorkspaceHasSkipAsDismiss() { InvokeClientToolConfirmationParams params = buildParams("/workspace/src/Main.java", false); - ConfirmationResult result = handler.evaluate(params, CONV_ID); + ConfirmationResult result = evaluate(params, CONV_ID); List actions = result.getContent().getActions(); ConfirmationAction last = actions.get(actions.size() - 1); @@ -543,7 +543,7 @@ void buildContent_inWorkspaceHasFileSessionAndGlobalActions() { InvokeClientToolConfirmationParams params = buildParams("/workspace/src/Main.java", false); - ConfirmationResult result = handler.evaluate(params, CONV_ID); + ConfirmationResult result = evaluate(params, CONV_ID); List actions = result.getContent().getActions(); boolean hasFileSession = actions.stream().anyMatch(a -> @@ -563,7 +563,7 @@ void buildContent_inWorkspaceNoFolderAction() { InvokeClientToolConfirmationParams params = buildParams("/workspace/src/Main.java", false); - ConfirmationResult result = handler.evaluate(params, CONV_ID); + ConfirmationResult result = evaluate(params, CONV_ID); List actions = result.getContent().getActions(); boolean hasFolderSession = actions.stream().anyMatch(a -> @@ -581,7 +581,7 @@ void buildContent_outsideWorkspaceHasFolderSessionAction() { InvokeClientToolConfirmationParams params = buildParams("/tmp/data/file.txt", true); - ConfirmationResult result = handler.evaluate(params, CONV_ID); + ConfirmationResult result = evaluate(params, CONV_ID); List actions = result.getContent().getActions(); boolean hasFolderSession = actions.stream().anyMatch(a -> @@ -597,7 +597,7 @@ void buildContent_outsideWorkspaceNoFileGlobalAction() { InvokeClientToolConfirmationParams params = buildParams("/tmp/data/file.txt", true); - ConfirmationResult result = handler.evaluate(params, CONV_ID); + ConfirmationResult result = evaluate(params, CONV_ID); List actions = result.getContent().getActions(); boolean hasFileGlobal = actions.stream().anyMatch(a -> @@ -615,7 +615,7 @@ void buildContent_actionScopesAreCorrect() { InvokeClientToolConfirmationParams params = buildParams("/workspace/src/Main.java", false); - ConfirmationResult result = handler.evaluate(params, CONV_ID); + ConfirmationResult result = evaluate(params, CONV_ID); List actions = result.getContent().getActions(); @@ -644,7 +644,7 @@ void evaluate_priorityOrder_attachedFileBeatsGlobalDenyRule() { InvokeClientToolConfirmationParams params = buildParams("/workspace/src/Main.java", false); - assertTrue(handler.evaluate(params, CONV_ID).isAutoApproved()); + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); } @Test @@ -659,7 +659,7 @@ void evaluate_priorityOrder_sessionApprovalBeatsGlobalDenyRule() { InvokeClientToolConfirmationParams params = buildParams("/workspace/src/Main.java", false); - assertTrue(handler.evaluate(params, CONV_ID).isAutoApproved()); + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); } @Test @@ -674,7 +674,7 @@ void evaluate_priorityOrder_sessionFolderBeatsOutsideWorkspace() { InvokeClientToolConfirmationParams params = buildParams("/external/dir/another.txt", true); - assertTrue(handler.evaluate(params, CONV_ID).isAutoApproved()); + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); } // --- Helpers --- @@ -689,6 +689,11 @@ private void stubUnmatched(boolean value) { Constants.AUTO_APPROVE_UNMATCHED_FILE_OP)).thenReturn(value); } + private ConfirmationResult evaluate( + InvokeClientToolConfirmationParams params, String conversationId) { + return handler.evaluate(params, conversationId, true); + } + private static InvokeClientToolConfirmationParams buildParams( String filePath, boolean isGlobal) { InvokeClientToolConfirmationParams params = diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/McpConfirmationHandlerTests.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/McpConfirmationHandlerTests.java new file mode 100644 index 00000000..4380439b --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/McpConfirmationHandlerTests.java @@ -0,0 +1,460 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.confirmation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; + +import com.google.gson.Gson; +import org.eclipse.jface.preference.IPreferenceStore; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import com.microsoft.copilot.eclipse.core.Constants; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationAction; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationActionScope; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationContent; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.InvokeClientToolConfirmationParams; +import com.microsoft.copilot.eclipse.core.lsp.protocol.ToolAnnotations; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class McpConfirmationHandlerTests { + + private static final String CONV_ID = "conv-mcp-1"; + private static final String SERVER = "myServer"; + private static final String TOOL = "myTool"; + private static final Gson GSON = new Gson(); + + @Mock + private IPreferenceStore preferenceStore; + + private McpConfirmationHandler handler; + + @BeforeEach + void setUp() { + handler = new McpConfirmationHandler(preferenceStore); + stubGlobalServers(List.of()); + stubGlobalTools(List.of()); + stubTrustAnnotations(false); + } + + // --- evaluate: global server list --- + + @Test + void evaluate_autoApprovedWhenServerInGlobalList() { + stubGlobalServers(List.of(SERVER)); + + ConfirmationResult result = evaluate( + buildParams(SERVER, TOOL), CONV_ID); + + assertTrue(result.isAutoApproved()); + } + + @Test + void evaluate_autoApprovedWhenServerInGlobalListCaseInsensitive() { + stubGlobalServers(List.of(SERVER.toUpperCase())); + + ConfirmationResult result = evaluate( + buildParams(SERVER.toLowerCase(), TOOL), CONV_ID); + + assertTrue(result.isAutoApproved()); + } + + @Test + void evaluate_notAutoApprovedWhenServerNotInGlobalList() { + stubGlobalServers(List.of("otherServer")); + + ConfirmationResult result = evaluate( + buildParams(SERVER, TOOL), CONV_ID); + + assertFalse(result.isAutoApproved()); + } + + // --- evaluate: global tool list --- + + @Test + void evaluate_autoApprovedWhenToolInGlobalList() { + String toolKey = SERVER.toLowerCase() + "::" + TOOL.toLowerCase(); + stubGlobalTools(List.of(toolKey)); + + ConfirmationResult result = evaluate( + buildParams(SERVER, TOOL), CONV_ID); + + assertTrue(result.isAutoApproved()); + } + + @Test + void evaluate_autoApprovedWhenToolInGlobalListCaseInsensitive() { + String toolKey = SERVER.toUpperCase() + "::" + TOOL.toUpperCase(); + stubGlobalTools(List.of(toolKey)); + + ConfirmationResult result = evaluate( + buildParams(SERVER.toLowerCase(), TOOL.toLowerCase()), CONV_ID); + + assertTrue(result.isAutoApproved()); + } + + @Test + void evaluate_notAutoApprovedWhenOnlyOtherToolInGlobalList() { + String otherKey = SERVER.toLowerCase() + "::otherTool"; + stubGlobalTools(List.of(otherKey)); + + ConfirmationResult result = evaluate( + buildParams(SERVER, TOOL), CONV_ID); + + assertFalse(result.isAutoApproved()); + } + + // --- evaluate: trust annotations --- + + @Test + void evaluate_autoApprovedWhenReadOnlyAndTrustAnnotationsEnabled() { + stubTrustAnnotations(true); + ToolAnnotations annotations = new ToolAnnotations(); + annotations.setReadOnlyHint(true); + annotations.setOpenWorldHint(false); + + InvokeClientToolConfirmationParams params = buildParams(SERVER, TOOL); + params.setAnnotations(annotations); + + ConfirmationResult result = evaluate(params, CONV_ID); + + assertTrue(result.isAutoApproved()); + } + + @Test + void evaluate_notAutoApprovedWhenReadOnlyButOpenWorldHint() { + stubTrustAnnotations(true); + ToolAnnotations annotations = new ToolAnnotations(); + annotations.setReadOnlyHint(true); + annotations.setOpenWorldHint(true); + + InvokeClientToolConfirmationParams params = buildParams(SERVER, TOOL); + params.setAnnotations(annotations); + + ConfirmationResult result = evaluate(params, CONV_ID); + + assertFalse(result.isAutoApproved()); + } + + @Test + void evaluate_notAutoApprovedWhenAnnotationsTrustedButNotReadOnly() { + stubTrustAnnotations(true); + ToolAnnotations annotations = new ToolAnnotations(); + annotations.setReadOnlyHint(false); + annotations.setOpenWorldHint(false); + + InvokeClientToolConfirmationParams params = buildParams(SERVER, TOOL); + params.setAnnotations(annotations); + + ConfirmationResult result = evaluate(params, CONV_ID); + + assertFalse(result.isAutoApproved()); + } + + @Test + void evaluate_notAutoApprovedWhenReadOnlyButTrustAnnotationsDisabled() { + stubTrustAnnotations(false); + ToolAnnotations annotations = new ToolAnnotations(); + annotations.setReadOnlyHint(true); + annotations.setOpenWorldHint(false); + + InvokeClientToolConfirmationParams params = buildParams(SERVER, TOOL); + params.setAnnotations(annotations); + + ConfirmationResult result = evaluate(params, CONV_ID); + + assertFalse(result.isAutoApproved()); + } + + // --- evaluate: session approvals --- + + @Test + void evaluate_autoApprovedWhenToolApprovedForSession() { + InvokeClientToolConfirmationParams params = buildParams(SERVER, TOOL); + ConfirmationAction action = buildAction( + McpConfirmationHandler.Action.ACCEPT_TOOL_SESSION, + Map.of(McpConfirmationHandler.META_TOOL_KEY, + SERVER.toLowerCase() + "::" + TOOL.toLowerCase())); + handler.cacheDecision(action, params, CONV_ID); + + ConfirmationResult result = evaluate(params, CONV_ID); + + assertTrue(result.isAutoApproved()); + } + + @Test + void evaluate_notAutoApprovedWhenToolApprovedForDifferentSession() { + InvokeClientToolConfirmationParams params = buildParams(SERVER, TOOL); + ConfirmationAction action = buildAction( + McpConfirmationHandler.Action.ACCEPT_TOOL_SESSION, + Map.of(McpConfirmationHandler.META_TOOL_KEY, + SERVER.toLowerCase() + "::" + TOOL.toLowerCase())); + handler.cacheDecision(action, params, "other-conv"); + + ConfirmationResult result = evaluate(params, CONV_ID); + + assertFalse(result.isAutoApproved()); + } + + @Test + void evaluate_autoApprovedWhenServerApprovedForSession() { + InvokeClientToolConfirmationParams params = buildParams(SERVER, TOOL); + ConfirmationAction action = buildAction( + McpConfirmationHandler.Action.ACCEPT_SERVER_SESSION, + Map.of(McpConfirmationHandler.META_SERVER_NAME, SERVER)); + handler.cacheDecision(action, params, CONV_ID); + + ConfirmationResult result = evaluate(params, CONV_ID); + + assertTrue(result.isAutoApproved()); + } + + // --- cacheDecision: global persistence --- + + @Test + void cacheDecision_acceptToolGlobal_writesToPreferenceStore() { + String toolKey = SERVER.toLowerCase() + "::" + TOOL.toLowerCase(); + stubGlobalTools(List.of()); + + ConfirmationAction action = buildAction( + McpConfirmationHandler.Action.ACCEPT_TOOL_GLOBAL, + Map.of(McpConfirmationHandler.META_TOOL_KEY, toolKey)); + handler.cacheDecision(action, buildParams(SERVER, TOOL), CONV_ID); + + ArgumentCaptor captor = ArgumentCaptor.forClass(String.class); + verify(preferenceStore).setValue( + org.mockito.ArgumentMatchers.eq(Constants.AUTO_APPROVE_MCP_TOOLS), + captor.capture()); + assertTrue(captor.getValue().contains(toolKey)); + } + + @Test + void cacheDecision_acceptServerGlobal_writesToPreferenceStore() { + stubGlobalServers(List.of()); + + ConfirmationAction action = buildAction( + McpConfirmationHandler.Action.ACCEPT_SERVER_GLOBAL, + Map.of(McpConfirmationHandler.META_SERVER_NAME, SERVER)); + handler.cacheDecision(action, buildParams(SERVER, TOOL), CONV_ID); + + ArgumentCaptor captor = ArgumentCaptor.forClass(String.class); + verify(preferenceStore).setValue( + org.mockito.ArgumentMatchers.eq( + Constants.AUTO_APPROVE_MCP_SERVERS), + captor.capture()); + assertTrue(captor.getValue().contains(SERVER)); + } + + @Test + void cacheDecision_acceptToolGlobal_noDuplicateWrite() { + String toolKey = SERVER.toLowerCase() + "::" + TOOL.toLowerCase(); + stubGlobalTools(List.of(toolKey)); + + ConfirmationAction action = buildAction( + McpConfirmationHandler.Action.ACCEPT_TOOL_GLOBAL, + Map.of(McpConfirmationHandler.META_TOOL_KEY, toolKey)); + handler.cacheDecision(action, buildParams(SERVER, TOOL), CONV_ID); + + // setValue should NOT be called — key already present + verify(preferenceStore, org.mockito.Mockito.never()) + .setValue(org.mockito.ArgumentMatchers.eq( + Constants.AUTO_APPROVE_MCP_TOOLS), + org.mockito.ArgumentMatchers.anyString()); + } + + // --- clearSession --- + + @Test + void clearSession_removesSessionApprovalsForConversation() { + InvokeClientToolConfirmationParams params = buildParams(SERVER, TOOL); + ConfirmationAction action = buildAction( + McpConfirmationHandler.Action.ACCEPT_TOOL_SESSION, + Map.of(McpConfirmationHandler.META_TOOL_KEY, + SERVER.toLowerCase() + "::" + TOOL.toLowerCase())); + handler.cacheDecision(action, params, CONV_ID); + + handler.clearSession(CONV_ID); + + ConfirmationResult result = evaluate(params, CONV_ID); + assertFalse(result.isAutoApproved()); + } + + @Test + void clearSession_doesNotAffectOtherConversation() { + InvokeClientToolConfirmationParams params = buildParams(SERVER, TOOL); + ConfirmationAction action = buildAction( + McpConfirmationHandler.Action.ACCEPT_TOOL_SESSION, + Map.of(McpConfirmationHandler.META_TOOL_KEY, + SERVER.toLowerCase() + "::" + TOOL.toLowerCase())); + handler.cacheDecision(action, params, CONV_ID); + + handler.clearSession("other-conv"); + + ConfirmationResult result = evaluate(params, CONV_ID); + assertTrue(result.isAutoApproved()); + } + + // --- buildContent (actions) --- + + @Test + void buildContent_hasAllowOnceAsFirstAction() { + ConfirmationResult result = evaluate( + buildParams(SERVER, TOOL), CONV_ID); + + List actions = result.getContent().getActions(); + assertTrue(actions.get(0).isPrimary()); + assertTrue(actions.get(0).isAccept()); + assertEquals(ConfirmationActionScope.ONCE, actions.get(0).getScope()); + } + + @Test + void buildContent_hasSkipAsLastAction() { + ConfirmationResult result = evaluate( + buildParams(SERVER, TOOL), CONV_ID); + + List actions = result.getContent().getActions(); + assertFalse(actions.get(actions.size() - 1).isAccept()); + } + + @Test + void buildContent_hasAllFourScopedActions() { + ConfirmationResult result = evaluate( + buildParams(SERVER, TOOL), CONV_ID); + + List actions = result.getContent().getActions(); + assertTrue(hasAction(actions, + McpConfirmationHandler.Action.ACCEPT_TOOL_SESSION)); + assertTrue(hasAction(actions, + McpConfirmationHandler.Action.ACCEPT_TOOL_GLOBAL)); + assertTrue(hasAction(actions, + McpConfirmationHandler.Action.ACCEPT_SERVER_SESSION)); + assertTrue(hasAction(actions, + McpConfirmationHandler.Action.ACCEPT_SERVER_GLOBAL)); + } + + @Test + void buildContent_toolAndServerActionsHaveCorrectScopes() { + ConfirmationResult result = evaluate( + buildParams(SERVER, TOOL), CONV_ID); + + List actions = result.getContent().getActions(); + actions.stream() + .filter(a -> hasAction(a, + McpConfirmationHandler.Action.ACCEPT_TOOL_SESSION) + || hasAction(a, + McpConfirmationHandler.Action.ACCEPT_SERVER_SESSION)) + .forEach(a -> assertEquals( + ConfirmationActionScope.SESSION, a.getScope())); + actions.stream() + .filter(a -> hasAction(a, + McpConfirmationHandler.Action.ACCEPT_TOOL_GLOBAL) + || hasAction(a, + McpConfirmationHandler.Action.ACCEPT_SERVER_GLOBAL)) + .forEach(a -> assertEquals( + ConfirmationActionScope.GLOBAL, a.getScope())); + } + + @Test + void buildContent_contentHasTitleWithToolAndServer() { + ConfirmationResult result = evaluate( + buildParams(SERVER, TOOL), CONV_ID); + + ConfirmationContent content = result.getContent(); + assertNotNull(content); + assertNotNull(content.getTitle()); + assertTrue(content.getTitle().contains(TOOL)); + assertTrue(content.getTitle().contains(SERVER)); + } + + @Test + void buildContent_noActionsWhenServerAndToolNull() { + InvokeClientToolConfirmationParams params = + buildParams(null, null); + + ConfirmationResult result = evaluate(params, CONV_ID); + + assertFalse(result.isAutoApproved()); + List actions = result.getContent().getActions(); + assertFalse(hasAction(actions, + McpConfirmationHandler.Action.ACCEPT_TOOL_SESSION)); + assertFalse(hasAction(actions, + McpConfirmationHandler.Action.ACCEPT_SERVER_SESSION)); + } + + // --- Helpers --- + + private void stubGlobalServers(List servers) { + when(preferenceStore.getString(Constants.AUTO_APPROVE_MCP_SERVERS)) + .thenReturn(GSON.toJson(servers)); + } + + private void stubGlobalTools(List tools) { + when(preferenceStore.getString(Constants.AUTO_APPROVE_MCP_TOOLS)) + .thenReturn(GSON.toJson(tools)); + } + + private void stubTrustAnnotations(boolean value) { + when(preferenceStore.getBoolean( + Constants.AUTO_APPROVE_TRUST_TOOL_ANNOTATIONS)) + .thenReturn(value); + } + + private ConfirmationResult evaluate( + InvokeClientToolConfirmationParams params, String conversationId) { + return handler.evaluate(params, conversationId, true); + } + + private static InvokeClientToolConfirmationParams buildParams( + String serverName, String toolName) { + InvokeClientToolConfirmationParams params = + new InvokeClientToolConfirmationParams(); + params.setConversationId(CONV_ID); + Map input = new java.util.HashMap<>(); + input.put("toolType", "mcp_tool"); + if (serverName != null) { + input.put("mcpServerName", serverName); + } + if (toolName != null) { + input.put("mcpToolName", toolName); + } + params.setInput(input); + return params; + } + + private static ConfirmationAction buildAction( + McpConfirmationHandler.Action type, Map extra) { + Map meta = new java.util.HashMap<>(extra); + meta.put(ConfirmationAction.META_ACTION, type.name()); + return new ConfirmationAction( + "test", true, ConfirmationActionScope.SESSION, meta, false); + } + + private static boolean hasAction(List actions, + McpConfirmationHandler.Action type) { + return actions.stream().anyMatch(a -> hasAction(a, type)); + } + + private static boolean hasAction(ConfirmationAction action, + McpConfirmationHandler.Action type) { + return action.getMetadata().containsKey(ConfirmationAction.META_ACTION) + && action.getMetadata().get(ConfirmationAction.META_ACTION) + .equals(type.name()); + } +} diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/TerminalConfirmationHandlerTests.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/TerminalConfirmationHandlerTests.java index ede64c98..5cb11e3e 100644 --- a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/TerminalConfirmationHandlerTests.java +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/TerminalConfirmationHandlerTests.java @@ -58,7 +58,7 @@ void ruleMatching_simpleRuleMatchesCommandAtStart() { InvokeClientToolConfirmationParams params = buildParams(new String[]{"rm -rf /tmp"}, new String[]{"rm"}, "rm -rf /tmp"); - assertTrue(handler.evaluate(params, CONV_ID).isAutoApproved()); + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); } @Test @@ -68,7 +68,7 @@ void ruleMatching_simpleRuleDoesNotMatchMiddleOfWord() { InvokeClientToolConfirmationParams params = buildParams(new String[]{"remove something"}, new String[]{"remove"}, "remove something"); - assertFalse(handler.evaluate(params, CONV_ID).isAutoApproved()); + assertFalse(evaluate(params, CONV_ID).isAutoApproved()); } @Test @@ -78,7 +78,7 @@ void ruleMatching_regexCaseInsensitive() { InvokeClientToolConfirmationParams params = buildParams(new String[]{"Git status"}, new String[]{"Git"}, "Git status"); - assertTrue(handler.evaluate(params, CONV_ID).isAutoApproved()); + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); } @Test @@ -89,7 +89,7 @@ void ruleMatching_regexDotallMatchesSubshell() { InvokeClientToolConfirmationParams params = buildParams(new String[]{"(echo hello)"}, new String[]{"(echo"}, "(echo hello)"); - assertTrue(handler.evaluate(params, CONV_ID).isAutoApproved()); + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); } @Test @@ -98,7 +98,7 @@ void ruleMatching_noMatchWhenSubCommandsNull() { stubUnmatched(false); InvokeClientToolConfirmationParams params = buildParams(null, null, "rm -rf"); - assertFalse(handler.evaluate(params, CONV_ID).isAutoApproved()); + assertFalse(evaluate(params, CONV_ID).isAutoApproved()); } @Test @@ -107,7 +107,7 @@ void ruleMatching_noMatchWhenSubCommandsEmpty() { stubUnmatched(false); InvokeClientToolConfirmationParams params = buildParams(new String[]{}, new String[]{}, "rm -rf"); - assertFalse(handler.evaluate(params, CONV_ID).isAutoApproved()); + assertFalse(evaluate(params, CONV_ID).isAutoApproved()); } @Test @@ -116,7 +116,7 @@ void ruleMatching_noMatchWhenSubCommandBlank() { stubUnmatched(false); InvokeClientToolConfirmationParams params = buildParams(new String[]{" "}, new String[]{" "}, " "); - assertFalse(handler.evaluate(params, CONV_ID).isAutoApproved()); + assertFalse(evaluate(params, CONV_ID).isAutoApproved()); } // --- evaluate --- @@ -129,7 +129,7 @@ void evaluate_autoApprovedWhenAllSubCommandsMatchAllowRules() { InvokeClientToolConfirmationParams params = buildParams(new String[]{"echo hello"}, new String[]{"echo"}, "echo hello"); - ConfirmationResult result = handler.evaluate(params, CONV_ID); + ConfirmationResult result = evaluate(params, CONV_ID); assertTrue(result.isAutoApproved()); } @@ -142,7 +142,7 @@ void evaluate_needsConfirmationWhenDenyRuleMatches() { InvokeClientToolConfirmationParams params = buildParams(new String[]{"rm -rf /"}, new String[]{"rm"}, "rm -rf /"); - ConfirmationResult result = handler.evaluate(params, CONV_ID); + ConfirmationResult result = evaluate(params, CONV_ID); assertFalse(result.isAutoApproved()); assertNotNull(result.getContent()); @@ -156,7 +156,7 @@ void evaluate_needsConfirmationWhenNoRulesMatchAndUnmatchedFalse() { InvokeClientToolConfirmationParams params = buildParams(new String[]{"ls -la"}, new String[]{"ls"}, "ls -la"); - ConfirmationResult result = handler.evaluate(params, CONV_ID); + ConfirmationResult result = evaluate(params, CONV_ID); assertFalse(result.isAutoApproved()); } @@ -169,7 +169,7 @@ void evaluate_autoApprovedWhenNoRulesMatchAndUnmatchedTrue() { InvokeClientToolConfirmationParams params = buildParams(new String[]{"ls -la"}, new String[]{"ls"}, "ls -la"); - ConfirmationResult result = handler.evaluate(params, CONV_ID); + ConfirmationResult result = evaluate(params, CONV_ID); assertTrue(result.isAutoApproved()); } @@ -180,7 +180,7 @@ void evaluate_needsConfirmationWhenSubCommandsNull() { InvokeClientToolConfirmationParams params = buildParams(null, null, "echo hello"); - ConfirmationResult result = handler.evaluate(params, CONV_ID); + ConfirmationResult result = evaluate(params, CONV_ID); assertFalse(result.isAutoApproved()); } @@ -191,7 +191,7 @@ void evaluate_needsConfirmationWhenSubCommandsEmpty() { InvokeClientToolConfirmationParams params = buildParams(new String[]{}, null, "echo hello"); - ConfirmationResult result = handler.evaluate(params, CONV_ID); + ConfirmationResult result = evaluate(params, CONV_ID); assertFalse(result.isAutoApproved()); } @@ -203,7 +203,7 @@ void evaluate_emptyRulesUsesUnmatchedSetting() { InvokeClientToolConfirmationParams params = buildParams(new String[]{"ls"}, new String[]{"ls"}, "ls"); - ConfirmationResult result = handler.evaluate(params, CONV_ID); + ConfirmationResult result = evaluate(params, CONV_ID); assertTrue(result.isAutoApproved()); } @@ -223,7 +223,7 @@ void cacheDecision_acceptAllSession_autoApprovesSubsequent() { TerminalConfirmationHandler.Action.ACCEPT_ALL_SESSION); handler.cacheDecision(allSession, params, CONV_ID); - ConfirmationResult result = handler.evaluate(params, CONV_ID); + ConfirmationResult result = evaluate(params, CONV_ID); assertTrue(result.isAutoApproved()); } @@ -240,7 +240,7 @@ void cacheDecision_acceptNamesSession_autoApprovesMatchingNames() { TerminalConfirmationHandler.Action.ACCEPT_NAMES_SESSION); handler.cacheDecision(namesSession, params, CONV_ID); - ConfirmationResult result = handler.evaluate(params, CONV_ID); + ConfirmationResult result = evaluate(params, CONV_ID); assertTrue(result.isAutoApproved()); } @@ -257,7 +257,7 @@ void cacheDecision_acceptExactSession_autoApprovesMatchingCommand() { TerminalConfirmationHandler.Action.ACCEPT_EXACT_SESSION); handler.cacheDecision(exactSession, params, CONV_ID); - ConfirmationResult result = handler.evaluate(params, CONV_ID); + ConfirmationResult result = evaluate(params, CONV_ID); assertTrue(result.isAutoApproved()); } @@ -276,7 +276,7 @@ void clearSession_removesApprovalsForConversation() { handler.clearSession(CONV_ID); - ConfirmationResult result = handler.evaluate(params, CONV_ID); + ConfirmationResult result = evaluate(params, CONV_ID); assertFalse(result.isAutoApproved()); } @@ -295,7 +295,7 @@ void clearSession_doesNotAffectOtherConversation() { handler.clearSession("other-conv"); - ConfirmationResult result = handler.evaluate(params, CONV_ID); + ConfirmationResult result = evaluate(params, CONV_ID); assertTrue(result.isAutoApproved()); } @@ -309,7 +309,7 @@ void buildContent_alwaysHasAllowOnceAsPrimary() { InvokeClientToolConfirmationParams params = buildParams(new String[]{"echo"}, new String[]{"echo"}, "echo hello"); - ConfirmationResult result = handler.evaluate(params, CONV_ID); + ConfirmationResult result = evaluate(params, CONV_ID); ConfirmationContent content = result.getContent(); assertNotNull(content); @@ -327,7 +327,7 @@ void buildContent_alwaysHasSkipAsDismiss() { InvokeClientToolConfirmationParams params = buildParams(new String[]{"echo"}, new String[]{"echo"}, "echo hello"); - ConfirmationResult result = handler.evaluate(params, CONV_ID); + ConfirmationResult result = evaluate(params, CONV_ID); List actions = result.getContent().getActions(); ConfirmationAction last = actions.get(actions.size() - 1); @@ -342,7 +342,7 @@ void buildContent_hasAllowAllSessionAction() { InvokeClientToolConfirmationParams params = buildParams(new String[]{"echo"}, new String[]{"echo"}, "echo hello"); - ConfirmationResult result = handler.evaluate(params, CONV_ID); + ConfirmationResult result = evaluate(params, CONV_ID); List actions = result.getContent().getActions(); boolean hasAllSession = actions.stream().anyMatch(a -> @@ -362,7 +362,7 @@ void buildContent_hasCommandNameActionsWhenNamesPresent() { InvokeClientToolConfirmationParams params = buildParams(new String[]{"echo"}, new String[]{"echo"}, "echo hello"); - ConfirmationResult result = handler.evaluate(params, CONV_ID); + ConfirmationResult result = evaluate(params, CONV_ID); List actions = result.getContent().getActions(); boolean hasNamesSession = actions.stream().anyMatch(a -> @@ -384,7 +384,7 @@ void buildContent_hasExactCommandActionsWhenDifferentFromName() { InvokeClientToolConfirmationParams params = buildParams(new String[]{"echo"}, new String[]{"echo"}, "echo hello"); - ConfirmationResult result = handler.evaluate(params, CONV_ID); + ConfirmationResult result = evaluate(params, CONV_ID); List actions = result.getContent().getActions(); boolean hasExactSession = actions.stream().anyMatch(a -> @@ -405,7 +405,7 @@ void buildContent_noExactActionsWhenSingleSubCommandEqualsName() { // commandLine equals the single commandName InvokeClientToolConfirmationParams params = buildParams(new String[]{"echo"}, new String[]{"echo"}, "echo"); - ConfirmationResult result = handler.evaluate(params, CONV_ID); + ConfirmationResult result = evaluate(params, CONV_ID); List actions = result.getContent().getActions(); boolean hasExact = actions.stream().anyMatch(a -> @@ -424,7 +424,7 @@ void buildContent_actionScopesAreCorrect() { InvokeClientToolConfirmationParams params = buildParams(new String[]{"echo"}, new String[]{"echo"}, "echo hello"); - ConfirmationResult result = handler.evaluate(params, CONV_ID); + ConfirmationResult result = evaluate(params, CONV_ID); List actions = result.getContent().getActions(); @@ -464,6 +464,11 @@ private void stubUnmatched(boolean value) { Constants.AUTO_APPROVE_UNMATCHED_TERMINAL)).thenReturn(value); } + private ConfirmationResult evaluate( + InvokeClientToolConfirmationParams params, String conversationId) { + return handler.evaluate(params, conversationId, true); + } + private static InvokeClientToolConfirmationParams buildParams( String[] subCommands, String[] commandNames, String commandLine) { TerminalCommandData tcd = new TerminalCommandData(); @@ -519,7 +524,7 @@ void buildContent_filtersSessionApprovedNamesFromActions() { new String[]{"echo hello", "curl example.com"}, new String[]{"echo", "curl"}, "echo hello && curl example.com"); - ConfirmationResult result = handler.evaluate(params, CONV_ID); + ConfirmationResult result = evaluate(params, CONV_ID); assertFalse(result.isAutoApproved()); List actions = result.getContent().getActions(); @@ -546,7 +551,7 @@ void buildContent_filtersGlobalApprovedNamesFromActions() { new String[]{"echo hello", "hostname"}, new String[]{"echo", "hostname"}, "echo hello && hostname"); - ConfirmationResult result = handler.evaluate(params, CONV_ID); + ConfirmationResult result = evaluate(params, CONV_ID); assertFalse(result.isAutoApproved()); List actions = result.getContent().getActions(); @@ -582,7 +587,7 @@ void buildContent_allNamesApproved_autoApproves() { new String[]{"echo hello", "curl example.com"}, new String[]{"echo", "curl"}, "echo hello && curl example.com"); - ConfirmationResult result = handler.evaluate(params, CONV_ID); + ConfirmationResult result = evaluate(params, CONV_ID); assertTrue(result.isAutoApproved()); } @@ -602,7 +607,7 @@ void sessionRules_surviveConversationSwitch() { InvokeClientToolConfirmationParams params = buildParams(new String[]{"echo world"}, new String[]{"echo"}, "echo world"); - ConfirmationResult result = handler.evaluate(params, "conv-A"); + ConfirmationResult result = evaluate(params, "conv-A"); assertTrue(result.isAutoApproved()); } @@ -631,15 +636,15 @@ void sessionRules_evictOldestWhenCapExceeded() { InvokeClientToolConfirmationParams params = buildParams(new String[]{"echo test"}, new String[]{"echo"}, "echo test"); - ConfirmationResult evicted = handler.evaluate(params, "conv-0"); + ConfirmationResult evicted = evaluate(params, "conv-0"); assertFalse(evicted.isAutoApproved()); // conv-new should still work - ConfirmationResult kept = handler.evaluate(params, "conv-new"); + ConfirmationResult kept = evaluate(params, "conv-new"); assertTrue(kept.isAutoApproved()); // conv-1 (second oldest, not evicted) should still work - ConfirmationResult second = handler.evaluate(params, "conv-1"); + ConfirmationResult second = evaluate(params, "conv-1"); assertTrue(second.isAutoApproved()); } } diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/Messages.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/Messages.java index f03a2186..157d800d 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/Messages.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/Messages.java @@ -52,6 +52,12 @@ public final class Messages extends NLS { public static String confirmation_action_allowFileSession; public static String confirmation_action_allowFolderSession; + // MCP confirmation dialog action labels + public static String confirmation_title_mcpTool; + public static String confirmation_title_mcpToolDefault; + public static String confirmation_action_allowServerSession; + public static String confirmation_action_alwaysAllowServer; + // Confirmation dialog titles public static String confirmation_title_terminal; public static String confirmation_title_fallback; diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/ConfirmationHandler.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/ConfirmationHandler.java index 18547886..3e1e2e1a 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/ConfirmationHandler.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/ConfirmationHandler.java @@ -5,6 +5,7 @@ import java.util.Locale; import java.util.Map; +import java.util.Map.Entry; import com.microsoft.copilot.eclipse.core.chat.ConfirmationAction; import com.microsoft.copilot.eclipse.core.chat.ConfirmationResult; @@ -49,17 +50,18 @@ static String extractToolType(InvokeClientToolConfirmationParams params) { } /** - * Evaluates whether the given confirmation request should be auto-approved. - * Implementations should check both global rules and session memory. + * Evaluates whether the given confirmation request should be auto-approved, + * taking into account whether the auto-approval feature is enabled by token/policy. * * @param params the confirmation request parameters from CLS * @param sessionConversationId the conversation ID to use for session-scoped * lookups (may differ from params.getConversationId() when called from a * subagent context) + * @param isAutoApprovalEnabled whether the auto-approval feature is currently enabled * @return the confirmation result */ ConfirmationResult evaluate(InvokeClientToolConfirmationParams params, - String sessionConversationId); + String sessionConversationId, boolean isAutoApprovalEnabled); /** * Caches a user's decision based on the action scope. @@ -80,4 +82,21 @@ default void cacheDecision(ConfirmationAction action, default void clearSession(String conversationId) { // no-op by default } + + /** + * Evicts the oldest entry from a {@link java.util.LinkedHashMap}-backed map when it reaches + * {@link #MAX_SESSION_CONVERSATIONS}. Thread-safe via {@code synchronized(map)}. + * + * @param value type + * @param map the map to evict from (must be a {@code Collections.synchronizedMap} wrapping a + * {@code LinkedHashMap} for correct eviction order) + */ + static void evictOldestIfNeeded(Map map) { + synchronized (map) { + while (map.size() >= MAX_SESSION_CONVERSATIONS) { + Entry oldest = map.entrySet().iterator().next(); + map.remove(oldest.getKey()); + } + } + } } diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/ConfirmationService.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/ConfirmationService.java index ed13ab8d..50242adc 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/ConfirmationService.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/ConfirmationService.java @@ -8,6 +8,9 @@ import org.eclipse.jface.preference.IPreferenceStore; +import com.microsoft.copilot.eclipse.core.Constants; +import com.microsoft.copilot.eclipse.core.CopilotCore; +import com.microsoft.copilot.eclipse.core.FeatureFlags; import com.microsoft.copilot.eclipse.core.chat.ConfirmationAction; import com.microsoft.copilot.eclipse.core.chat.ConfirmationActionScope; import com.microsoft.copilot.eclipse.core.chat.ConfirmationResult; @@ -79,27 +82,37 @@ public ConfirmationService(IPreferenceStore preferenceStore, handlers.put(ToolCategory.FILE_READ, fileHandler); handlers.put(ToolCategory.FILE_WRITE, fileHandler); handlers.put(ToolCategory.FILE_OPERATION, fileHandler); + handlers.put(ToolCategory.MCP_TOOL, + new McpConfirmationHandler(preferenceStore)); } /** * Evaluates whether a tool confirmation request should be auto-approved. * + *

When the auto-approval feature is disabled by token or organization policy, all + * auto-approve rules (YOLO, session, global) are bypassed and the user is always prompted + * with a simple Allow-Once / Skip dialog. + * * @param params the confirmation request parameters * @param sessionConversationId the conversation ID for session-scoped lookups */ public ConfirmationResult evaluate( InvokeClientToolConfirmationParams params, String sessionConversationId) { + FeatureFlags flags = CopilotCore.getPlugin().getFeatureFlags(); + boolean autoApprovalEnabled = flags == null || flags.isAutoApprovalEnabled(); + + if (autoApprovalEnabled && preferenceStore.getBoolean(Constants.AUTO_APPROVE_YOLO_MODE)) { + return ConfirmationResult.AUTO_APPROVED; + } + ToolCategory category = classify(params); if (category == ToolCategory.SAFE_TOOL) { return ConfirmationResult.AUTO_APPROVED; } - ConfirmationHandler handler = handlers.get(category); - if (handler == null) { - handler = fallbackHandler; - } - return handler.evaluate(params, sessionConversationId); + ConfirmationHandler handler = handlers.getOrDefault(category, fallbackHandler); + return handler.evaluate(params, sessionConversationId, autoApprovalEnabled); } /** diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/FallbackConfirmationHandler.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/FallbackConfirmationHandler.java index 9fd99b25..d58c77e7 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/FallbackConfirmationHandler.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/FallbackConfirmationHandler.java @@ -22,7 +22,7 @@ public class FallbackConfirmationHandler implements ConfirmationHandler { @Override public ConfirmationResult evaluate( InvokeClientToolConfirmationParams params, - String sessionConversationId) { + String sessionConversationId, boolean isAutoApprovalEnabled) { String title = params.getTitle() != null ? params.getTitle() : NLS.bind(Messages.confirmation_title_fallback, params.getName()); diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/FileOperationConfirmationHandler.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/FileOperationConfirmationHandler.java index 87969a87..313c56c2 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/FileOperationConfirmationHandler.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/FileOperationConfirmationHandler.java @@ -133,6 +133,15 @@ public FileOperationConfirmationHandler(IPreferenceStore preferenceStore, @Override public ConfirmationResult evaluate(InvokeClientToolConfirmationParams params, + String sessionConversationId, boolean isAutoApprovalEnabled) { + if (!isAutoApprovalEnabled) { + return evaluateAutoApprovalDisabled(params, sessionConversationId); + } + return evaluateAutoApprovalEnabled(params, sessionConversationId); + } + + private ConfirmationResult evaluateAutoApprovalEnabled( + InvokeClientToolConfirmationParams params, String sessionConversationId) { String filePath = extractFilePath(params); if (StringUtils.isBlank(filePath)) { @@ -180,6 +189,36 @@ public ConfirmationResult evaluate(InvokeClientToolConfirmationParams params, return evaluateUnmatched(params); } + private ConfirmationResult evaluateAutoApprovalDisabled( + InvokeClientToolConfirmationParams params, String sessionConversationId) { + String filePath = extractFilePath(params); + if (StringUtils.isBlank(filePath)) { + return ConfirmationResult.DISMISSED; + } + + // Still honor files explicitly attached by the user (intentional context) + if (attachedFileRegistry.isAttachedFile(sessionConversationId, filePath)) { + return ConfirmationResult.AUTO_APPROVED; + } + + // Outside workspace always requires confirmation (simplified dialog only) + if (isOutsideWorkspace(params)) { + return ConfirmationResult.needsConfirmation(buildSimplifiedContent(params)); + } + + // Only check default rules; ignore user-configured rules + for (FileOperationAutoApproveRule rule : FALLBACK_DEFAULT_RULES) { + if (matchesGlob(filePath, rule.getPattern())) { + return rule.isAutoApprove() + ? ConfirmationResult.AUTO_APPROVED + : ConfirmationResult.needsConfirmation(buildSimplifiedContent(params)); + } + } + + // Unmatched workspace file: auto-approve + return ConfirmationResult.AUTO_APPROVED; + } + private ConfirmationResult evaluateUnmatched( InvokeClientToolConfirmationParams params) { if (preferenceStore.getBoolean(Constants.AUTO_APPROVE_UNMATCHED_FILE_OP)) { @@ -240,6 +279,31 @@ private ConfirmationContent buildContent( return new ConfirmationContent(title, message, actions); } + /** + * Builds a simplified confirmation dialog with only Allow Once and Skip actions. + * Used when the auto-approval feature is disabled by token/policy. + */ + private ConfirmationContent buildSimplifiedContent( + InvokeClientToolConfirmationParams params) { + String filePath = extractFilePath(params); + final FileToolType fileType = + FileToolType.fromValue(ConfirmationHandler.extractToolType(params)); + String fileName = ""; + try { + if (filePath != null) { + fileName = Path.of(filePath).getFileName().toString(); + } + } catch (Exception ignored) { + // use empty + } + String title = params.getTitle() != null ? params.getTitle() : fileType.getDefaultTitle(); + String message = NLS.bind(fileType.getMessageTemplate(), fileName); + return new ConfirmationContent(title, message, + List.of( + ConfirmationAction.allowOnce(Messages.confirmation_action_allowOnce), + ConfirmationAction.skip(Messages.confirmation_action_skip))); + } + private static ConfirmationAction action(Action type, String label, ConfirmationActionScope scope, Map extra) { Map meta = new java.util.HashMap<>(extra); @@ -342,7 +406,7 @@ public void cacheDecision(ConfirmationAction action, case ACCEPT_FILE_SESSION: String fp = meta.getOrDefault(META_FILE_PATH, ""); if (!fp.isEmpty()) { - evictOldestIfNeeded(sessionApprovedFiles); + ConfirmationHandler.evictOldestIfNeeded(sessionApprovedFiles); sessionApprovedFiles.computeIfAbsent( convId, k -> ConcurrentHashMap.newKeySet()) .add(normalizePath(fp)); @@ -351,7 +415,7 @@ public void cacheDecision(ConfirmationAction action, case ACCEPT_FOLDER_SESSION: String folder = meta.getOrDefault(META_FOLDER_PATH, ""); if (!folder.isEmpty()) { - evictOldestIfNeeded(sessionApprovedFolders); + ConfirmationHandler.evictOldestIfNeeded(sessionApprovedFolders); sessionApprovedFolders.computeIfAbsent( convId, k -> ConcurrentHashMap.newKeySet()) .add(normalizePath(folder)); @@ -384,22 +448,6 @@ public void cacheDecision(ConfirmationAction action, } } - /** - * Evicts the oldest entry from a LinkedHashMap when it reaches the - * maximum number of tracked conversations. - */ - private static void evictOldestIfNeeded(Map map) { - synchronized (map) { - while (map.size() >= MAX_SESSION_CONVERSATIONS) { - var it = map.entrySet().iterator(); - if (it.hasNext()) { - it.next(); - it.remove(); - } - } - } - } - @Override public void clearSession(String conversationId) { sessionApprovedFiles.remove(conversationId); diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/McpConfirmationHandler.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/McpConfirmationHandler.java new file mode 100644 index 00000000..c2757223 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/McpConfirmationHandler.java @@ -0,0 +1,364 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.confirmation; + +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; +import org.apache.commons.lang3.StringUtils; +import org.eclipse.jface.preference.IPreferenceStore; +import org.eclipse.osgi.util.NLS; + +import com.microsoft.copilot.eclipse.core.Constants; +import com.microsoft.copilot.eclipse.core.CopilotCore; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationAction; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationActionScope; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationContent; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.InvokeClientToolConfirmationParams; +import com.microsoft.copilot.eclipse.core.lsp.protocol.ToolAnnotations; +import com.microsoft.copilot.eclipse.ui.chat.Messages; + +/** + * Evaluates MCP tool confirmation requests against session and global approval lists. + * Supports per-server and per-tool approval at both session and global scopes, + * plus optional trust-annotation-based auto-approve for read-only tools. + */ +public class McpConfirmationHandler implements ConfirmationHandler { + + /** Action types for MCP tool confirmation decisions. */ + public enum Action { + /** Allow a specific tool for the current session/conversation. */ + ACCEPT_TOOL_SESSION, + /** Always allow a specific tool (persisted globally). */ + ACCEPT_TOOL_GLOBAL, + /** Allow all tools from a server for the current session/conversation. */ + ACCEPT_SERVER_SESSION, + /** Always allow all tools from a server (persisted globally). */ + ACCEPT_SERVER_GLOBAL + } + + static final String META_SERVER_NAME = "serverName"; + static final String META_TOOL_KEY = "toolKey"; + + private static final String SEPARATOR = "::"; + private static final Type STRING_LIST_TYPE = + new TypeToken>() {}.getType(); + + private final IPreferenceStore preferenceStore; + + // Session-scoped in-memory storage keyed by conversationId. + private final Map> approvedServers = + Collections.synchronizedMap(new LinkedHashMap<>()); + private final Map> approvedTools = + Collections.synchronizedMap(new LinkedHashMap<>()); + + /** + * Creates a new McpConfirmationHandler. + * + * @param preferenceStore the preference store for reading MCP auto-approve settings + */ + public McpConfirmationHandler(IPreferenceStore preferenceStore) { + this.preferenceStore = preferenceStore; + } + + /** + * When the auto-approval feature is disabled, MCP tools always prompt with + * Allow Once / Skip only — no session or global approval buttons. + * This matches IntelliJ's behavior where MCP ignores all rules when disabled. + */ + @Override + public ConfirmationResult evaluate(InvokeClientToolConfirmationParams params, + String sessionConversationId, boolean isAutoApprovalEnabled) { + if (!isAutoApprovalEnabled) { + return evaluateAutoApprovalDisabled(params); + } + return evaluateAutoApprovalEnabled(params, sessionConversationId); + } + + /** + * Evaluates an MCP tool confirmation request. Check order: + * 1. Session approved servers (by conversationId) + * 2. Session approved tools (by conversationId, key = "server::tool") + * 3. Global approved servers list + * 4. Global approved tools list + * 5. Trust annotations (readOnlyHint=true AND openWorldHint=false) + * 6. Otherwise: needs confirmation + */ + private ConfirmationResult evaluateAutoApprovalEnabled( + InvokeClientToolConfirmationParams params, + String sessionConversationId) { + String serverName = extractServerName(params); + String toolName = extractToolName(params); + String serverLower = serverName != null + ? serverName.toLowerCase(Locale.ROOT) : null; + String toolKey = buildToolKey(serverLower, toolName); + + // 1. Session: server approved for this conversation + if (serverLower != null) { + Set sessionServers = + approvedServers.get(sessionConversationId); + if (sessionServers != null + && sessionServers.contains(serverLower)) { + return ConfirmationResult.AUTO_APPROVED; + } + } + + // 2. Session: specific tool approved for this conversation + if (toolKey != null) { + Set sessionTools = + approvedTools.get(sessionConversationId); + if (sessionTools != null && sessionTools.contains(toolKey)) { + return ConfirmationResult.AUTO_APPROVED; + } + } + + // 3. Global: server in approved servers list + if (serverLower != null) { + List globalServers = loadJsonList( + Constants.AUTO_APPROVE_MCP_SERVERS); + for (String s : globalServers) { + if (s.toLowerCase(Locale.ROOT).equals(serverLower)) { + return ConfirmationResult.AUTO_APPROVED; + } + } + } + + // 4. Global: tool in approved tools list + if (toolKey != null) { + List globalTools = loadJsonList( + Constants.AUTO_APPROVE_MCP_TOOLS); + for (String t : globalTools) { + if (t.toLowerCase(Locale.ROOT).equals(toolKey)) { + return ConfirmationResult.AUTO_APPROVED; + } + } + } + + // 5. Trust annotations: read-only and not open-world + if (preferenceStore.getBoolean( + Constants.AUTO_APPROVE_TRUST_TOOL_ANNOTATIONS)) { + ToolAnnotations annotations = params.getAnnotations(); + if (annotations != null + && annotations.isReadOnlyHint() + && !annotations.isOpenWorldHint()) { + return ConfirmationResult.AUTO_APPROVED; + } + } + + // 6. Needs confirmation + return ConfirmationResult.needsConfirmation( + buildContent(params, serverName, toolName)); + } + + private ConfirmationResult evaluateAutoApprovalDisabled( + InvokeClientToolConfirmationParams params) { + String serverName = extractServerName(params); + String toolName = extractToolName(params); + return ConfirmationResult.needsConfirmation( + buildContent(params, serverName, toolName, /* simplifiedOnly= */ true)); + } + + @Override + public void cacheDecision(ConfirmationAction confirmAction, + InvokeClientToolConfirmationParams params, + String sessionConversationId) { + String actionName = confirmAction.getMetadata() + .get(ConfirmationAction.META_ACTION); + if (actionName == null) { + return; + } + Action type; + try { + type = Action.valueOf(actionName); + } catch (IllegalArgumentException e) { + return; + } + + Map meta = confirmAction.getMetadata(); + String serverName = meta.get(META_SERVER_NAME); + String toolKey = meta.get(META_TOOL_KEY); + + switch (type) { + case ACCEPT_TOOL_SESSION: + if (toolKey != null) { + synchronized (approvedTools) { + ConfirmationHandler.evictOldestIfNeeded(approvedTools); + approvedTools.computeIfAbsent( + sessionConversationId, + k -> ConcurrentHashMap.newKeySet()) + .add(toolKey.toLowerCase(Locale.ROOT)); + } + } + break; + case ACCEPT_SERVER_SESSION: + if (serverName != null) { + synchronized (approvedServers) { + ConfirmationHandler.evictOldestIfNeeded(approvedServers); + approvedServers.computeIfAbsent( + sessionConversationId, + k -> ConcurrentHashMap.newKeySet()) + .add(serverName.toLowerCase(Locale.ROOT)); + } + } + break; + case ACCEPT_TOOL_GLOBAL: + if (toolKey != null) { + addToGlobalList(Constants.AUTO_APPROVE_MCP_TOOLS, toolKey); + } + break; + case ACCEPT_SERVER_GLOBAL: + if (serverName != null) { + addToGlobalList(Constants.AUTO_APPROVE_MCP_SERVERS, serverName); + } + break; + default: + break; + } + } + + @Override + public void clearSession(String conversationId) { + approvedServers.remove(conversationId); + approvedTools.remove(conversationId); + } + + private ConfirmationContent buildContent( + InvokeClientToolConfirmationParams params, + String serverName, String toolName) { + return buildContent(params, serverName, toolName, false); + } + + private ConfirmationContent buildContent( + InvokeClientToolConfirmationParams params, + String serverName, String toolName, boolean simplifiedOnly) { + String toolKey = buildToolKey( + serverName != null ? serverName.toLowerCase(Locale.ROOT) : null, + toolName); + + List actions = new ArrayList<>(); + actions.add(ConfirmationAction.allowOnce( + Messages.confirmation_action_allowOnce)); + + if (!simplifiedOnly) { + if (toolName != null && toolKey != null) { + actions.add(action(Action.ACCEPT_TOOL_SESSION, + NLS.bind(Messages.confirmation_action_allowNamesSession, + "'" + toolName + "'"), + ConfirmationActionScope.SESSION, + Map.of(META_TOOL_KEY, toolKey))); + actions.add(action(Action.ACCEPT_TOOL_GLOBAL, + NLS.bind(Messages.confirmation_action_alwaysAllowNames, + "'" + toolName + "'"), + ConfirmationActionScope.GLOBAL, + Map.of(META_TOOL_KEY, toolKey))); + } + + if (serverName != null) { + actions.add(action(Action.ACCEPT_SERVER_SESSION, + NLS.bind(Messages.confirmation_action_allowServerSession, + "'" + serverName + "'"), + ConfirmationActionScope.SESSION, + Map.of(META_SERVER_NAME, serverName))); + actions.add(action(Action.ACCEPT_SERVER_GLOBAL, + NLS.bind(Messages.confirmation_action_alwaysAllowServer, + "'" + serverName + "'"), + ConfirmationActionScope.GLOBAL, + Map.of(META_SERVER_NAME, serverName))); + } + } + + actions.add(ConfirmationAction.skip( + Messages.confirmation_action_skip)); + + String title; + if (toolName != null && serverName != null) { + title = NLS.bind(Messages.confirmation_title_mcpTool, + toolName, serverName); + } else { + title = params.getTitle() != null + ? params.getTitle() + : Messages.confirmation_title_mcpToolDefault; + } + + return new ConfirmationContent(title, params.getMessage(), actions); + } + + private static ConfirmationAction action(Action type, String label, + ConfirmationActionScope scope, Map extra) { + Map meta = new HashMap<>(extra); + meta.put(ConfirmationAction.META_ACTION, type.name()); + return new ConfirmationAction(label, true, scope, meta, false); + } + + private String extractServerName( + InvokeClientToolConfirmationParams params) { + Object input = params.getInput(); + if (input instanceof Map inputMap) { + Object value = inputMap.get("mcpServerName"); + if (value instanceof String s && StringUtils.isNotBlank(s)) { + return s; + } + } + return null; + } + + private String extractToolName( + InvokeClientToolConfirmationParams params) { + Object input = params.getInput(); + if (input instanceof Map inputMap) { + Object value = inputMap.get("mcpToolName"); + if (value instanceof String s && StringUtils.isNotBlank(s)) { + return s; + } + } + return null; + } + + private static String buildToolKey(String serverLower, String toolName) { + if (serverLower == null || toolName == null) { + return null; + } + return serverLower + SEPARATOR + + toolName.toLowerCase(Locale.ROOT); + } + + private List loadJsonList(String preferenceKey) { + String json = preferenceStore.getString(preferenceKey); + if (StringUtils.isBlank(json) || "[]".equals(json.trim())) { + return Collections.emptyList(); + } + try { + List list = new Gson().fromJson(json, STRING_LIST_TYPE); + return list != null ? list : Collections.emptyList(); + } catch (Exception e) { + CopilotCore.LOGGER.error( + "Failed to parse MCP auto-approve list: " + preferenceKey, e); + return Collections.emptyList(); + } + } + + private void addToGlobalList(String preferenceKey, String value) { + List current = new ArrayList<>(loadJsonList(preferenceKey)); + String lowerValue = value.toLowerCase(Locale.ROOT); + for (String existing : current) { + if (existing.toLowerCase(Locale.ROOT).equals(lowerValue)) { + return; // already present + } + } + current.add(value); + preferenceStore.setValue(preferenceKey, new Gson().toJson(current)); + } + +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/TerminalConfirmationHandler.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/TerminalConfirmationHandler.java index 6d80a09b..b4641499 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/TerminalConfirmationHandler.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/TerminalConfirmationHandler.java @@ -111,6 +111,20 @@ public TerminalConfirmationHandler(IPreferenceStore preferenceStore) { this.preferenceStore = preferenceStore; } + /** + * When the auto-approval feature is disabled, terminal commands always prompt + * with Allow Once / Skip only — no session or global approval buttons. + * This matches IntelliJ's behavior where terminal ignores all rules when disabled. + */ + @Override + public ConfirmationResult evaluate(InvokeClientToolConfirmationParams params, + String sessionConversationId, boolean isAutoApprovalEnabled) { + if (!isAutoApprovalEnabled) { + return evaluateAutoApprovalDisabled(params); + } + return evaluateAutoApprovalEnabled(params, sessionConversationId); + } + /** * Evaluates a terminal confirmation request. Check order follows IntelliJ: * 1. Session "allow all" flag @@ -120,8 +134,7 @@ public TerminalConfirmationHandler(IPreferenceStore preferenceStore) { * 5. Global per-subCommand regex/prefix match against rules * 6. Unmatched fallback (auto-approve if preference enabled) */ - @Override - public ConfirmationResult evaluate( + private ConfirmationResult evaluateAutoApprovalEnabled( InvokeClientToolConfirmationParams params, String sessionConversationId) { String convId = sessionConversationId; @@ -198,6 +211,17 @@ public ConfirmationResult evaluate( } } + private ConfirmationResult evaluateAutoApprovalDisabled( + InvokeClientToolConfirmationParams params) { + String title = params.getTitle() != null + ? params.getTitle() : Messages.confirmation_title_terminal; + return ConfirmationResult.needsConfirmation( + new ConfirmationContent(title, params.getMessage(), + List.of( + ConfirmationAction.allowOnce(Messages.confirmation_action_allowOnce), + ConfirmationAction.skip(Messages.confirmation_action_skip)))); + } + /** * Evaluates each sub-command against global rules and session state. * Returns a verdict and the list of unapproved command names. @@ -454,7 +478,7 @@ public void cacheDecision(ConfirmationAction confirmAction, switch (type) { case ACCEPT_NAMES_SESSION: if (cmdNames != null) { - evictOldestIfNeeded(allowedCommandNames); + ConfirmationHandler.evictOldestIfNeeded(allowedCommandNames); Set nameSet = allowedCommandNames.computeIfAbsent( convId, k -> ConcurrentHashMap.newKeySet()); Collections.addAll(nameSet, cmdNames); @@ -462,7 +486,7 @@ public void cacheDecision(ConfirmationAction confirmAction, break; case ACCEPT_EXACT_SESSION: if (commandLine != null && !commandLine.isBlank()) { - evictOldestIfNeeded(allowedExactCommands); + ConfirmationHandler.evictOldestIfNeeded(allowedExactCommands); allowedExactCommands.computeIfAbsent( convId, k -> ConcurrentHashMap.newKeySet()) .add(commandLine.trim()); @@ -490,22 +514,6 @@ public void cacheDecision(ConfirmationAction confirmAction, } } - /** - * Evicts the oldest entry from a LinkedHashMap when it reaches the - * maximum number of tracked conversations. - */ - private static void evictOldestIfNeeded(Map map) { - synchronized (map) { - while (map.size() >= MAX_SESSION_CONVERSATIONS) { - var it = map.entrySet().iterator(); - if (it.hasNext()) { - it.next(); - it.remove(); - } - } - } - } - @Override public void clearSession(String conversationId) { allowedCommandNames.remove(conversationId); diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/messages.properties b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/messages.properties index cf7d3153..f7ced706 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/messages.properties +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/messages.properties @@ -46,6 +46,12 @@ confirmation_action_alwaysAllow=Always Allow confirmation_action_allowFileSession=Allow this file in this Session confirmation_action_allowFolderSession=Allow folder ''{0}'' in this Session +# MCP confirmation dialog +confirmation_title_mcpTool=Run ''{0}'' tool from ''{1}'' MCP server +confirmation_title_mcpToolDefault=Allow MCP tool? +confirmation_action_allowServerSession=Allow tools from {0} in this Session +confirmation_action_alwaysAllowServer=Always Allow tools from {0} + # Confirmation dialog titles confirmation_title_terminal=Run command in terminal confirmation_title_fallback=Allow {0}? diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/McpConfigService.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/McpConfigService.java index 60ccc202..9fc753c3 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/McpConfigService.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/McpConfigService.java @@ -29,6 +29,7 @@ import com.microsoft.copilot.eclipse.ui.CopilotUi; import com.microsoft.copilot.eclipse.ui.chat.dialogs.DynamicOauthDialog; import com.microsoft.copilot.eclipse.ui.i18n.Messages; +import com.microsoft.copilot.eclipse.ui.preferences.McpAutoApproveSection; import com.microsoft.copilot.eclipse.ui.preferences.McpPreferencePage; /** @@ -50,6 +51,7 @@ public class McpConfigService extends ChatBaseService implements IMcpConfigServi private ISideEffect mcpToolButtonEnableSideEffect; private ISideEffect mcpToolsButtonRedNoticeSideEffect; private ISideEffect mcpPrefencePageExtMcpTitleRedNoticeSideEffect; + private ISideEffect mcpAutoApproveSideEffect; private IEventBroker eventBroker; @@ -108,6 +110,25 @@ private void initializeMcpFeatureFlagUpdateEvent() { eventBroker.subscribe(CopilotEventConstants.TOPIC_CHAT_DID_CHANGE_FEATURE_FLAGS, featureFlagNotifiedEventHandler); } + /** + * Bind the observable with UI in McpAutoApproveSection. + */ + public void bindWithAutoApproveSection(McpAutoApproveSection section) { + ensureRealm(() -> { + unbindWithAutoApproveSection(); + mcpAutoApproveSideEffect = ISideEffect.create( + mcpToolsObservableValue::getValue, + section::updateServerCollections); + }); + } + + private void unbindWithAutoApproveSection() { + if (mcpAutoApproveSideEffect != null) { + mcpAutoApproveSideEffect.dispose(); + mcpAutoApproveSideEffect = null; + } + } + /** * Bind the observable with UI in McpPreferencePage. */ diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/AutoApprovePreferencePage.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/AutoApprovePreferencePage.java index 1fccef6a..15fba7d8 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/AutoApprovePreferencePage.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/AutoApprovePreferencePage.java @@ -10,10 +10,16 @@ import org.eclipse.swt.layout.GridLayout; import org.eclipse.swt.widgets.Composite; import org.eclipse.swt.widgets.Control; +import org.eclipse.ui.ISharedImages; import org.eclipse.ui.IWorkbench; import org.eclipse.ui.IWorkbenchPreferencePage; +import org.eclipse.ui.PlatformUI; +import com.microsoft.copilot.eclipse.core.CopilotCore; +import com.microsoft.copilot.eclipse.core.FeatureFlags; import com.microsoft.copilot.eclipse.ui.CopilotUi; +import com.microsoft.copilot.eclipse.ui.chat.services.ChatServiceManager; +import com.microsoft.copilot.eclipse.ui.chat.services.McpConfigService; /** * Auto-Approve preference page for terminal and file operation auto-approval rules. @@ -26,6 +32,8 @@ public class AutoApprovePreferencePage extends PreferencePage private TerminalAutoApproveSection terminalSection; private FileOperationAutoApproveSection fileOperationSection; + private McpAutoApproveSection mcpSection; + private GlobalAutoApproveSection globalSection; @Override public void init(IWorkbench workbench) { @@ -35,26 +43,60 @@ public void init(IWorkbench workbench) { @Override protected Control createContents(Composite parent) { + FeatureFlags flags = CopilotCore.getPlugin().getFeatureFlags(); + if (flags != null && !flags.isAutoApprovalEnabled()) { + return WrappableIconLink.createWithSharedImage(parent, + PlatformUI.getWorkbench().getSharedImages() + .getImage(ISharedImages.IMG_OBJS_INFO_TSK), + Messages.preferences_page_auto_approve_disabled_by_organization); + } + Composite root = new Composite(parent, SWT.NONE); - root.setLayout(new GridLayout(1, false)); - root.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, true)); + GridLayout layout = new GridLayout(1, false); + layout.marginWidth = 0; + layout.marginHeight = 0; + root.setLayout(layout); + root.setLayoutData(new GridData(SWT.FILL, SWT.TOP, true, false)); IPreferenceStore store = getPreferenceStore(); - terminalSection = new TerminalAutoApproveSection(root, SWT.NONE); terminalSection.loadFromPreferences(store); fileOperationSection = new FileOperationAutoApproveSection(root, SWT.NONE); fileOperationSection.loadFromPreferences(store); + mcpSection = new McpAutoApproveSection(root, SWT.NONE); + mcpSection.loadFromPreferences(store); + bindMcpConfigService(); + + globalSection = new GlobalAutoApproveSection(root, SWT.NONE); + globalSection.loadFromPreferences(store); + return root; } @Override public boolean performOk() { + if (terminalSection == null) { + return true; + } IPreferenceStore store = getPreferenceStore(); terminalSection.saveToPreferences(store); fileOperationSection.saveToPreferences(store); + mcpSection.saveToPreferences(store); + globalSection.saveToPreferences(store); return true; } -} \ No newline at end of file + + private void bindMcpConfigService() { + ChatServiceManager chatServiceManager = + CopilotUi.getPlugin().getChatServiceManager(); + if (chatServiceManager != null) { + McpConfigService mcpConfigService = + chatServiceManager.getMcpConfigService(); + if (mcpConfigService != null) { + mcpConfigService.bindWithAutoApproveSection(mcpSection); + } + } + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CopilotPreferenceInitializer.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CopilotPreferenceInitializer.java index 4d59d41b..413a0f63 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CopilotPreferenceInitializer.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CopilotPreferenceInitializer.java @@ -67,6 +67,10 @@ public void initializeDefaultPreferences() { pref.setDefault(Constants.AUTO_APPROVE_FILE_OP_RULES, new Gson().toJson(FileOperationConfirmationHandler.FALLBACK_DEFAULT_RULES)); pref.setDefault(Constants.AUTO_APPROVE_UNMATCHED_FILE_OP, true); + pref.setDefault(Constants.AUTO_APPROVE_MCP_SERVERS, "[]"); + pref.setDefault(Constants.AUTO_APPROVE_MCP_TOOLS, "[]"); + pref.setDefault(Constants.AUTO_APPROVE_TRUST_TOOL_ANNOTATIONS, false); + pref.setDefault(Constants.AUTO_APPROVE_YOLO_MODE, false); IEclipsePreferences configPrefs = ConfigurationScope.INSTANCE .getNode(CopilotUi.getPlugin().getBundle().getSymbolicName()); diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/GlobalAutoApproveSection.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/GlobalAutoApproveSection.java new file mode 100644 index 00000000..3aaa8c7a --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/GlobalAutoApproveSection.java @@ -0,0 +1,122 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.preferences; + +import org.eclipse.jface.dialogs.MessageDialog; +import org.eclipse.jface.preference.IPreferenceStore; +import org.eclipse.swt.SWT; +import org.eclipse.swt.events.SelectionAdapter; +import org.eclipse.swt.events.SelectionEvent; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.layout.GridLayout; +import org.eclipse.swt.widgets.Button; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Group; +import org.eclipse.swt.widgets.Label; +import org.eclipse.ui.ISharedImages; +import org.eclipse.ui.PlatformUI; + +import com.microsoft.copilot.eclipse.core.Constants; + +/** + * Global auto-approve preference section with a YOLO mode checkbox + * that bypasses all confirmation dialogs when enabled. + */ +public class GlobalAutoApproveSection extends Composite { + + private static final int TOOLTIP_LINE_LENGTH = 90; + + private Button yoloCheckbox; + + /** Creates the global auto-approve section inside the given parent. */ + public GlobalAutoApproveSection(Composite parent, int style) { + super(parent, style); + setLayout(new GridLayout(1, false)); + setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); + createContents(); + } + + private void createContents() { + Group group = new Group(this, SWT.NONE); + group.setText(Messages.preferences_page_global_auto_approve_title); + group.setLayout(new GridLayout(1, false)); + group.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); + group.setBackgroundMode(SWT.INHERIT_FORCE); + + Composite yoloRow = new Composite(group, SWT.NONE); + GridLayout yoloRowLayout = new GridLayout(2, false); + yoloRowLayout.marginWidth = 0; + yoloRowLayout.marginHeight = 0; + yoloRow.setLayout(yoloRowLayout); + yoloRow.setLayoutData(new GridData(SWT.FILL, SWT.TOP, true, false)); + + yoloCheckbox = new Button(yoloRow, SWT.CHECK); + yoloCheckbox.setText( + Messages.preferences_page_global_auto_approve_label); + yoloCheckbox.setLayoutData( + new GridData(SWT.LEFT, SWT.CENTER, false, false)); + yoloCheckbox.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + if (yoloCheckbox.getSelection()) { + MessageDialog dialog = new MessageDialog( + getShell(), + Messages.preferences_page_global_auto_approve_confirm_title, + null, + Messages.preferences_page_global_auto_approve_confirm_message, + MessageDialog.WARNING, + new String[] { + Messages.preferences_page_global_auto_approve_confirm_button, + Messages.preferences_page_global_auto_approve_cancel_button + }, + 1); + int result = dialog.open(); + if (result != 0) { + yoloCheckbox.setSelection(false); + } + } + } + }); + + Label warningIcon = new Label(yoloRow, SWT.NONE); + warningIcon.setImage(PlatformUI.getWorkbench().getSharedImages() + .getImage(ISharedImages.IMG_OBJS_WARN_TSK)); + warningIcon.setToolTipText(wrapTooltip( + Messages.preferences_page_global_auto_approve_confirm_message)); + warningIcon.setLayoutData(new GridData(SWT.LEFT, SWT.CENTER, false, false)); + + new WrappableNoteLabel(group, + Messages.preferences_page_note_prefix + " ", + Messages.preferences_page_global_auto_approve_confirm_message); + } + + /** Loads global auto-approve settings from the preference store. */ + public void loadFromPreferences(IPreferenceStore store) { + yoloCheckbox.setSelection( + store.getBoolean(Constants.AUTO_APPROVE_YOLO_MODE)); + } + + /** Saves global auto-approve settings to the preference store. */ + public void saveToPreferences(IPreferenceStore store) { + store.setValue(Constants.AUTO_APPROVE_YOLO_MODE, + yoloCheckbox.getSelection()); + } + + private static String wrapTooltip(String text) { + StringBuilder wrapped = new StringBuilder(text.length()); + int lineLength = 0; + for (String word : text.split(" ")) { + if (lineLength > 0 && lineLength + word.length() + 1 > TOOLTIP_LINE_LENGTH) { + wrapped.append('\n'); + lineLength = 0; + } else if (lineLength > 0) { + wrapped.append(' '); + lineLength++; + } + wrapped.append(word); + lineLength += word.length(); + } + return wrapped.toString(); + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/McpAutoApproveSection.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/McpAutoApproveSection.java new file mode 100644 index 00000000..bc52d63e --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/McpAutoApproveSection.java @@ -0,0 +1,382 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.preferences; + +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; +import org.apache.commons.lang3.StringUtils; +import org.eclipse.jface.preference.IPreferenceStore; +import org.eclipse.jface.viewers.CheckStateChangedEvent; +import org.eclipse.jface.viewers.CheckboxTreeViewer; +import org.eclipse.jface.viewers.ICheckStateListener; +import org.eclipse.jface.viewers.ITreeContentProvider; +import org.eclipse.jface.viewers.LabelProvider; +import org.eclipse.swt.SWT; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.layout.GridLayout; +import org.eclipse.swt.widgets.Button; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Group; +import org.eclipse.swt.widgets.Label; + +import com.microsoft.copilot.eclipse.core.Constants; +import com.microsoft.copilot.eclipse.core.CopilotCore; +import com.microsoft.copilot.eclipse.core.lsp.mcp.McpServerToolsCollection; +import com.microsoft.copilot.eclipse.core.lsp.mcp.McpToolInformation; +import com.microsoft.copilot.eclipse.ui.utils.SwtUtils; + +/** + * MCP auto-approve preference section with a trust-annotations checkbox + * and a tree viewer for per-server/tool approval management. + */ +public class McpAutoApproveSection extends Composite { + + private static final int TREE_HEIGHT_HINT = 200; + private static final Type STRING_LIST_TYPE = + new TypeToken>() {}.getType(); + + private Button trustAnnotationsCheckbox; + private CheckboxTreeViewer treeViewer; + private Group group; + + private List serverCollections = + new ArrayList<>(); + + // Current check state (lowercased for case-insensitive matching) + private final Set checkedServers = new HashSet<>(); + private final Set checkedTools = new HashSet<>(); + + /** Creates the MCP auto-approve section inside the given parent. */ + public McpAutoApproveSection(Composite parent, int style) { + super(parent, style); + setLayout(new GridLayout(1, false)); + setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); + createContents(); + } + + private void createContents() { + group = new Group(this, SWT.NONE); + group.setText(Messages.preferences_page_mcp_auto_approve_title); + group.setLayout(new GridLayout(1, false)); + group.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); + group.setBackgroundMode(SWT.INHERIT_FORCE); + + // Trust annotations checkbox + trustAnnotationsCheckbox = new Button(group, SWT.CHECK); + trustAnnotationsCheckbox.setText( + Messages.preferences_page_mcp_auto_approve_trust_annotations); + trustAnnotationsCheckbox.setLayoutData( + new GridData(SWT.FILL, SWT.TOP, true, false)); + + new WrappableNoteLabel(group, + Messages.preferences_page_note_prefix + " ", + Messages.preferences_page_mcp_auto_approve_trust_annotations_note); + + // Server/tool approval label + Label serverToolsLabel = new Label(group, SWT.NONE); + serverToolsLabel.setText( + Messages.preferences_page_mcp_auto_approve_server_tools_label); + GridData labelData = new GridData(SWT.FILL, SWT.TOP, true, false); + serverToolsLabel.setLayoutData(labelData); + + // Tree viewer for server/tool approval + treeViewer = new CheckboxTreeViewer(group, + SWT.BORDER | SWT.FULL_SELECTION); + GridData treeData = new GridData(SWT.FILL, SWT.FILL, true, false); + treeData.heightHint = TREE_HEIGHT_HINT; + treeViewer.getTree().setLayoutData(treeData); + + treeViewer.setContentProvider(new McpTreeContentProvider()); + treeViewer.setLabelProvider(new McpTreeLabelProvider()); + treeViewer.addCheckStateListener(new McpCheckStateListener()); + treeViewer.setInput(serverCollections); + } + + /** Loads MCP auto-approve settings from the preference store. */ + public void loadFromPreferences(IPreferenceStore store) { + trustAnnotationsCheckbox.setSelection( + store.getBoolean(Constants.AUTO_APPROVE_TRUST_TOOL_ANNOTATIONS)); + + checkedServers.clear(); + checkedTools.clear(); + + List servers = loadJsonList(store, + Constants.AUTO_APPROVE_MCP_SERVERS); + for (String s : servers) { + checkedServers.add(s.toLowerCase(Locale.ROOT)); + } + + List tools = loadJsonList(store, + Constants.AUTO_APPROVE_MCP_TOOLS); + for (String t : tools) { + checkedTools.add(t.toLowerCase(Locale.ROOT)); + } + + refreshTreeCheckState(); + } + + /** Saves MCP auto-approve settings to the preference store. */ + public void saveToPreferences(IPreferenceStore store) { + store.setValue(Constants.AUTO_APPROVE_TRUST_TOOL_ANNOTATIONS, + trustAnnotationsCheckbox.getSelection()); + + store.setValue(Constants.AUTO_APPROVE_MCP_SERVERS, + new Gson().toJson(new ArrayList<>(checkedServers))); + store.setValue(Constants.AUTO_APPROVE_MCP_TOOLS, + new Gson().toJson(new ArrayList<>(checkedTools))); + } + + /** + * Updates the server/tool collections displayed in the tree viewer. + * Called from the MCP config service when server data changes. + */ + public void updateServerCollections( + List collections) { + if (isDisposed()) { + return; + } + SwtUtils.invokeOnDisplayThreadAsync(() -> { + if (isDisposed()) { + return; + } + this.serverCollections = collections != null + ? collections : Collections.emptyList(); + treeViewer.setInput(serverCollections); + refreshTreeCheckState(); + requestLayout(); + }, this); + } + + private void refreshTreeCheckState() { + if (treeViewer.getTree().isDisposed()) { + return; + } + for (McpServerToolsCollection server : serverCollections) { + // Expand to ensure child TreeItems exist before setChecked + treeViewer.expandToLevel(server, 1); + + String serverLower = server.getName() != null + ? server.getName().toLowerCase(Locale.ROOT) : ""; + boolean serverChecked = checkedServers.contains(serverLower); + + List tools = server.getTools(); + if (tools == null) { + tools = Collections.emptyList(); + } + + if (serverChecked) { + // Server is approved: check server and all its tools + treeViewer.setChecked(server, true); + treeViewer.setGrayed(server, false); + for (McpToolInformation tool : tools) { + treeViewer.setChecked(tool, true); + } + } else { + // Check individual tools + int checkedCount = 0; + for (McpToolInformation tool : tools) { + String toolKey = serverLower + "::" + + (tool.getName() != null + ? tool.getName().toLowerCase(Locale.ROOT) : ""); + boolean toolChecked = checkedTools.contains(toolKey); + treeViewer.setChecked(tool, toolChecked); + if (toolChecked) { + checkedCount++; + } + } + // Update parent check/gray state + if (checkedCount == 0) { + treeViewer.setChecked(server, false); + treeViewer.setGrayed(server, false); + } else if (checkedCount == tools.size()) { + treeViewer.setChecked(server, true); + treeViewer.setGrayed(server, false); + } else { + treeViewer.setChecked(server, true); + treeViewer.setGrayed(server, true); + } + } + } + } + + private static List loadJsonList(IPreferenceStore store, + String key) { + String json = store.getString(key); + if (StringUtils.isBlank(json) || "[]".equals(json.trim())) { + return Collections.emptyList(); + } + try { + List list = new Gson().fromJson(json, STRING_LIST_TYPE); + return list != null ? list : Collections.emptyList(); + } catch (Exception e) { + CopilotCore.LOGGER.error( + "Failed to parse MCP auto-approve list: " + key, e); + return Collections.emptyList(); + } + } + + /** Content provider for the server/tool tree. */ + private static class McpTreeContentProvider + implements ITreeContentProvider { + + @Override + public Object[] getElements(Object inputElement) { + if (inputElement instanceof List list) { + return list.toArray(); + } + return new Object[0]; + } + + @Override + public Object[] getChildren(Object parentElement) { + if (parentElement instanceof McpServerToolsCollection server) { + List tools = server.getTools(); + return tools != null ? tools.toArray() : new Object[0]; + } + return new Object[0]; + } + + @Override + public Object getParent(Object element) { + return null; + } + + @Override + public boolean hasChildren(Object element) { + if (element instanceof McpServerToolsCollection server) { + List tools = server.getTools(); + return tools != null && !tools.isEmpty(); + } + return false; + } + } + + /** Label provider for the server/tool tree. */ + private static class McpTreeLabelProvider extends LabelProvider { + @Override + public String getText(Object element) { + if (element instanceof McpServerToolsCollection server) { + return server.getName() != null ? server.getName() : ""; + } + if (element instanceof McpToolInformation tool) { + return tool.getName() != null ? tool.getName() : ""; + } + return ""; + } + } + + /** Handles check state changes in the server/tool tree. */ + private class McpCheckStateListener implements ICheckStateListener { + @Override + public void checkStateChanged(CheckStateChangedEvent event) { + Object element = event.getElement(); + boolean checked = event.getChecked(); + + if (element instanceof McpServerToolsCollection server) { + handleServerCheckChanged(server, checked); + } else if (element instanceof McpToolInformation tool) { + handleToolCheckChanged(tool, checked); + } + } + + private void handleServerCheckChanged( + McpServerToolsCollection server, boolean checked) { + String serverLower = server.getName() != null + ? server.getName().toLowerCase(Locale.ROOT) : ""; + treeViewer.setGrayed(server, false); + + if (checked) { + checkedServers.add(serverLower); + } else { + checkedServers.remove(serverLower); + } + + // Check/uncheck all children + List tools = server.getTools(); + if (tools != null) { + for (McpToolInformation tool : tools) { + treeViewer.setChecked(tool, checked); + String toolKey = serverLower + "::" + + (tool.getName() != null + ? tool.getName().toLowerCase(Locale.ROOT) : ""); + if (checked) { + checkedTools.add(toolKey); + } else { + checkedTools.remove(toolKey); + } + } + } + } + + private void handleToolCheckChanged( + McpToolInformation tool, boolean checked) { + // Find parent server + McpServerToolsCollection parentServer = findParentServer(tool); + if (parentServer == null) { + return; + } + String serverLower = parentServer.getName() != null + ? parentServer.getName().toLowerCase(Locale.ROOT) : ""; + String toolKey = serverLower + "::" + + (tool.getName() != null + ? tool.getName().toLowerCase(Locale.ROOT) : ""); + + if (checked) { + checkedTools.add(toolKey); + } else { + checkedTools.remove(toolKey); + } + + // Update parent check/gray state + updateParentState(parentServer); + } + + private void updateParentState(McpServerToolsCollection server) { + String serverLower = server.getName() != null + ? server.getName().toLowerCase(Locale.ROOT) : ""; + List tools = server.getTools(); + if (tools == null || tools.isEmpty()) { + return; + } + int checkedCount = 0; + for (McpToolInformation tool : tools) { + if (treeViewer.getChecked(tool)) { + checkedCount++; + } + } + if (checkedCount == 0) { + treeViewer.setChecked(server, false); + treeViewer.setGrayed(server, false); + checkedServers.remove(serverLower); + } else if (checkedCount == tools.size()) { + treeViewer.setChecked(server, true); + treeViewer.setGrayed(server, false); + checkedServers.add(serverLower); + } else { + treeViewer.setChecked(server, true); + treeViewer.setGrayed(server, true); + checkedServers.remove(serverLower); + } + } + + private McpServerToolsCollection findParentServer( + McpToolInformation tool) { + for (McpServerToolsCollection server : serverCollections) { + List tools = server.getTools(); + if (tools != null && tools.contains(tool)) { + return server; + } + } + return null; + } + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/Messages.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/Messages.java index 3c71c481..3f2f4ffe 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/Messages.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/Messages.java @@ -159,6 +159,7 @@ public class Messages extends NLS { public static String setting_disabled_by_organization; // Shared Auto-Approve strings + public static String preferences_page_auto_approve_disabled_by_organization; public static String preferences_page_auto_approve_column_status; public static String preferences_page_auto_approve_add; public static String preferences_page_auto_approve_remove; @@ -197,6 +198,20 @@ public class Messages extends NLS { public static String preferences_page_file_op_auto_approve_duplicate_title; public static String preferences_page_file_op_auto_approve_duplicate_message; + // MCP Auto-Approve + public static String preferences_page_mcp_auto_approve_title; + public static String preferences_page_mcp_auto_approve_trust_annotations; + public static String preferences_page_mcp_auto_approve_trust_annotations_note; + public static String preferences_page_mcp_auto_approve_server_tools_label; + + // Global Auto-Approve + public static String preferences_page_global_auto_approve_title; + public static String preferences_page_global_auto_approve_label; + public static String preferences_page_global_auto_approve_confirm_title; + public static String preferences_page_global_auto_approve_confirm_message; + public static String preferences_page_global_auto_approve_confirm_button; + public static String preferences_page_global_auto_approve_cancel_button; + static { // initialize resource bundle NLS.initializeMessages(BUNDLE_NAME, Messages.class); diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/messages.properties b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/messages.properties index dfc3122f..bc43bd6e 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/messages.properties +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/messages.properties @@ -150,6 +150,8 @@ setting_disabled_by_organization=This setting is disabled by your organization. setting_managed_by_organization=This setting is managed by your organization. +preferences_page_auto_approve_disabled_by_organization=Tool auto-approval rules are disabled by your organization's administrator. Please contact your organization's administrator for more information. + # Shared Auto Approve strings (reusable by all auto-approve sections) preferences_page_auto_approve_column_status=Auto Approve preferences_page_auto_approve_add=Add... @@ -188,3 +190,17 @@ preferences_page_file_op_auto_approve_add_dialog_pattern_hint=e.g., **/.idea/**/ preferences_page_file_op_auto_approve_add_dialog_description_hint=Optional description preferences_page_file_op_auto_approve_duplicate_title=Duplicate Pattern preferences_page_file_op_auto_approve_duplicate_message=A rule for this pattern already exists. + +# MCP Auto Approve +preferences_page_mcp_auto_approve_title=MCP Auto Approve +preferences_page_mcp_auto_approve_trust_annotations=Trust MCP tool annotations +preferences_page_mcp_auto_approve_trust_annotations_note=When enabled, Copilot uses MCP tool annotations to automatically approve read-only tool calls without confirmation. +preferences_page_mcp_auto_approve_server_tools_label=MCP Server and Tool Approval + +# Global Auto Approve +preferences_page_global_auto_approve_title=Global Auto Approve +preferences_page_global_auto_approve_label=Auto approve all tool calls +preferences_page_global_auto_approve_confirm_title=Enable Global Auto Approve? +preferences_page_global_auto_approve_confirm_message=Global auto-approve disables manual approval completely for all tools. When enabled, ALL tool calls (terminal commands, file edits, built-in tools, and MCP tools) will be automatically approved without any confirmation. This is extremely dangerous and is never recommended. +preferences_page_global_auto_approve_confirm_button=Confirm +preferences_page_global_auto_approve_cancel_button=Cancel