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 2ef3fbb0..63b7b7fa 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 @@ -54,6 +54,8 @@ private Constants() { // Auto-Approve settings public static final String AUTO_APPROVE_TERMINAL_RULES = "autoApproveTerminalRules"; 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"; // 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/chat/ConfirmationResult.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/ConfirmationResult.java index 89199cec..a496ab10 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/ConfirmationResult.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/ConfirmationResult.java @@ -14,26 +14,36 @@ public class ConfirmationResult { /** Auto-approved, no user confirmation needed. */ - public static final ConfirmationResult AUTO_APPROVED = new ConfirmationResult(true, null); + public static final ConfirmationResult AUTO_APPROVED = new ConfirmationResult(true, false, null); + + /** Dismissed — malformed or unhandleable request; CLS should be told to skip the tool. */ + public static final ConfirmationResult DISMISSED = new ConfirmationResult(false, true, null); private final boolean autoApproved; + private final boolean dismissed; private final ConfirmationContent content; - private ConfirmationResult(boolean autoApproved, ConfirmationContent content) { + private ConfirmationResult(boolean autoApproved, boolean dismissed, ConfirmationContent content) { this.autoApproved = autoApproved; + this.dismissed = dismissed; this.content = content; } /** Creates a result that requires user confirmation with the given content. */ public static ConfirmationResult needsConfirmation( ConfirmationContent content) { - return new ConfirmationResult(false, content); + return new ConfirmationResult(false, false, content); } public boolean isAutoApproved() { return autoApproved; } + /** Returns true if the request should be dismissed without showing UI. */ + public boolean isDismissed() { + return dismissed; + } + /** Returns the confirmation content, or null if auto-approved or using defaults. */ public ConfirmationContent getContent() { return content; @@ -41,7 +51,7 @@ public ConfirmationContent getContent() { @Override public int hashCode() { - return Objects.hash(autoApproved, content); + return Objects.hash(autoApproved, dismissed, content); } @Override @@ -57,6 +67,7 @@ public boolean equals(Object obj) { } ConfirmationResult other = (ConfirmationResult) obj; return autoApproved == other.autoApproved + && dismissed == other.dismissed && Objects.equals(content, other.content); } @@ -64,6 +75,7 @@ public boolean equals(Object obj) { public String toString() { return new ToStringBuilder(this) .append("autoApproved", autoApproved) + .append("dismissed", dismissed) .append("content", content) .toString(); } diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/FileOperationAutoApproveRule.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/FileOperationAutoApproveRule.java new file mode 100644 index 00000000..9f5c819d --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/FileOperationAutoApproveRule.java @@ -0,0 +1,109 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.chat; + +import java.util.Objects; + +import org.apache.commons.lang3.builder.ToStringBuilder; + +/** + * A single file-operation auto-approve rule mapping a glob pattern to an allow/deny decision. + */ +public class FileOperationAutoApproveRule { + private String pattern; + private String description; + private boolean autoApprove; + private transient boolean isDefault; + + /** + * Creates a new rule. + * + * @param pattern the glob pattern (e.g., "**\/.github/instructions/*") + * @param description human-readable description of what this pattern matches + * @param autoApprove true to auto-approve, false to always require confirmation + */ + public FileOperationAutoApproveRule(String pattern, String description, boolean autoApprove) { + this(pattern, description, autoApprove, false); + } + + /** + * Creates a new rule. + * + * @param pattern the glob pattern + * @param description human-readable description + * @param autoApprove true to auto-approve, false to always require confirmation + * @param isDefault true if this is a CLS default rule (non-removable) + */ + public FileOperationAutoApproveRule(String pattern, String description, + boolean autoApprove, boolean isDefault) { + this.pattern = pattern; + this.description = description; + this.autoApprove = autoApprove; + this.isDefault = isDefault; + } + + /** Default constructor for Gson deserialization. */ + public FileOperationAutoApproveRule() { + } + + public String getPattern() { + return pattern; + } + + public void setPattern(String pattern) { + this.pattern = pattern; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public boolean isAutoApprove() { + return autoApprove; + } + + public void setAutoApprove(boolean autoApprove) { + this.autoApprove = autoApprove; + } + + public boolean isDefault() { + return isDefault; + } + + public void setDefault(boolean isDefault) { + this.isDefault = isDefault; + } + + @Override + public int hashCode() { + return Objects.hash(pattern, description, autoApprove); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + FileOperationAutoApproveRule other = (FileOperationAutoApproveRule) obj; + return Objects.equals(pattern, other.pattern) + && Objects.equals(description, other.description) + && autoApprove == other.autoApprove; + } + + @Override + public String toString() { + return new ToStringBuilder(this) + .append("pattern", pattern) + .append("description", description) + .append("autoApprove", autoApprove) + .toString(); + } +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageServer.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageServer.java index aae71950..8f74d5dd 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageServer.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageServer.java @@ -36,6 +36,7 @@ import com.microsoft.copilot.eclipse.core.lsp.protocol.DidShowInlineEditParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.GenerateThinkingTitleParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.GenerateThinkingTitleResponse; +import com.microsoft.copilot.eclipse.core.lsp.protocol.GetDefaultFileSafetyRulesResult; import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolInformation; import com.microsoft.copilot.eclipse.core.lsp.protocol.NextEditSuggestionsParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.NextEditSuggestionsResult; @@ -285,6 +286,13 @@ public interface CopilotLanguageServer extends LanguageServer { @JsonRequest("githubApi/searchPR") CompletableFuture searchPr(SearchPrParams params); + /** + * Get the default file safety rules from CLS. + */ + @JsonRequest("getDefaultFileSafetyRules") + CompletableFuture getDefaultFileSafetyRules( + NullParams params); + /** * Notify that an inline edit was shown. */ diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageServerConnection.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageServerConnection.java index 12faad47..8edfe4b4 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageServerConnection.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageServerConnection.java @@ -55,6 +55,7 @@ import com.microsoft.copilot.eclipse.core.lsp.protocol.DidShowInlineEditParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.GenerateThinkingTitleParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.GenerateThinkingTitleResponse; +import com.microsoft.copilot.eclipse.core.lsp.protocol.GetDefaultFileSafetyRulesResult; import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolInformation; import com.microsoft.copilot.eclipse.core.lsp.protocol.NextEditSuggestionsParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.NextEditSuggestionsResult; @@ -153,6 +154,16 @@ public CompletableFuture checkQuota() { return this.languageServerWrapper.execute(fn); } + /** + * Get the default file safety rules from CLS. + */ + public CompletableFuture getDefaultFileSafetyRules() { + Function> fn = + server -> ((CopilotLanguageServer) server) + .getDefaultFileSafetyRules(new NullParams()); + return this.languageServerWrapper.execute(fn); + } + /** * Get single completion for the given parameters. */ diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CopilotAgentSettings.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CopilotAgentSettings.java index 6fafb3b8..e9412e2f 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CopilotAgentSettings.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CopilotAgentSettings.java @@ -23,6 +23,9 @@ public class CopilotAgentSettings { @SerializedName("autoApproveUnmatchedTerminal") private boolean autoApproveUnmatchedTerminal; + @SerializedName("autoApproveUnmatchedFileOp") + private boolean autoApproveUnmatchedFileOp; + // Tells CLS to always send confirmation requests to the editor @SerializedName("editorHandlesAllConfirmation") private boolean editorHandlesAllConfirmation = true; @@ -32,6 +35,7 @@ public class CopilotAgentSettings { /** Nested tools settings matching CLS agent.tools structure. */ public static class ToolsSettings { private TerminalSettings terminal; + private EditSettings edit; /** Gets terminal settings, creating if needed. */ public TerminalSettings getTerminal() { @@ -40,6 +44,39 @@ public TerminalSettings getTerminal() { } return terminal; } + + /** Gets edit settings, creating if needed. */ + public EditSettings getEdit() { + if (edit == null) { + edit = new EditSettings(); + } + return edit; + } + + @Override + public int hashCode() { + return Objects.hash(terminal, edit); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + ToolsSettings other = (ToolsSettings) obj; + return Objects.equals(terminal, other.terminal) && Objects.equals(edit, other.edit); + } + + @Override + public String toString() { + return new ToStringBuilder(this) + .append("terminal", terminal) + .append("edit", edit) + .toString(); + } } /** Terminal auto-approve rules: command/pattern -> allow(true)/deny(false). */ @@ -53,6 +90,65 @@ public Map getAutoApprove() { public void setAutoApprove(Map autoApprove) { this.autoApprove = autoApprove; } + + @Override + public int hashCode() { + return Objects.hash(autoApprove); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + return Objects.equals(autoApprove, ((TerminalSettings) obj).autoApprove); + } + + @Override + public String toString() { + return new ToStringBuilder(this) + .append("autoApprove", autoApprove) + .toString(); + } + } + + /** Edit (file operation) auto-approve rules: pattern → allow(true)/deny(false). */ + public static class EditSettings { + private Map autoApprove; + + public Map getAutoApprove() { + return autoApprove; + } + + public void setAutoApprove(Map autoApprove) { + this.autoApprove = autoApprove; + } + + @Override + public int hashCode() { + return Objects.hash(autoApprove); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + return Objects.equals(autoApprove, ((EditSettings) obj).autoApprove); + } + + @Override + public String toString() { + return new ToStringBuilder(this) + .append("autoApprove", autoApprove) + .toString(); + } } public int getAgentMaxRequests() { @@ -98,6 +194,14 @@ public void setAutoApproveUnmatchedTerminal(boolean autoApproveUnmatchedTerminal this.autoApproveUnmatchedTerminal = autoApproveUnmatchedTerminal; } + public boolean isAutoApproveUnmatchedFileOp() { + return autoApproveUnmatchedFileOp; + } + + public void setAutoApproveUnmatchedFileOp(boolean autoApproveUnmatchedFileOp) { + this.autoApproveUnmatchedFileOp = autoApproveUnmatchedFileOp; + } + /** Gets tools settings, creating if needed. */ public ToolsSettings getTools() { if (tools == null) { @@ -109,7 +213,7 @@ public ToolsSettings getTools() { @Override public int hashCode() { return Objects.hash(agentMaxRequests, enableSkills, transcriptDirectory, - editorHandlesAllConfirmation, autoApproveUnmatchedTerminal, tools); + editorHandlesAllConfirmation, autoApproveUnmatchedTerminal, autoApproveUnmatchedFileOp, tools); } @Override @@ -128,6 +232,7 @@ public boolean equals(Object obj) { && Objects.equals(transcriptDirectory, other.transcriptDirectory) && editorHandlesAllConfirmation == other.editorHandlesAllConfirmation && autoApproveUnmatchedTerminal == other.autoApproveUnmatchedTerminal + && autoApproveUnmatchedFileOp == other.autoApproveUnmatchedFileOp && Objects.equals(tools, other.tools); } @@ -139,6 +244,7 @@ public String toString() { builder.append("transcriptDirectory", transcriptDirectory); builder.append("editorHandlesAllConfirmation", editorHandlesAllConfirmation); builder.append("autoApproveUnmatchedTerminal", autoApproveUnmatchedTerminal); + builder.append("autoApproveUnmatchedFileOp", autoApproveUnmatchedFileOp); builder.append("tools", tools); return builder.toString(); } diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/FileSafetyRuleInfo.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/FileSafetyRuleInfo.java new file mode 100644 index 00000000..90348463 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/FileSafetyRuleInfo.java @@ -0,0 +1,92 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.lsp.protocol; + +import java.util.Objects; + +import org.apache.commons.lang3.builder.ToStringBuilder; + +/** + * A file safety rule as returned by CLS {@code getDefaultFileSafetyRules}. + * + *

Field names match the CLS JSON-RPC response exactly.

+ */ +public class FileSafetyRuleInfo { + + private String pattern; + private boolean requiresConfirmation; + private String description; + + /** Default constructor for Gson deserialization. */ + public FileSafetyRuleInfo() { + } + + /** + * Creates a new FileSafetyRuleInfo. + * + * @param pattern the glob pattern + * @param requiresConfirmation whether the file requires confirmation + * @param description description of the rule + */ + public FileSafetyRuleInfo(String pattern, boolean requiresConfirmation, + String description) { + this.pattern = pattern; + this.requiresConfirmation = requiresConfirmation; + this.description = description; + } + + public String getPattern() { + return pattern; + } + + + public void setPattern(String pattern) { + this.pattern = pattern; + } + + public boolean isRequiresConfirmation() { + return requiresConfirmation; + } + + public void setRequiresConfirmation(boolean requiresConfirmation) { + this.requiresConfirmation = requiresConfirmation; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + @Override + public int hashCode() { + return Objects.hash(description, pattern, requiresConfirmation); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + FileSafetyRuleInfo other = (FileSafetyRuleInfo) obj; + return Objects.equals(description, other.description) + && Objects.equals(pattern, other.pattern) + && requiresConfirmation == other.requiresConfirmation; + } + + @Override + public String toString() { + return new ToStringBuilder(this) + .append("pattern", pattern) + .append("requiresConfirmation", requiresConfirmation) + .append("description", description) + .toString(); + } + +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/GetDefaultFileSafetyRulesResult.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/GetDefaultFileSafetyRulesResult.java new file mode 100644 index 00000000..a88e2c6c --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/GetDefaultFileSafetyRulesResult.java @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.lsp.protocol; + +import java.util.List; +import java.util.Objects; + +import org.apache.commons.lang3.builder.ToStringBuilder; + +/** + * Result of the {@code getDefaultFileSafetyRules} CLS request. + */ +public class GetDefaultFileSafetyRulesResult { + + private List defaultRules; + + /** Default constructor for Gson deserialization. */ + public GetDefaultFileSafetyRulesResult() { + } + + /** + * Creates a new result with the given default rules. + * + * @param defaultRules the list of default file safety rules + */ + public GetDefaultFileSafetyRulesResult( + List defaultRules) { + this.defaultRules = defaultRules; + } + + public List getDefaultRules() { + return defaultRules; + } + + public void setDefaultRules(List defaultRules) { + this.defaultRules = defaultRules; + } + + @Override + public int hashCode() { + return Objects.hash(defaultRules); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + GetDefaultFileSafetyRulesResult other = (GetDefaultFileSafetyRulesResult) obj; + return Objects.equals(defaultRules, other.defaultRules); + } + + @Override + public String toString() { + return new ToStringBuilder(this) + .append("defaultRules", defaultRules) + .toString(); + } +} diff --git a/com.microsoft.copilot.eclipse.swtbot.test/test-plans/file-operation-auto-approve/file-operation-auto-approve.md b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/file-operation-auto-approve/file-operation-auto-approve.md new file mode 100644 index 00000000..2a76f5f9 --- /dev/null +++ b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/file-operation-auto-approve/file-operation-auto-approve.md @@ -0,0 +1,342 @@ +# File Operation Auto Approve + +## Overview +Tests the file-operation (read/write) auto-approve feature end-to-end: +configuring glob-pattern rules in the preference page, attaching context +files, then triggering Agent Mode tool calls and observing whether the +confirmation dialog appears or the operation runs automatically. + +Each test case exercises the full stack: preference store → CLS sync → +Agent Mode prompt → tool confirmation request → `ConfirmationService` → +`FileOperationConfirmationHandler` → dialog (or auto-approve) → file +operation execution. This mirrors the real user workflow: tweak settings, +attach files, chat with Copilot, observe behavior. + +Entry points exercised: +- **Preferences → GitHub Copilot → Tool Auto Approve** — the file + operation rule table (add / remove / toggle / reset). +- **Agent Mode chat** — sending prompts that trigger `copilot.read_file`, + `copilot.editFile`, `copilot.createFile`, or `copilot.deleteFile` tool + calls. +- **Confirmation dialog** — the button with session/global allow actions. +- **Attached files** — the context panel for user-attached files. + +--- + +## 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. +- A workspace with at least one Java project open (e.g., `demo`). +- No previous file-operation auto-approve rules beyond the defaults + (reset via "Reset to Defaults" before each scenario). +- "Auto approve file operations not covered by rules" is **unchecked** + unless the test specifies otherwise. + +--- + +## 1. Default behavior and dialog UI + +### TC-001: File read in workspace triggers confirmation dialog + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- Default rules are active (not modified). +- "Auto approve file operations not covered by rules" is **unchecked**. + +#### Steps +1. Open **Preferences → GitHub Copilot → Tool Auto Approve**. +2. Verify the "File Operation Auto Approve" section is visible with a + table showing default deny rules (e.g., `.github/instructions/*`, + `github-copilot/**/*`). +3. **Manually uncheck** "Auto approve file operations not covered by rules" + (system default is checked; uncheck it for this test). Close preferences. +4. Open the **Copilot Chat** view, select **Agent** mode. +5. Type: `read the file src/demo/App.java and summarize it`. +6. Wait for the agent to invoke the `copilot.read_file` tool. +7. Observe the confirmation dialog. Verify it shows: + - Title: **"Read file"**, message mentioning the file name. + - **"Allow Once"** button with dropdown, and a **"Skip"** button. +8. Click the dropdown arrow. Verify the menu contains: + - "Allow this file in this Session" + - "Always Allow" +9. Click **"Skip"**. +10. Verify the agent receives a dismiss result — no file content. + +#### Expected Result +- No matching allow rule → confirmation dialog appears. +- Dialog renders correctly with session/global actions. +- Skip prevents execution. + +#### 📸 Key Screenshots +- [ ] Preference page with default rules. +- [ ] Confirmation dialog with dropdown expanded. +- [ ] Agent turn after skip. + +--- + +## 2. Attached file auto-approval + +### TC-002: Attached file auto-approves; deny rule does not block attached files + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- **Manually uncheck** "Auto approve file operations not covered by rules" + (system default is checked; uncheck it for this test). + +#### Steps +1. Open preferences, add a deny rule `**/*.java` → **Deny**. + Apply and close. +2. Open `src/demo/App.java` in the editor. +3. Open the **Copilot Chat** view, select **Agent** mode. +4. Attach `App.java` via the context panel (paperclip / "Add Context"). +5. Type: `read App.java and explain what it does`. +6. Observe **no confirmation dialog** — the file is auto-approved + because it is attached, even though the deny rule matches. +7. Verify the agent reads and summarizes the file content. +8. In the **same conversation**, type: `now read Helper.java`. +9. Observe **confirmation dialog appears** — `Helper.java` is not + attached and the deny rule matches. + +#### Expected Result +- Attached file auto-approval takes precedence over deny rules. +- Non-attached files still respect rules normally. + +#### 📸 Key Screenshots +- [ ] Context panel showing attached `App.java`. +- [ ] Agent auto-approved read without dialog. +- [ ] Dialog appears for non-attached `Helper.java`. + +--- + +## 3. Session-level file approval + +### TC-003: Session approval — read, then write, then new conversation + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- **Manually uncheck** "Auto approve file operations not covered by rules" + (system default is checked; uncheck it so the initial read triggers a dialog). + +#### Steps +1. Ensure no custom rules exist for the test file. +2. In Agent Mode, type: `read src/demo/App.java`. +3. Confirmation dialog appears. +4. Click dropdown → **"Allow this file in this Session"**. +5. The file is read successfully. +6. In the **same conversation**, type: `read src/demo/App.java again`. +7. Observe **auto-approved** — session cache hit. +8. In the **same conversation**, type: `add a comment "// test" to + the top of src/demo/App.java`. +9. The agent invokes `copilot.editFile` for `App.java`. +10. Observe **auto-approved** — session approval is path-based, covers + both reads and writes. +11. Start a **new conversation** (click "New Chat"). +12. Type: `read src/demo/App.java`. +13. Observe **confirmation dialog appears** — session approvals do not + carry to new conversations. + +#### Expected Result +- Session approval: same file re-read auto-approves. +- Session approval: write to same file auto-approves. +- New conversation: resets session state. + +#### 📸 Key Screenshots +- [ ] First dialog: selecting "Allow this file in this Session". +- [ ] Write auto-approved in same conversation. +- [ ] New conversation: dialog reappears. + +--- + +## 4. Global rules — allow, deny, and "Always Allow" from dialog + +### TC-004: Glob allow and deny rules with unmatched toggle + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Steps +1. Open **Preferences → GitHub Copilot → Tool Auto Approve**. +2. Add rule: `**/*.java` → **Allow**. Click OK. +3. Add rule: `**/secret/**` → **Deny**. Click OK. +4. Enable **"Auto approve file operations not covered by rules"**. +5. Click **"Apply and Close"**. +6. In Agent Mode, type: `read src/demo/App.java`. +7. Observe **auto-approved** — matches `**/*.java` allow rule. +8. Type: `read src/secret/config.properties`. +9. Observe **confirmation dialog** — matches `**/secret/**` deny rule, + even though unmatched is enabled. +10. Click **"Skip"**. +11. Type: `read README.md`. +12. Observe **auto-approved** — no rule matches, unmatched fallback. +13. Open preferences, **uncheck** "Auto approve file operations not + covered by rules". Apply and close. +14. Type: `read README.md`. +15. Observe **confirmation dialog** — unmatched now disabled. + +#### Expected Result +- Allow glob rule auto-approves matching files. +- Deny glob rule blocks matching files even with unmatched enabled. +- Unmatched toggle controls fallback for non-matching files. + +#### 📸 Key Screenshots +- [ ] Preference page with both rules. +- [ ] `.java` auto-approved, `secret/**` denied, `README.md` varies. + +--- + +### TC-005: "Always Allow" persists as global rule and overrides deny + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- **Manually uncheck** "Auto approve file operations not covered by rules" + (system default is checked; uncheck it for this test). + +#### Steps +1. Open preferences, add deny rule for the file's absolute path + (e.g., `C:\\demo\src\demo\App.java`) → **Deny**. Apply and close. +2. In Agent Mode, trigger a file read for `App.java`. +3. Confirmation dialog appears (deny rule matches). +4. Click dropdown → **"Always Allow"**. +5. The file is read. +6. Open **Preferences → Tool Auto Approve**. +7. Verify the rule changed from **Deny → Allow** (no duplicate). +8. Close preferences. +9. Start a **new conversation**. +10. Type: `read src/demo/App.java`. +11. Observe **auto-approved** — the updated global rule persists. + +#### Expected Result +- "Always Allow" writes/updates the file path as a global allow rule. +- Overrides existing deny rule (case-insensitive match, no duplicates). +- Persists across conversations. + +#### 📸 Key Screenshots +- [ ] Preference page: deny → allow transition. +- [ ] New conversation: auto-approved. + +--- + +## 5. Outside-workspace files and folder-level approval + +### TC-006: Outside-workspace file requires confirmation with folder +approval + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Steps +1. Enable "Auto approve file operations not covered by rules". +2. Add allow rule `**/*`. Apply and close. +3. In Agent Mode, type: `read C:\temp\test\file1.txt` + (path outside workspace). +4. Observe **confirmation dialog** — outside-workspace files always + require confirmation regardless of rules. +5. Verify the dialog offers folder-level approval: + - "Allow Once" + - "Allow files in 'test' folder in this Session" + - "Skip" +6. Click dropdown → **"Allow files in 'test' folder in this Session"**. +7. The file is read. +8. In the same conversation, trigger read for `C:\temp\test\file2.txt`. +9. Observe **auto-approved** — same folder. +10. Trigger read for `C:\temp\other\file3.txt`. +11. Observe **confirmation dialog** — different folder. + +#### Expected Result +- Outside-workspace files bypass rules, always show dialog. +- Folder-level session approval covers sibling files. +- Different folders still require confirmation. + +#### 📸 Key Screenshots +- [ ] Dialog with folder-level action. +- [ ] Same-folder auto-approved. +- [ ] Different-folder dialog. + +--- + +## 6. Session approval overrides deny rule + +### TC-007: Session approval overrides deny rule within conversation + +**Type:** `Edge Case` +**Priority:** `P1` + +#### Steps +1. Add deny rule `**/App.java` in preferences. Apply and close. +2. In Agent Mode, trigger a read for `src/demo/App.java`. +3. Confirmation dialog appears (deny rule). +4. Click dropdown → **"Allow this file in this Session"**. +5. The file is read. +6. In the same conversation, trigger another read for `App.java`. +7. Observe **auto-approved** — session approval checked before rules. + +#### Expected Result +- Session-level approval overrides global deny rules. + +--- + +## 7. Subagent inherits parent session approvals + +### TC-008: Session file approval applies to subagent + +**Type:** `Edge Case` +**Priority:** `P1` + +#### Preconditions +- No custom rules for the test file. +- "Auto approve file operations not covered by rules" is **unchecked**. + +#### Steps +1. In Agent Mode, trigger a read for `App.java`. +2. Confirmation dialog appears. +3. Select **"Allow this file in this Session"**. +4. The file is read. +5. In the **same conversation**, send a prompt that spawns a subagent + (e.g., `use a subagent to analyze App.java`). +6. The subagent invokes `copilot.read_file` for `App.java`. +7. Observe **auto-approved** — session approval carries to subagent. + +#### Expected Result +- Subagent shares parent conversation's session scope. + +--- + +## 8. Reset to Defaults + +### TC-009: Reset clears custom rules and reverts behavior + +**Type:** `Happy Path` +**Priority:** `P2` + +#### Steps +1. Add custom rules: `**/*.java` → Allow, `**/secret/*` → Deny. + Apply and close. +2. Verify in Agent Mode: `.java` files auto-approve. +3. Open preferences, click **"Reset to Defaults"** and confirm. +4. **Manually uncheck** "Auto approve file operations not covered by rules" + (Reset to Defaults only clears rules, it does not reset this checkbox). + Apply and close. +5. Verify only default deny rules remain (`.github/instructions/*`, + `github-copilot/**/*`). +5. Apply and close. +6. In Agent Mode, trigger a `.java` file read. +7. Observe **confirmation dialog** — custom allow rule removed. + +#### Expected Result +- Reset removes all custom rules and restores defaults. + +#### 📸 Key Screenshots +- [ ] Preference page after reset — only defaults. +- [ ] `.java` file shows confirmation dialog post-reset. 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 new file mode 100644 index 00000000..beaa4d2a --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/FileOperationConfirmationHandlerTests.java @@ -0,0 +1,732 @@ +// 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.when; + +import java.util.HashMap; +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.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.chat.FileOperationAutoApproveRule; +import com.microsoft.copilot.eclipse.core.lsp.protocol.InvokeClientToolConfirmationParams; +import com.microsoft.copilot.eclipse.core.lsp.protocol.ToolMetadata; +import com.microsoft.copilot.eclipse.core.lsp.protocol.ToolMetadata.SensitiveFileData; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class FileOperationConfirmationHandlerTests { + + private static final String CONV_ID = "conv-1"; + private static final Gson GSON = new Gson(); + + @Mock + private IPreferenceStore preferenceStore; + + private AttachedFileRegistry attachedFileRegistry; + private FileOperationConfirmationHandler handler; + + @BeforeEach + void setUp() { + attachedFileRegistry = new AttachedFileRegistry(); + handler = new FileOperationConfirmationHandler( + preferenceStore, attachedFileRegistry); + } + + // --- glob matching behavior (tested via evaluate + rules) --- + + @Test + void evaluate_globExactPathMatchCaseInsensitive() { + // Rule uses forward slash + lowercase; evaluate uses backslash + uppercase + stubRules(List.of( + new FileOperationAutoApproveRule("C:/Users/test.java", "", true))); + stubUnmatched(false); + + assertTrue(handler.evaluate( + buildParams("C:\\Users\\test.java", false), CONV_ID).isAutoApproved()); + } + + @Test + void evaluate_globStarStarPatternMatches() { + stubRules(List.of( + new FileOperationAutoApproveRule("**/*.java", "", true))); + stubUnmatched(false); + + assertTrue(handler.evaluate( + buildParams("/workspace/src/Main.java", false), CONV_ID).isAutoApproved()); + } + + @Test + void evaluate_globPatternNoMatch() { + stubRules(List.of( + new FileOperationAutoApproveRule("**/*.py", "", true))); + stubUnmatched(false); + + assertFalse(handler.evaluate( + buildParams("/workspace/src/Main.java", false), CONV_ID).isAutoApproved()); + } + + @Test + void evaluate_globBackslashPathNormalized() { + // Rule uses forward slashes; file path uses backslashes + stubRules(List.of( + new FileOperationAutoApproveRule("**/.github/instructions/*", "", true))); + stubUnmatched(false); + + assertTrue(handler.evaluate( + buildParams("C:\\project\\.github\\instructions\\file.md", false), + CONV_ID).isAutoApproved()); + } + + @Test + void evaluate_invalidGlobRuleFallsThrough() { + // Invalid glob should not match; falls through to unmatched setting + stubRules(List.of( + new FileOperationAutoApproveRule("[invalid", "", true))); + stubUnmatched(true); + + assertTrue(handler.evaluate( + buildParams("/a/b.java", false), CONV_ID).isAutoApproved()); + } + + // --- evaluate: attached files --- + + @Test + void evaluate_autoApprovedWhenFileAttachedViaPending() { + attachedFileRegistry.addPending(List.of("/workspace/src/Main.java")); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + assertTrue(handler.evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void evaluate_autoApprovedWhenFileAttachedToConversation() { + attachedFileRegistry.addAttachedFiles(CONV_ID, + List.of("/workspace/src/Main.java")); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + assertTrue(handler.evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void evaluate_attachedFilePathNormalized() { + // Attached with backslashes + uppercase + attachedFileRegistry.addPending( + List.of("C:\\Workspace\\Src\\Main.java")); + + // Evaluate with forward slashes + lowercase + InvokeClientToolConfirmationParams params = + buildParams("c:/workspace/src/Main.java", false); + assertTrue(handler.evaluate(params, CONV_ID).isAutoApproved()); + } + + // --- evaluate: session overrides --- + + @Test + void evaluate_autoApprovedBySessionFileApproval() { + // Cache a file-level session approval + ConfirmationAction action = buildAction( + FileOperationConfirmationHandler.Action.ACCEPT_FILE_SESSION, + Map.of(FileOperationConfirmationHandler.META_FILE_PATH, + "/workspace/src/Main.java")); + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + handler.cacheDecision(action, params, CONV_ID); + + assertTrue(handler.evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void evaluate_sessionFileApprovalNormalizesPath() { + // Cache with backslash + uppercase + ConfirmationAction action = buildAction( + FileOperationConfirmationHandler.Action.ACCEPT_FILE_SESSION, + Map.of(FileOperationConfirmationHandler.META_FILE_PATH, + "C:\\Workspace\\Main.java")); + handler.cacheDecision(action, + buildParams("C:\\Workspace\\Main.java", false), CONV_ID); + + // Evaluate with forward slash + lowercase + InvokeClientToolConfirmationParams params = + buildParams("c:/workspace/Main.java", false); + assertTrue(handler.evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void evaluate_autoApprovedBySessionFolderApproval() { + ConfirmationAction action = buildAction( + FileOperationConfirmationHandler.Action.ACCEPT_FOLDER_SESSION, + Map.of(FileOperationConfirmationHandler.META_FOLDER_PATH, + "/home/user/external")); + handler.cacheDecision(action, + buildParams("/home/user/external/file.txt", true), CONV_ID); + + InvokeClientToolConfirmationParams params = + buildParams("/home/user/external/data.csv", false); + assertTrue(handler.evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void evaluate_sessionFolderDoesNotMatchParentPath() { + stubRules(List.of()); + stubUnmatched(false); + + ConfirmationAction action = buildAction( + FileOperationConfirmationHandler.Action.ACCEPT_FOLDER_SESSION, + Map.of(FileOperationConfirmationHandler.META_FOLDER_PATH, + "/home/user/external")); + handler.cacheDecision(action, + buildParams("/home/user/external/file.txt", true), CONV_ID); + + // 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()); + } + + @Test + void evaluate_sessionApprovalDoesNotAffectOtherConversation() { + stubRules(List.of()); + stubUnmatched(false); + + ConfirmationAction action = buildAction( + FileOperationConfirmationHandler.Action.ACCEPT_FILE_SESSION, + Map.of(FileOperationConfirmationHandler.META_FILE_PATH, + "/workspace/src/Main.java")); + handler.cacheDecision(action, + buildParams("/workspace/src/Main.java", false), CONV_ID); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + assertFalse(handler.evaluate(params, "other-conv").isAutoApproved()); + } + + // --- evaluate: outside workspace --- + + @Test + void evaluate_outsideWorkspaceAlwaysRequiresConfirmation() { + InvokeClientToolConfirmationParams params = + buildParams("/tmp/secret.txt", true); + assertFalse(handler.evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void evaluate_outsideWorkspaceStillAutoApprovedBySessionFolder() { + // Session folder approval overrides the outside-workspace check + ConfirmationAction action = buildAction( + FileOperationConfirmationHandler.Action.ACCEPT_FOLDER_SESSION, + Map.of(FileOperationConfirmationHandler.META_FOLDER_PATH, + "/tmp")); + handler.cacheDecision(action, + buildParams("/tmp/file.txt", true), CONV_ID); + + InvokeClientToolConfirmationParams params = + buildParams("/tmp/other.txt", true); + assertTrue(handler.evaluate(params, CONV_ID).isAutoApproved()); + } + + // --- evaluate: rule matching --- + + @Test + void evaluate_autoApprovedByAllowRule() { + stubRules(List.of( + new FileOperationAutoApproveRule("**/*.java", "", true))); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + assertTrue(handler.evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void evaluate_needsConfirmationByDenyRule() { + stubRules(List.of( + new FileOperationAutoApproveRule("**/.github/**/*", "", false))); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/.github/instructions/rules.md", false); + ConfirmationResult result = handler.evaluate(params, CONV_ID); + + assertFalse(result.isAutoApproved()); + assertNotNull(result.getContent()); + } + + @Test + void evaluate_firstMatchingRuleWins() { + stubRules(List.of( + new FileOperationAutoApproveRule("**/*.java", "", false), + new FileOperationAutoApproveRule("**/*", "", true))); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + // The first rule (deny .java) should win + assertFalse(handler.evaluate(params, CONV_ID).isAutoApproved()); + } + + // --- evaluate: unmatched fallback --- + + @Test + void evaluate_unmatchedAutoApprovedWhenCheckboxTrue() { + stubRules(List.of( + new FileOperationAutoApproveRule("**/*.py", "", true))); + stubUnmatched(true); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + assertTrue(handler.evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void evaluate_unmatchedNeedsConfirmationWhenCheckboxFalse() { + stubRules(List.of( + new FileOperationAutoApproveRule("**/*.py", "", true))); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + assertFalse(handler.evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void evaluate_emptyRulesUsesUnmatchedSetting() { + stubRules(List.of()); + stubUnmatched(true); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + assertTrue(handler.evaluate(params, CONV_ID).isAutoApproved()); + } + + // --- evaluate: blank file path --- + + @Test + void evaluate_blankFilePathNeedsConfirmation() { + stubRules(List.of()); + stubUnmatched(true); + + InvokeClientToolConfirmationParams params = + buildParams(null, false); + assertFalse(handler.evaluate(params, CONV_ID).isAutoApproved()); + } + + // --- evaluate: file path extraction --- + + @Test + void evaluate_extractsFilePathFromSensitiveFileData() { + stubRules(List.of( + new FileOperationAutoApproveRule("**/*.java", "", true))); + stubUnmatched(false); + + // Path set via sensitiveFileData (toolMetadata), not input map + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + assertTrue(handler.evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void evaluate_extractsFilePathFromInputMapFallback() { + stubRules(List.of( + new FileOperationAutoApproveRule("**/*.java", "", true))); + stubUnmatched(false); + + // No toolMetadata, only input map with "filePath" + InvokeClientToolConfirmationParams params = + new InvokeClientToolConfirmationParams(); + params.setConversationId(CONV_ID); + Map input = new HashMap<>(); + input.put("filePath", "/workspace/src/Main.java"); + input.put("toolType", "file_write"); + params.setInput(input); + + assertTrue(handler.evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void evaluate_extractsPathKeyFromInputMapFallback() { + stubRules(List.of( + new FileOperationAutoApproveRule("**/*.java", "", true))); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + new InvokeClientToolConfirmationParams(); + params.setConversationId(CONV_ID); + Map input = new HashMap<>(); + input.put("path", "/workspace/src/Main.java"); + input.put("toolType", "file_write"); + params.setInput(input); + + assertTrue(handler.evaluate(params, CONV_ID).isAutoApproved()); + } + + // --- cacheDecision: global rule --- + + @Test + void cacheDecision_globalAddsRuleToPreferenceStore() { + stubRules(List.of()); + + ConfirmationAction action = buildAction( + FileOperationConfirmationHandler.Action.ACCEPT_FILE_GLOBAL, + Map.of(FileOperationConfirmationHandler.META_FILE_PATH, + "/workspace/src/Main.java")); + + handler.cacheDecision(action, + buildParams("/workspace/src/Main.java", false), CONV_ID); + + // The handler should have called setValue on the preference store. + // We verify by loading rules from the same store (which requires + // the mock to return the updated value). Instead, verify the + // store was called with the right key. + org.mockito.Mockito.verify(preferenceStore).setValue( + org.mockito.ArgumentMatchers.eq(Constants.AUTO_APPROVE_FILE_OP_RULES), + org.mockito.ArgumentMatchers.anyString()); + } + + @Test + void cacheDecision_globalUpdatesExistingRuleCaseInsensitive() { + // Start with a deny rule + stubRules(List.of( + new FileOperationAutoApproveRule( + "C:/workspace/Main.java", "", false))); + + ConfirmationAction action = buildAction( + FileOperationConfirmationHandler.Action.ACCEPT_FILE_GLOBAL, + Map.of(FileOperationConfirmationHandler.META_FILE_PATH, + "c:/workspace/Main.java")); + + handler.cacheDecision(action, + buildParams("c:/workspace/Main.java", false), CONV_ID); + + // Verify setValue was called (updated existing rule to autoApprove) + org.mockito.Mockito.verify(preferenceStore).setValue( + org.mockito.ArgumentMatchers.eq(Constants.AUTO_APPROVE_FILE_OP_RULES), + org.mockito.ArgumentMatchers.anyString()); + } + + @Test + void cacheDecision_ignoresUnknownAction() { + Map meta = Map.of( + ConfirmationAction.META_ACTION, "UNKNOWN_ACTION"); + ConfirmationAction action = new ConfirmationAction( + "test", true, ConfirmationActionScope.SESSION, meta, false); + + // Should not throw + handler.cacheDecision(action, + buildParams("/workspace/src/Main.java", false), CONV_ID); + } + + @Test + void cacheDecision_ignoresNullActionMetadata() { + ConfirmationAction action = new ConfirmationAction( + "test", true, ConfirmationActionScope.SESSION, Map.of(), false); + + // Should not throw + handler.cacheDecision(action, + buildParams("/workspace/src/Main.java", false), CONV_ID); + } + + // --- clearSession --- + + @Test + void clearSession_removesFileAndFolderApprovals() { + stubRules(List.of()); + stubUnmatched(false); + + ConfirmationAction fileAction = buildAction( + FileOperationConfirmationHandler.Action.ACCEPT_FILE_SESSION, + Map.of(FileOperationConfirmationHandler.META_FILE_PATH, + "/workspace/src/Main.java")); + handler.cacheDecision(fileAction, + buildParams("/workspace/src/Main.java", false), CONV_ID); + + handler.clearSession(CONV_ID); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + assertFalse(handler.evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void clearSession_doesNotAffectOtherConversation() { + stubRules(List.of()); + stubUnmatched(false); + + ConfirmationAction action = buildAction( + FileOperationConfirmationHandler.Action.ACCEPT_FILE_SESSION, + Map.of(FileOperationConfirmationHandler.META_FILE_PATH, + "/workspace/src/Main.java")); + handler.cacheDecision(action, + buildParams("/workspace/src/Main.java", false), CONV_ID); + + handler.clearSession("other-conv"); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + assertTrue(handler.evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void clearSession_clearsAttachedFileRegistry() { + stubRules(List.of()); + stubUnmatched(false); + + attachedFileRegistry.addAttachedFiles(CONV_ID, + List.of("/workspace/src/Main.java")); + + handler.clearSession(CONV_ID); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + assertFalse(handler.evaluate(params, CONV_ID).isAutoApproved()); + } + + // --- buildContent: in-workspace actions --- + + @Test + void buildContent_inWorkspaceHasAllowOnceAsPrimary() { + stubRules(List.of()); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + ConfirmationResult result = handler.evaluate(params, CONV_ID); + + ConfirmationContent content = result.getContent(); + assertNotNull(content); + List actions = content.getActions(); + ConfirmationAction first = actions.get(0); + assertTrue(first.isPrimary()); + assertTrue(first.isAccept()); + assertEquals(ConfirmationActionScope.ONCE, first.getScope()); + } + + @Test + void buildContent_inWorkspaceHasSkipAsDismiss() { + stubRules(List.of()); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + ConfirmationResult result = handler.evaluate(params, CONV_ID); + + List actions = result.getContent().getActions(); + ConfirmationAction last = actions.get(actions.size() - 1); + assertFalse(last.isAccept()); + } + + @Test + void buildContent_inWorkspaceHasFileSessionAndGlobalActions() { + stubRules(List.of()); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + ConfirmationResult result = handler.evaluate(params, CONV_ID); + + List actions = result.getContent().getActions(); + boolean hasFileSession = actions.stream().anyMatch(a -> + hasActionType(a, + FileOperationConfirmationHandler.Action.ACCEPT_FILE_SESSION)); + boolean hasFileGlobal = actions.stream().anyMatch(a -> + hasActionType(a, + FileOperationConfirmationHandler.Action.ACCEPT_FILE_GLOBAL)); + assertTrue(hasFileSession); + assertTrue(hasFileGlobal); + } + + @Test + void buildContent_inWorkspaceNoFolderAction() { + stubRules(List.of()); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + ConfirmationResult result = handler.evaluate(params, CONV_ID); + + List actions = result.getContent().getActions(); + boolean hasFolderSession = actions.stream().anyMatch(a -> + hasActionType(a, + FileOperationConfirmationHandler.Action.ACCEPT_FOLDER_SESSION)); + assertFalse(hasFolderSession); + } + + // --- buildContent: outside-workspace actions --- + + @Test + void buildContent_outsideWorkspaceHasFolderSessionAction() { + stubRules(List.of()); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams("/tmp/data/file.txt", true); + ConfirmationResult result = handler.evaluate(params, CONV_ID); + + List actions = result.getContent().getActions(); + boolean hasFolderSession = actions.stream().anyMatch(a -> + hasActionType(a, + FileOperationConfirmationHandler.Action.ACCEPT_FOLDER_SESSION)); + assertTrue(hasFolderSession); + } + + @Test + void buildContent_outsideWorkspaceNoFileGlobalAction() { + stubRules(List.of()); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams("/tmp/data/file.txt", true); + ConfirmationResult result = handler.evaluate(params, CONV_ID); + + List actions = result.getContent().getActions(); + boolean hasFileGlobal = actions.stream().anyMatch(a -> + hasActionType(a, + FileOperationConfirmationHandler.Action.ACCEPT_FILE_GLOBAL)); + assertFalse(hasFileGlobal); + } + + // --- buildContent: action scopes --- + + @Test + void buildContent_actionScopesAreCorrect() { + stubRules(List.of()); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + ConfirmationResult result = handler.evaluate(params, CONV_ID); + + List actions = result.getContent().getActions(); + + // Session actions have SESSION scope + actions.stream() + .filter(a -> hasActionType(a, + FileOperationConfirmationHandler.Action.ACCEPT_FILE_SESSION)) + .forEach(a -> assertEquals( + ConfirmationActionScope.SESSION, a.getScope())); + + // Global actions have GLOBAL scope + actions.stream() + .filter(a -> hasActionType(a, + FileOperationConfirmationHandler.Action.ACCEPT_FILE_GLOBAL)) + .forEach(a -> assertEquals( + ConfirmationActionScope.GLOBAL, a.getScope())); + } + + // --- evaluate priority order --- + + @Test + void evaluate_priorityOrder_attachedFileBeatsGlobalDenyRule() { + // Attached file auto-approves even when a deny rule would otherwise apply + attachedFileRegistry.addPending( + List.of("/workspace/src/Main.java")); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + assertTrue(handler.evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void evaluate_priorityOrder_sessionApprovalBeatsGlobalDenyRule() { + // Session-level approval auto-approves even when a deny rule would otherwise apply + ConfirmationAction action = buildAction( + FileOperationConfirmationHandler.Action.ACCEPT_FILE_SESSION, + Map.of(FileOperationConfirmationHandler.META_FILE_PATH, + "/workspace/src/Main.java")); + handler.cacheDecision(action, + buildParams("/workspace/src/Main.java", false), CONV_ID); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + assertTrue(handler.evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void evaluate_priorityOrder_sessionFolderBeatsOutsideWorkspace() { + // Session folder approval auto-approves even for outside-workspace files + ConfirmationAction action = buildAction( + FileOperationConfirmationHandler.Action.ACCEPT_FOLDER_SESSION, + Map.of(FileOperationConfirmationHandler.META_FOLDER_PATH, + "/external/dir")); + handler.cacheDecision(action, + buildParams("/external/dir/file.txt", true), CONV_ID); + + InvokeClientToolConfirmationParams params = + buildParams("/external/dir/another.txt", true); + assertTrue(handler.evaluate(params, CONV_ID).isAutoApproved()); + } + + // --- Helpers --- + + private void stubRules(List rules) { + when(preferenceStore.getString(Constants.AUTO_APPROVE_FILE_OP_RULES)) + .thenReturn(GSON.toJson(rules)); + } + + private void stubUnmatched(boolean value) { + when(preferenceStore.getBoolean( + Constants.AUTO_APPROVE_UNMATCHED_FILE_OP)).thenReturn(value); + } + + private static InvokeClientToolConfirmationParams buildParams( + String filePath, boolean isGlobal) { + InvokeClientToolConfirmationParams params = + new InvokeClientToolConfirmationParams(); + params.setConversationId(CONV_ID); + + if (filePath != null) { + SensitiveFileData sfd = new SensitiveFileData(); + sfd.setFilePath(filePath); + sfd.setGlobal(isGlobal); + + ToolMetadata meta = new ToolMetadata(); + meta.setSensitiveFileData(sfd); + params.setToolMetadata(meta); + } + + Map input = new HashMap<>(); + input.put("toolType", "file_write"); + if (filePath != null) { + input.put("filePath", filePath); + } + params.setInput(input); + return params; + } + + private static ConfirmationAction buildAction( + FileOperationConfirmationHandler.Action actionType, + Map extra) { + Map meta = new HashMap<>(extra); + meta.put(ConfirmationAction.META_ACTION, actionType.name()); + return new ConfirmationAction( + "test", true, ConfirmationActionScope.SESSION, meta, false); + } + + private static boolean hasActionType(ConfirmationAction action, + FileOperationConfirmationHandler.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 65c9e551..ede64c98 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 @@ -19,6 +19,8 @@ import org.junit.jupiter.api.extension.ExtendWith; 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; @@ -31,6 +33,7 @@ import com.microsoft.copilot.eclipse.core.lsp.protocol.ToolMetadata.TerminalCommandData; @ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) class TerminalConfirmationHandlerTests { private static final String CONV_ID = "conv-1"; diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/preferences/LanguageServerSettingManagerTests.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/preferences/LanguageServerSettingManagerTests.java index 5843ccfe..2892f521 100644 --- a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/preferences/LanguageServerSettingManagerTests.java +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/preferences/LanguageServerSettingManagerTests.java @@ -14,6 +14,8 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import java.util.LinkedHashMap; + import org.eclipse.core.net.proxy.IProxyData; import org.eclipse.core.net.proxy.IProxyService; import org.eclipse.jface.preference.IPreferenceStore; @@ -23,6 +25,8 @@ 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.lsp.CopilotLanguageServerConnection; @@ -33,6 +37,7 @@ import com.microsoft.copilot.eclipse.ui.utils.PreferencesUtils; @ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) class LanguageServerSettingManagerTests { @Mock private IPreferenceStore mockPreferenceStore; @@ -53,6 +58,10 @@ void testNoProxy() { settings.getGithubSettings().getCopilotSettings().getAgent() .setEnableSkills(PreferencesUtils.isSkillsEnabled()) .setTranscriptDirectory(PlatformUtils.getTranscriptDirectory()); + settings.getGithubSettings().getCopilotSettings().getAgent() + .getTools().getTerminal().setAutoApprove(new LinkedHashMap<>()); + settings.getGithubSettings().getCopilotSettings().getAgent() + .getTools().getEdit().setAutoApprove(new LinkedHashMap<>()); params.setSettings(settings); // act @@ -83,6 +92,10 @@ void testBasicProxy() { settings.getGithubSettings().getCopilotSettings().getAgent() .setEnableSkills(PreferencesUtils.isSkillsEnabled()) .setTranscriptDirectory(PlatformUtils.getTranscriptDirectory()); + settings.getGithubSettings().getCopilotSettings().getAgent() + .getTools().getTerminal().setAutoApprove(new LinkedHashMap<>()); + settings.getGithubSettings().getCopilotSettings().getAgent() + .getTools().getEdit().setAutoApprove(new LinkedHashMap<>()); params.setSettings(settings); // act @@ -121,6 +134,10 @@ void testUpdateConfigShouldBeCalledWhenWorkspaceInstructionsEnabledWithContent() settings.getGithubSettings().setCopilotSettings(copilotSettings); settings.getGithubSettings().getCopilotSettings().getAgent() .setTranscriptDirectory(PlatformUtils.getTranscriptDirectory()); + settings.getGithubSettings().getCopilotSettings().getAgent() + .getTools().getTerminal().setAutoApprove(new LinkedHashMap<>()); + settings.getGithubSettings().getCopilotSettings().getAgent() + .getTools().getEdit().setAutoApprove(new LinkedHashMap<>()); params.setSettings(settings); // act @@ -152,6 +169,10 @@ void testUpdateConfigShouldBeCalledWithoutInstructionWhenWorkspaceInstructionsDi expectedSettings.getGithubSettings().getCopilotSettings().getAgent() .setEnableSkills(PreferencesUtils.isSkillsEnabled()) .setTranscriptDirectory(PlatformUtils.getTranscriptDirectory()); + expectedSettings.getGithubSettings().getCopilotSettings().getAgent() + .getTools().getTerminal().setAutoApprove(new LinkedHashMap<>()); + expectedSettings.getGithubSettings().getCopilotSettings().getAgent() + .getTools().getEdit().setAutoApprove(new LinkedHashMap<>()); expectedParams.setSettings(expectedSettings); // act diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java index 4fec954b..c22da6b0 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java @@ -3,6 +3,7 @@ package com.microsoft.copilot.eclipse.ui.chat; +import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Map; @@ -75,6 +76,7 @@ import com.microsoft.copilot.eclipse.core.persistence.UserTurnData; import com.microsoft.copilot.eclipse.ui.CopilotUi; import com.microsoft.copilot.eclipse.ui.UiConstants; +import com.microsoft.copilot.eclipse.ui.chat.services.AgentToolService; import com.microsoft.copilot.eclipse.ui.chat.services.ChatCompletionService; import com.microsoft.copilot.eclipse.ui.chat.services.ChatServiceManager; import com.microsoft.copilot.eclipse.ui.chat.services.DebugEventAutoResponseHandler; @@ -985,7 +987,16 @@ private void onSendInternal(String workDoneToken, String message, String agentSl final CopilotLanguageServerConnection ls = CopilotCore.getPlugin().getCopilotLanguageServer(); final CopilotModel activeModel = chatServiceManager.getModelService().getActiveModel(); + // Collect attached file paths for auto-approve of file operations. + // Stage as pending so confirmation requests can match immediately, + // even before the real conversation ID arrives. + List pendingAttachedFiles = + collectAttachedFilePaths(currentFile, references); + stagePendingAttachedFiles(pendingAttachedFiles); + if (conversationState == ConversationState.CONTINUED_CONVERSATION) { + // conversationId is already the real one — flush pending into registry + flushPendingAttachedFiles(this.conversationId); // Continue existing conversation - persist user message and send to existing conversation if (persistenceManager != null) { this.persistUserTurnFuture = persistenceManager.persistUserTurnInfo(conversationId, null, processedMessage, @@ -1084,6 +1095,9 @@ private void onSendInternal(String workDoneToken, String message, String agentSl CopilotCore.LOGGER.error("Error updating conversation ID in persistence manager: ", e); } + // Flush pending attached files into the real conversation ID + flushPendingAttachedFiles(newConversationId); + // Render model information in the Copilot turn widget if (result != null && StringUtils.isNotBlank(result.getModelName()) && !UiConstants.GITHUB_COPILOT_CODING_AGENT_SLUG.equals(result.getAgentSlug())) { @@ -1096,6 +1110,7 @@ private void onSendInternal(String workDoneToken, String message, String agentSl } } }).exceptionally(th -> { + clearPendingAttachedFiles(); if (!ConversationUtils.isConversationCancellationThrowable(th)) { CopilotCore.LOGGER.error("Error creating new conversation with exception: ", th); displayErrorAndResetSendButton(workDoneToken, th.getMessage()); @@ -1177,8 +1192,75 @@ private void handleCodingAgentMessage(CodingAgentMessageRequestParams params) { }, parent); } + /** + * Collects absolute paths of the current file and explicitly attached + * references. The returned list is saved to the + * {@link AttachedFileRegistry} once a stable conversation ID is available. + */ + private List collectAttachedFilePaths(IFile currentFile, + List references) { + List filePaths = new ArrayList<>(); + if (currentFile != null && currentFile.getLocation() != null) { + filePaths.add(currentFile.getLocation().toOSString()); + } + if (references != null) { + for (IResource r : references) { + if (r instanceof IFile && r.getLocation() != null) { + filePaths.add(r.getLocation().toOSString()); + } + } + } + return filePaths; + } + + /** + * Stages file paths as pending in the attached file registry. + * These are immediately visible to confirmation handlers. + */ + private void stagePendingAttachedFiles(List filePaths) { + if (filePaths.isEmpty() || this.chatServiceManager == null) { + return; + } + AgentToolService agentToolService = + this.chatServiceManager.getAgentToolService(); + if (agentToolService == null) { + return; + } + agentToolService.getAttachedFileRegistry().addPending(filePaths); + } + + /** + * Flushes pending attached files into per-conversation storage + * under the given (stable) conversation ID. + */ + private void flushPendingAttachedFiles(String conversationId) { + if (this.chatServiceManager == null + || StringUtils.isBlank(conversationId)) { + return; + } + AgentToolService agentToolService = + this.chatServiceManager.getAgentToolService(); + if (agentToolService == null) { + return; + } + agentToolService.getAttachedFileRegistry() + .flushPending(conversationId); + } + + private void clearPendingAttachedFiles() { + if (this.chatServiceManager == null) { + return; + } + AgentToolService agentToolService = + this.chatServiceManager.getAgentToolService(); + if (agentToolService != null) { + agentToolService.getAttachedFileRegistry().clearPending(); + } + } + private void clearCurrentConversation() { this.onCancel(); + clearPendingAttachedFiles(); this.hasHistory = false; this.conversationId = ""; this.conversationState = ConversationState.NEW_CONVERSATION; 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 94a4cbeb..f03a2186 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 @@ -49,10 +49,20 @@ public final class Messages extends NLS { public static String confirmation_action_allowExactSession; public static String confirmation_action_alwaysAllowExact; public static String confirmation_action_alwaysAllow; + public static String confirmation_action_allowFileSession; + public static String confirmation_action_allowFolderSession; // Confirmation dialog titles public static String confirmation_title_terminal; public static String confirmation_title_fallback; + public static String confirmation_title_fileRead; + public static String confirmation_title_fileWrite; + public static String confirmation_title_fileOperation; + + // Confirmation dialog messages + public static String confirmation_message_fileRead; + public static String confirmation_message_fileWrite; + public static String confirmation_message_fileOperation; // Misc public static String confirmation_autoApprovedDescription; diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/AttachedFileRegistry.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/AttachedFileRegistry.java new file mode 100644 index 00000000..bb548c77 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/AttachedFileRegistry.java @@ -0,0 +1,140 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.confirmation; + +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.apache.commons.lang3.StringUtils; + +/** + * Tracks files explicitly attached by the user in the context panel. + * + *

New files are first stored in a {@code pending} set (not yet bound + * to any conversation ID). The confirmation handler checks pending files + * first, so auto-approve works even before the real conversation ID + * arrives from CLS. Once the real ID is known, call + * {@link #flushPending(String)} to move them into per-conversation + * storage. + */ +public class AttachedFileRegistry { + + private final Map> attachedPaths = + Collections.synchronizedMap(new LinkedHashMap<>()); + + /** Files waiting for a stable conversation ID. */ + private final Set pendingFiles = + Collections.synchronizedSet(new HashSet<>()); + + /** + * Stages file paths for auto-approve before the conversation ID is + * known. These are checked by {@link #isAttachedFile} immediately. + */ + public void addPending(Collection filePaths) { + if (filePaths == null || filePaths.isEmpty()) { + return; + } + filePaths.stream() + .filter(StringUtils::isNotBlank) + .map(AttachedFileRegistry::toComparisonKey) + .forEach(pendingFiles::add); + } + + /** + * Moves pending files into per-conversation storage under the given + * conversation ID, then clears the pending set. + */ + public void flushPending(String conversationId) { + if (StringUtils.isBlank(conversationId) || pendingFiles.isEmpty()) { + return; + } + Set flushed; + synchronized (pendingFiles) { + flushed = new HashSet<>(pendingFiles); + pendingFiles.clear(); + } + evictOldestIfNeeded(); + synchronized (attachedPaths) { + attachedPaths.merge(conversationId, flushed, (a, b) -> { + Set merged = new HashSet<>(a); + merged.addAll(b); + return merged; + }); + } + } + + /** + * Records files for an existing conversation (continued turns). + */ + public void addAttachedFiles(String conversationId, + Collection filePaths) { + if (filePaths == null || filePaths.isEmpty() + || StringUtils.isBlank(conversationId)) { + return; + } + Set keys = filePaths.stream() + .filter(StringUtils::isNotBlank) + .map(AttachedFileRegistry::toComparisonKey) + .collect(Collectors.toSet()); + if (keys.isEmpty()) { + return; + } + evictOldestIfNeeded(); + synchronized (attachedPaths) { + attachedPaths.merge(conversationId, keys, (a, b) -> { + Set merged = new HashSet<>(a); + merged.addAll(b); + return merged; + }); + } + } + + /** + * Returns {@code true} when the given file was explicitly attached + * by the user — either in the pending set or for the given conversation. + */ + public boolean isAttachedFile(String conversationId, String filePath) { + if (StringUtils.isBlank(filePath)) { + return false; + } + String key = toComparisonKey(filePath); + // Check pending files first (before conversation ID is known) + if (pendingFiles.contains(key)) { + return true; + } + Set paths = attachedPaths.get(conversationId); + return paths != null && paths.contains(key); + } + + /** Removes all tracked data for a conversation. */ + public void clearConversation(String conversationId) { + attachedPaths.remove(conversationId); + } + + /** Discards any pending (pre-conversation) files. */ + public void clearPending() { + pendingFiles.clear(); + } + + private void evictOldestIfNeeded() { + synchronized (attachedPaths) { + while (attachedPaths.size() >= ConfirmationHandler.MAX_SESSION_CONVERSATIONS) { + var it = attachedPaths.entrySet().iterator(); + if (it.hasNext()) { + it.next(); + it.remove(); + } + } + } + } + + private static String toComparisonKey(String path) { + return ConfirmationHandler.normalizePath(path); + } +} 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 83c73f78..18547886 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 @@ -3,6 +3,9 @@ package com.microsoft.copilot.eclipse.ui.chat.confirmation; +import java.util.Locale; +import java.util.Map; + import com.microsoft.copilot.eclipse.core.chat.ConfirmationAction; import com.microsoft.copilot.eclipse.core.chat.ConfirmationResult; import com.microsoft.copilot.eclipse.core.lsp.protocol.InvokeClientToolConfirmationParams; @@ -13,6 +16,38 @@ */ public interface ConfirmationHandler { + /** Maximum number of conversations tracked in session memory. */ + int MAX_SESSION_CONVERSATIONS = 50; + + /** + * Normalizes a file path for case-insensitive, separator-agnostic comparison. + * Converts backslashes to forward slashes and lowercases the result. + * + * @param path the file path to normalize + * @return the normalized path + */ + static String normalizePath(String path) { + return path.replace('\\', '/').toLowerCase(Locale.ROOT); + } + + /** + * Extracts the {@code toolType} string from the input map of a confirmation request. + * Returns {@code null} if the field is absent or not a string. + * + * @param params the confirmation request parameters from CLS + * @return the toolType value, or {@code null} + */ + static String extractToolType(InvokeClientToolConfirmationParams params) { + Object input = params.getInput(); + if (input instanceof Map inputMap) { + Object toolType = inputMap.get("toolType"); + if (toolType instanceof String) { + return (String) toolType; + } + } + return null; + } + /** * Evaluates whether the given confirmation request should be auto-approved. * Implementations should check both global rules and session memory. 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 3ddf6a93..ed13ab8d 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 @@ -66,11 +66,19 @@ public static ToolCategory fromValue(String value) { * Creates a new ConfirmationService. * * @param preferenceStore the preference store for reading auto-approve settings + * @param attachedFileRegistry registry of user-attached context files */ - public ConfirmationService(IPreferenceStore preferenceStore) { + public ConfirmationService(IPreferenceStore preferenceStore, + AttachedFileRegistry attachedFileRegistry) { this.preferenceStore = preferenceStore; handlers.put(ToolCategory.TERMINAL, new TerminalConfirmationHandler(preferenceStore)); + FileOperationConfirmationHandler fileHandler = + new FileOperationConfirmationHandler(preferenceStore, + attachedFileRegistry); + handlers.put(ToolCategory.FILE_READ, fileHandler); + handlers.put(ToolCategory.FILE_WRITE, fileHandler); + handlers.put(ToolCategory.FILE_OPERATION, fileHandler); } /** @@ -123,18 +131,7 @@ public void clearSession(String conversationId) { } ToolCategory classify(InvokeClientToolConfirmationParams params) { - return ToolCategory.fromValue(extractToolType(params)); + return ToolCategory.fromValue(ConfirmationHandler.extractToolType(params)); } - private String extractToolType( - InvokeClientToolConfirmationParams params) { - Object input = params.getInput(); - if (input instanceof Map inputMap) { - Object toolType = inputMap.get("toolType"); - if (toolType instanceof String) { - return (String) toolType; - } - } - return null; - } } 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 new file mode 100644 index 00000000..87969a87 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/FileOperationConfirmationHandler.java @@ -0,0 +1,409 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.confirmation; + +import java.lang.reflect.Type; +import java.nio.file.FileSystems; +import java.nio.file.Path; +import java.nio.file.PathMatcher; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +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.chat.FileOperationAutoApproveRule; +import com.microsoft.copilot.eclipse.core.lsp.protocol.InvokeClientToolConfirmationParams; +import com.microsoft.copilot.eclipse.core.lsp.protocol.ToolMetadata; +import com.microsoft.copilot.eclipse.ui.chat.Messages; + +/** + * Evaluates file-operation confirmation requests against user-configured glob pattern rules. + * Files outside the workspace always require confirmation. + */ +public class FileOperationConfirmationHandler implements ConfirmationHandler { + + /** Action types for edit confirmation. */ + public enum Action { + /** Allow this specific file for the rest of the session. */ + ACCEPT_FILE_SESSION, + /** Always allow this specific file (persisted globally). */ + ACCEPT_FILE_GLOBAL, + /** Allow all files under this folder for the session. */ + ACCEPT_FOLDER_SESSION + } + + /** File tool type matching CLS values. */ + enum FileToolType { + FILE_READ("file_read"), + FILE_WRITE("file_write"), + FILE_OPERATION("file_operation"); + + private final String value; + + FileToolType(String value) { + this.value = value; + } + + String getDefaultTitle() { + switch (this) { + case FILE_READ: + return Messages.confirmation_title_fileRead; + case FILE_WRITE: + return Messages.confirmation_title_fileWrite; + default: + return Messages.confirmation_title_fileOperation; + } + } + + String getMessageTemplate() { + switch (this) { + case FILE_READ: + return Messages.confirmation_message_fileRead; + case FILE_WRITE: + return Messages.confirmation_message_fileWrite; + default: + return Messages.confirmation_message_fileOperation; + } + } + + static FileToolType fromValue(String value) { + if (value != null) { + for (FileToolType t : values()) { + if (t.value.equals(value)) { + return t; + } + } + } + return FILE_OPERATION; + } + } + + static final String META_FILE_PATH = "filePath"; + static final String META_FOLDER_PATH = "folderPath"; + + /** + * Local fallback defaults matching CLS + * {@code FileSafetyRulesService.defaultRules}. + * Used when CLS is not yet available. + */ + public static final List FALLBACK_DEFAULT_RULES = List.of( + new FileOperationAutoApproveRule("**/.github/instructions/*", + "GitHub instructions files", false, true), + new FileOperationAutoApproveRule("**/github-copilot/**/*", + "GitHub Copilot settings and token files", false, true)); + + private static final Type RULES_TYPE = + new TypeToken>() { + }.getType(); + + private final IPreferenceStore preferenceStore; + private final AttachedFileRegistry attachedFileRegistry; + private final Map> sessionApprovedFiles = + Collections.synchronizedMap(new LinkedHashMap<>()); + private final Map> sessionApprovedFolders = + Collections.synchronizedMap(new LinkedHashMap<>()); + + /** + * Creates a new FileOperationConfirmationHandler. + * + * @param preferenceStore the preference store for reading file-operation auto-approve rules + * @param attachedFileRegistry registry of user-attached files for auto-approval + */ + public FileOperationConfirmationHandler(IPreferenceStore preferenceStore, + AttachedFileRegistry attachedFileRegistry) { + this.preferenceStore = preferenceStore; + this.attachedFileRegistry = attachedFileRegistry; + } + + @Override + public ConfirmationResult evaluate(InvokeClientToolConfirmationParams params, + String sessionConversationId) { + String filePath = extractFilePath(params); + if (StringUtils.isBlank(filePath)) { + return ConfirmationResult.DISMISSED; + } + + // Auto-approve files explicitly attached by the user in the context panel + if (attachedFileRegistry.isAttachedFile( + sessionConversationId, filePath)) { + return ConfirmationResult.AUTO_APPROVED; + } + + // Session override — file level + String convId = sessionConversationId; + Set convFiles = sessionApprovedFiles.get(convId); + if (convFiles != null && convFiles.contains(normalizePath(filePath))) { + return ConfirmationResult.AUTO_APPROVED; + } + // Session override — folder level + Set convFolders = sessionApprovedFolders.get(convId); + if (convFolders != null && filePath != null) { + String normalizedPath = normalizePath(filePath); + for (String folder : convFolders) { + if (normalizedPath.startsWith(folder + "/")) { + return ConfirmationResult.AUTO_APPROVED; + } + } + } + + // Files outside workspace always require confirmation + if (isOutsideWorkspace(params)) { + return ConfirmationResult.needsConfirmation(buildContent(params)); + } + + // Check against rules + List rules = loadRules(); + for (FileOperationAutoApproveRule rule : rules) { + if (matchesGlob(filePath, rule.getPattern())) { + return rule.isAutoApprove() + ? ConfirmationResult.AUTO_APPROVED + : ConfirmationResult.needsConfirmation(buildContent(params)); + } + } + + return evaluateUnmatched(params); + } + + private ConfirmationResult evaluateUnmatched( + InvokeClientToolConfirmationParams params) { + if (preferenceStore.getBoolean(Constants.AUTO_APPROVE_UNMATCHED_FILE_OP)) { + return ConfirmationResult.AUTO_APPROVED; + } + return ConfirmationResult.needsConfirmation(buildContent(params)); + } + + private ConfirmationContent buildContent( + InvokeClientToolConfirmationParams params) { + String filePath = extractFilePath(params); + final FileToolType fileType = + FileToolType.fromValue(ConfirmationHandler.extractToolType(params)); + String safeFilePath = filePath != null ? filePath : ""; + boolean outsideWorkspace = isOutsideWorkspace(params); + + List actions = new ArrayList<>(); + actions.add(ConfirmationAction.allowOnce( + Messages.confirmation_action_allowOnce)); + + if (outsideWorkspace && filePath != null) { + // Outside workspace: offer folder-level approval + Path parent = Path.of(filePath).getParent(); + String folderName = (parent != null && parent.getFileName() != null) + ? parent.getFileName().toString() + : (parent != null ? parent.toString() : filePath); + String folderPath = parent != null ? parent.toString() : ""; + actions.add(action(Action.ACCEPT_FOLDER_SESSION, + NLS.bind(Messages.confirmation_action_allowFolderSession, + folderName), + ConfirmationActionScope.SESSION, + Map.of(META_FOLDER_PATH, folderPath))); + } else { + // In workspace: offer file-level approval + actions.add(action(Action.ACCEPT_FILE_SESSION, + Messages.confirmation_action_allowFileSession, + ConfirmationActionScope.SESSION, + Map.of(META_FILE_PATH, safeFilePath))); + actions.add(action(Action.ACCEPT_FILE_GLOBAL, + Messages.confirmation_action_alwaysAllow, + ConfirmationActionScope.GLOBAL, + Map.of(META_FILE_PATH, safeFilePath))); + } + actions.add(ConfirmationAction.skip( + Messages.confirmation_action_skip)); + + String title = params.getTitle() != null + ? params.getTitle() : fileType.getDefaultTitle(); + String fileName = ""; + try { + if (filePath != null) { + fileName = Path.of(filePath).getFileName().toString(); + } + } catch (Exception ignored) { + // use empty + } + String message = NLS.bind(fileType.getMessageTemplate(), fileName); + return new ConfirmationContent(title, message, actions); + } + + private static ConfirmationAction action(Action type, String label, + ConfirmationActionScope scope, Map extra) { + Map meta = new java.util.HashMap<>(extra); + meta.put(ConfirmationAction.META_ACTION, type.name()); + return new ConfirmationAction(label, true, scope, meta, false); + } + + private String extractFilePath(InvokeClientToolConfirmationParams params) { + // Try toolMetadata first + ToolMetadata metadata = params.getToolMetadata(); + if (metadata != null && metadata.getSensitiveFileData() != null) { + return metadata.getSensitiveFileData().getFilePath(); + } + // Fallback: extract from input map + Object input = params.getInput(); + if (input instanceof Map) { + Object path = ((Map) input).get("filePath"); + if (path == null) { + path = ((Map) input).get("path"); + } + return path instanceof String ? (String) path : null; + } + return null; + } + + // Uses CLS-provided isGlobal flag from sensitiveFileData metadata + private boolean isOutsideWorkspace(InvokeClientToolConfirmationParams params) { + ToolMetadata metadata = params.getToolMetadata(); + if (metadata != null && metadata.getSensitiveFileData() != null) { + return metadata.getSensitiveFileData().isGlobal(); + } + return false; + } + + /** Normalizes a file path for case-insensitive, separator-agnostic comparison. */ + private static String normalizePath(String path) { + return ConfirmationHandler.normalizePath(path); + } + + static boolean matchesGlob(String filePath, String globPattern) { + if (StringUtils.isBlank(filePath) || StringUtils.isBlank(globPattern)) { + return false; + } + try { + // Normalize both path and pattern to forward slashes for consistent matching + String normalizedPath = filePath.replace('\\', '/'); + String normalizedPattern = globPattern.replace('\\', '/'); + + // Fast exact-match for absolute file path rules (e.g., from "Always Allow") + if (normalizedPath.equalsIgnoreCase(normalizedPattern)) { + return true; + } + + PathMatcher matcher = FileSystems.getDefault() + .getPathMatcher("glob:" + normalizedPattern); + return matcher.matches(Path.of(normalizedPath)); + } catch (Exception e) { + CopilotCore.LOGGER.error( + "Invalid file-operation auto-approve glob: " + globPattern, e); + return false; + } + } + + List loadRules() { + String json = + preferenceStore.getString(Constants.AUTO_APPROVE_FILE_OP_RULES); + if (StringUtils.isBlank(json) || "[]".equals(json.trim())) { + return Collections.emptyList(); + } + try { + List rules = + new Gson().fromJson(json, RULES_TYPE); + return rules != null ? rules : Collections.emptyList(); + } catch (Exception e) { + CopilotCore.LOGGER.error( + "Failed to parse file-operation auto-approve rules", e); + return Collections.emptyList(); + } + } + + @Override + public void cacheDecision(ConfirmationAction action, + InvokeClientToolConfirmationParams params, + String sessionConversationId) { + String actionName = action.getMetadata() + .get(ConfirmationAction.META_ACTION); + if (actionName == null) { + return; + } + Action type; + try { + type = Action.valueOf(actionName); + } catch (IllegalArgumentException e) { + return; + } + + String convId = sessionConversationId; + Map meta = action.getMetadata(); + switch (type) { + case ACCEPT_FILE_SESSION: + String fp = meta.getOrDefault(META_FILE_PATH, ""); + if (!fp.isEmpty()) { + evictOldestIfNeeded(sessionApprovedFiles); + sessionApprovedFiles.computeIfAbsent( + convId, k -> ConcurrentHashMap.newKeySet()) + .add(normalizePath(fp)); + } + break; + case ACCEPT_FOLDER_SESSION: + String folder = meta.getOrDefault(META_FOLDER_PATH, ""); + if (!folder.isEmpty()) { + evictOldestIfNeeded(sessionApprovedFolders); + sessionApprovedFolders.computeIfAbsent( + convId, k -> ConcurrentHashMap.newKeySet()) + .add(normalizePath(folder)); + } + break; + case ACCEPT_FILE_GLOBAL: + String globalFp = meta.getOrDefault(META_FILE_PATH, ""); + if (!globalFp.isEmpty()) { + List rules = + new ArrayList<>(loadRules()); + // Update existing rule if path matches (case-insensitive for Windows) + boolean found = false; + for (FileOperationAutoApproveRule r : rules) { + if (r.getPattern().equalsIgnoreCase(globalFp)) { + r.setAutoApprove(true); + found = true; + break; + } + } + if (!found) { + rules.add(new FileOperationAutoApproveRule(globalFp, + Messages.confirmation_autoApprovedDescription, true)); + } + preferenceStore.setValue(Constants.AUTO_APPROVE_FILE_OP_RULES, + new Gson().toJson(rules)); + } + break; + default: + break; + } + } + + /** + * 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); + sessionApprovedFolders.remove(conversationId); + attachedFileRegistry.clearConversation(conversationId); + } +} 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 9a137d1e..6d80a09b 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 @@ -89,12 +89,6 @@ private static final class RuleResult { private static final Type RULES_TYPE = new TypeToken>() { }.getType(); - /** - * Maximum number of conversations whose session-scoped approvals are kept - * in memory. When exceeded, the oldest conversation's data is evicted. - */ - static final int MAX_SESSION_CONVERSATIONS = 50; - private final IPreferenceStore preferenceStore; // Session-scoped in-memory storage keyed by 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 ad5a555d..cf7d3153 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 @@ -43,10 +43,20 @@ confirmation_action_alwaysAllowNames=Always Allow {0} confirmation_action_allowExactSession=Allow this exact command in this Session confirmation_action_alwaysAllowExact=Always Allow this exact command confirmation_action_alwaysAllow=Always Allow +confirmation_action_allowFileSession=Allow this file in this Session +confirmation_action_allowFolderSession=Allow folder ''{0}'' in this Session # Confirmation dialog titles confirmation_title_terminal=Run command in terminal confirmation_title_fallback=Allow {0}? +confirmation_title_fileRead=Allow reading sensitive file? +confirmation_title_fileWrite=Allow edits to sensitive file? +confirmation_title_fileOperation=Allow operation on sensitive file? + +# Confirmation dialog messages +confirmation_message_fileRead=The model wants to read sensitive file ({0}). +confirmation_message_fileWrite=The model wants to edit sensitive file ({0}). +confirmation_message_fileOperation=The model wants to access sensitive file ({0}). # Misc confirmation_autoApprovedDescription=Auto-approved from dialog diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/AgentToolService.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/AgentToolService.java index 3eee71f0..be236d9c 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/AgentToolService.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/AgentToolService.java @@ -40,6 +40,7 @@ import com.microsoft.copilot.eclipse.ui.chat.ChatContentViewer; import com.microsoft.copilot.eclipse.ui.chat.ChatView; import com.microsoft.copilot.eclipse.ui.chat.InvokeToolConfirmationDialog; +import com.microsoft.copilot.eclipse.ui.chat.confirmation.AttachedFileRegistry; import com.microsoft.copilot.eclipse.ui.chat.confirmation.ConfirmationService; import com.microsoft.copilot.eclipse.ui.chat.tools.BaseTool; import com.microsoft.copilot.eclipse.ui.chat.tools.CreateFileTool; @@ -62,6 +63,7 @@ public class AgentToolService implements ToolInvocationListener, TerminalService private volatile boolean terminalToolsRegistered = false; private List cachedBuiltInTools; private final ConfirmationService confirmationService; + private final AttachedFileRegistry attachedFileRegistry; /** * Constructor for AgentToolService. @@ -69,8 +71,10 @@ public class AgentToolService implements ToolInvocationListener, TerminalService public AgentToolService(CopilotLanguageServerConnection lsConnection) { this.tools = new ConcurrentHashMap<>(); this.lsConnection = lsConnection; + this.attachedFileRegistry = new AttachedFileRegistry(); this.confirmationService = new ConfirmationService( - CopilotUi.getPlugin().getPreferenceStore()); + CopilotUi.getPlugin().getPreferenceStore(), + attachedFileRegistry); TerminalServiceManager terminalManager = TerminalServiceManager.getInstance(); if (terminalManager != null) { terminalManager.addListener(this); @@ -270,6 +274,10 @@ public CompletableFuture onToolConfirmation return CompletableFuture.completedFuture( new LanguageModelToolConfirmationResult(ToolConfirmationResult.ACCEPT)); } + if (autoApproveResult.isDismissed()) { + return CompletableFuture.completedFuture( + new LanguageModelToolConfirmationResult(ToolConfirmationResult.DISMISS)); + } BaseTurnWidget turnWidget = boundChatView.getChatContentViewer().getTurnWidget(params.getTurnId()); if (turnWidget == null) { @@ -337,6 +345,11 @@ public ConfirmationService getConfirmationService() { return confirmationService; } + /** Returns the registry of user-attached context files. */ + public AttachedFileRegistry getAttachedFileRegistry() { + return attachedFileRegistry; + } + /** * Dispose the service. */ diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/AddFileOperationRuleDialog.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/AddFileOperationRuleDialog.java new file mode 100644 index 00000000..cbaa82c1 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/AddFileOperationRuleDialog.java @@ -0,0 +1,124 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.preferences; + +import org.eclipse.jface.dialogs.Dialog; +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.Control; +import org.eclipse.swt.widgets.Label; +import org.eclipse.swt.widgets.Shell; +import org.eclipse.swt.widgets.Text; + +/** + * Dialog for adding a file-operation auto-approve rule with pattern, description, and allow/deny. + */ +public class AddFileOperationRuleDialog extends Dialog { + + private Text patternText; + private Text descriptionText; + private Button allowRadio; + + private String pattern; + private String description; + private boolean autoApprove; + + /** + * Creates the dialog. + * + * @param parent the parent shell + */ + public AddFileOperationRuleDialog(Shell parent) { + super(parent); + } + + @Override + protected void configureShell(Shell shell) { + super.configureShell(shell); + shell.setText( + Messages.preferences_page_file_op_auto_approve_add_dialog_title); + } + + @Override + protected Control createDialogArea(Composite parent) { + Composite area = (Composite) super.createDialogArea(parent); + Composite container = new Composite(area, SWT.NONE); + container.setLayout(new GridLayout(2, false)); + container.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, true)); + + // Pattern * + Label patternLabel = new Label(container, SWT.NONE); + patternLabel.setText( + Messages.preferences_page_file_op_auto_approve_add_dialog_pattern + + " *"); + patternText = new Text(container, SWT.BORDER); + GridData patternData = new GridData(SWT.FILL, SWT.CENTER, true, false); + patternData.widthHint = 300; + patternText.setLayoutData(patternData); + patternText.setMessage( + Messages.preferences_page_file_op_auto_approve_add_dialog_pattern_hint); + patternText.addModifyListener(e -> updateOkButton()); + + // Description + Label descLabel = new Label(container, SWT.NONE); + descLabel.setText( + Messages.preferences_page_file_op_auto_approve_add_dialog_description); + descriptionText = new Text(container, SWT.BORDER); + descriptionText.setMessage( + Messages.preferences_page_file_op_auto_approve_add_dialog_description_hint); + descriptionText.setLayoutData( + new GridData(SWT.FILL, SWT.CENTER, true, false)); + + // Allow / Deny + Label approveLabel = new Label(container, SWT.NONE); + approveLabel.setText( + Messages.preferences_page_auto_approve_add_dialog_approve); + Composite radioGroup = new Composite(container, SWT.NONE); + radioGroup.setLayout(new GridLayout(2, false)); + allowRadio = new Button(radioGroup, SWT.RADIO); + allowRadio.setText(Messages.preferences_page_auto_approve_allow); + allowRadio.setSelection(true); + Button denyRadio = new Button(radioGroup, SWT.RADIO); + denyRadio.setText(Messages.preferences_page_auto_approve_deny); + + return area; + } + + @Override + protected void createButtonsForButtonBar(Composite parent) { + super.createButtonsForButtonBar(parent); + updateOkButton(); + } + + private void updateOkButton() { + Button ok = getButton(OK); + if (ok != null) { + ok.setEnabled( + patternText != null && !patternText.getText().trim().isEmpty()); + } + } + + @Override + protected void okPressed() { + pattern = patternText.getText().trim(); + description = descriptionText.getText().trim(); + autoApprove = allowRadio.getSelection(); + super.okPressed(); + } + + public String getPattern() { + return pattern; + } + + public String getDescription() { + return description; + } + + public boolean isAutoApprove() { + return autoApprove; + } +} 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 1aa5c4d5..1fccef6a 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 @@ -16,7 +16,7 @@ import com.microsoft.copilot.eclipse.ui.CopilotUi; /** - * Auto-Approve preference page for terminal command auto-approval rules. + * Auto-Approve preference page for terminal and file operation auto-approval rules. */ public class AutoApprovePreferencePage extends PreferencePage implements IWorkbenchPreferencePage { @@ -25,6 +25,7 @@ public class AutoApprovePreferencePage extends PreferencePage "com.microsoft.copilot.eclipse.ui.preferences.AutoApprovePreferencePage"; private TerminalAutoApproveSection terminalSection; + private FileOperationAutoApproveSection fileOperationSection; @Override public void init(IWorkbench workbench) { @@ -38,11 +39,14 @@ protected Control createContents(Composite parent) { root.setLayout(new GridLayout(1, false)); root.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, true)); - terminalSection = new TerminalAutoApproveSection(root, SWT.NONE); - IPreferenceStore store = getPreferenceStore(); + + terminalSection = new TerminalAutoApproveSection(root, SWT.NONE); terminalSection.loadFromPreferences(store); + fileOperationSection = new FileOperationAutoApproveSection(root, SWT.NONE); + fileOperationSection.loadFromPreferences(store); + return root; } @@ -50,6 +54,7 @@ protected Control createContents(Composite parent) { public boolean performOk() { IPreferenceStore store = getPreferenceStore(); terminalSection.saveToPreferences(store); + fileOperationSection.saveToPreferences(store); return true; } } \ No newline at end of file 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 0bc66a4b..4d59d41b 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 @@ -11,6 +11,7 @@ import com.microsoft.copilot.eclipse.core.Constants; import com.microsoft.copilot.eclipse.ui.CopilotUi; +import com.microsoft.copilot.eclipse.ui.chat.confirmation.FileOperationConfirmationHandler; import com.microsoft.copilot.eclipse.ui.chat.confirmation.TerminalConfirmationHandler; /** @@ -63,6 +64,9 @@ public void initializeDefaultPreferences() { pref.setDefault(Constants.AUTO_APPROVE_TERMINAL_RULES, new Gson().toJson(TerminalConfirmationHandler.DEFAULT_RULES)); pref.setDefault(Constants.AUTO_APPROVE_UNMATCHED_TERMINAL, false); + pref.setDefault(Constants.AUTO_APPROVE_FILE_OP_RULES, + new Gson().toJson(FileOperationConfirmationHandler.FALLBACK_DEFAULT_RULES)); + pref.setDefault(Constants.AUTO_APPROVE_UNMATCHED_FILE_OP, true); IEclipsePreferences configPrefs = ConfigurationScope.INSTANCE .getNode(CopilotUi.getPlugin().getBundle().getSymbolicName()); diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/FileOperationAutoApproveSection.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/FileOperationAutoApproveSection.java new file mode 100644 index 00000000..93bc9d21 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/FileOperationAutoApproveSection.java @@ -0,0 +1,496 @@ +// 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.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; +import org.apache.commons.lang3.StringUtils; +import org.eclipse.jface.dialogs.MessageDialog; +import org.eclipse.jface.preference.IPreferenceStore; +import org.eclipse.jface.viewers.ArrayContentProvider; +import org.eclipse.jface.viewers.ColumnLabelProvider; +import org.eclipse.jface.viewers.IStructuredSelection; +import org.eclipse.jface.viewers.TableViewer; +import org.eclipse.jface.viewers.TableViewerColumn; +import org.eclipse.swt.SWT; +import org.eclipse.swt.graphics.Color; +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.Display; +import org.eclipse.swt.widgets.Group; +import org.eclipse.swt.widgets.Label; +import org.eclipse.swt.widgets.Table; + +import com.microsoft.copilot.eclipse.core.Constants; +import com.microsoft.copilot.eclipse.core.CopilotCore; +import com.microsoft.copilot.eclipse.core.chat.FileOperationAutoApproveRule; +import com.microsoft.copilot.eclipse.core.lsp.CopilotLanguageServerConnection; +import com.microsoft.copilot.eclipse.core.lsp.protocol.FileSafetyRuleInfo; +import com.microsoft.copilot.eclipse.ui.chat.confirmation.FileOperationConfirmationHandler; +import com.microsoft.copilot.eclipse.ui.utils.SwtUtils; + +/** + * File-operation auto-approve section with a rule table, action buttons, and + * unmatched-file-operation checkbox. + * + *

Default rules are fetched from CLS asynchronously and merged with + * local fallback defaults + * ({@link FileOperationConfirmationHandler#FALLBACK_DEFAULT_RULES}). + * Default rules cannot be removed; only user-added rules can be removed.

+ */ +public class FileOperationAutoApproveSection extends Composite { + + private static final int TABLE_HEIGHT_HINT = 200; + private static final Type FILE_OP_RULES_TYPE = + new TypeToken>() {}.getType(); + + private TableViewer tableViewer; + private final List defaultRules = + new ArrayList<>(); + private final List userRules = + new ArrayList<>(); + /** Combined view (defaults + user) shown in the table. */ + private final List allRules = + new ArrayList<>(); + private boolean defaultRulesLoaded; + private Button removeButton; + private Button toggleButton; + private Button resetButton; + private Button unmatchedCheckbox; + + /** Creates the file-operation auto-approve section inside the given parent. */ + public FileOperationAutoApproveSection(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_file_op_auto_approve_title); + group.setLayout(new GridLayout(1, false)); + group.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); + group.setBackgroundMode(SWT.INHERIT_FORCE); + + Label description = new Label(group, SWT.WRAP); + description.setText( + Messages.preferences_page_file_op_auto_approve_description); + GridData descData = new GridData(SWT.FILL, SWT.TOP, true, false); + descData.widthHint = 400; + description.setLayoutData(descData); + + Composite container = new Composite(group, SWT.NONE); + container.setLayout(new GridLayout(2, false)); + container.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); + + createTable(container); + createButtons(container); + + unmatchedCheckbox = new Button(group, SWT.CHECK); + unmatchedCheckbox.setText( + Messages.preferences_page_file_op_auto_approve_unmatched); + unmatchedCheckbox.setLayoutData( + new GridData(SWT.FILL, SWT.TOP, true, false)); + + new WrappableNoteLabel(group, + Messages.preferences_page_note_prefix + " ", + Messages.preferences_page_file_op_auto_approve_unmatched_note); + } + + private void createTable(Composite parent) { + tableViewer = new TableViewer(parent, + SWT.BORDER | SWT.FULL_SELECTION | SWT.SINGLE); + Table table = tableViewer.getTable(); + GridData tableData = new GridData(SWT.FILL, SWT.FILL, true, false); + tableData.heightHint = TABLE_HEIGHT_HINT; + table.setLayoutData(tableData); + table.setHeaderVisible(true); + table.setLinesVisible(true); + + TableViewerColumn patternCol = + new TableViewerColumn(tableViewer, SWT.NONE); + patternCol.getColumn().setText( + Messages.preferences_page_file_op_auto_approve_column_pattern); + patternCol.getColumn().setWidth(200); + patternCol.setLabelProvider(new ColumnLabelProvider() { + @Override + public String getText(Object element) { + return ((FileOperationAutoApproveRule) element).getPattern(); + } + + @Override + public Color getForeground(Object element) { + return ((FileOperationAutoApproveRule) element).isDefault() + ? Display.getDefault().getSystemColor(SWT.COLOR_DARK_GRAY) : null; + } + }); + + TableViewerColumn descCol = + new TableViewerColumn(tableViewer, SWT.NONE); + descCol.getColumn().setText( + Messages.preferences_page_file_op_auto_approve_column_description); + descCol.getColumn().setWidth(150); + descCol.setLabelProvider(new ColumnLabelProvider() { + @Override + public String getText(Object element) { + String desc = + ((FileOperationAutoApproveRule) element).getDescription(); + return desc != null ? desc : ""; + } + + @Override + public Color getForeground(Object element) { + return ((FileOperationAutoApproveRule) element).isDefault() + ? Display.getDefault().getSystemColor(SWT.COLOR_DARK_GRAY) : null; + } + }); + + TableViewerColumn statusCol = + new TableViewerColumn(tableViewer, SWT.NONE); + statusCol.getColumn().setText( + Messages.preferences_page_auto_approve_column_status); + statusCol.getColumn().setWidth(100); + statusCol.setLabelProvider(new ColumnLabelProvider() { + @Override + public String getText(Object element) { + return ((FileOperationAutoApproveRule) element).isAutoApprove() + ? Messages.preferences_page_auto_approve_allow + : Messages.preferences_page_auto_approve_deny; + } + + @Override + public Color getForeground(Object element) { + return ((FileOperationAutoApproveRule) element).isDefault() + ? Display.getDefault().getSystemColor(SWT.COLOR_DARK_GRAY) : null; + } + }); + + tableViewer.setContentProvider(ArrayContentProvider.getInstance()); + tableViewer.addSelectionChangedListener(e -> updateButtonState()); + } + + private void createButtons(Composite parent) { + Composite btnGroup = new Composite(parent, SWT.NONE); + btnGroup.setLayout(new GridLayout(1, false)); + btnGroup.setLayoutData( + new GridData(SWT.BEGINNING, SWT.BEGINNING, false, false)); + + Button addButton = new Button(btnGroup, SWT.PUSH); + addButton.setText(Messages.preferences_page_auto_approve_add); + addButton.setLayoutData(new GridData(SWT.FILL, SWT.TOP, true, false)); + addButton.addListener(SWT.Selection, e -> onAdd()); + + removeButton = new Button(btnGroup, SWT.PUSH); + removeButton.setText( + Messages.preferences_page_auto_approve_remove); + removeButton.setLayoutData( + new GridData(SWT.FILL, SWT.TOP, true, false)); + removeButton.setEnabled(false); + removeButton.addListener(SWT.Selection, e -> onRemove()); + + toggleButton = new Button(btnGroup, SWT.PUSH); + toggleButton.setText( + Messages.preferences_page_auto_approve_allow); + toggleButton.setLayoutData( + new GridData(SWT.FILL, SWT.TOP, true, false)); + toggleButton.setEnabled(false); + toggleButton.addListener(SWT.Selection, e -> onToggle()); + + resetButton = new Button(btnGroup, SWT.PUSH); + resetButton.setText( + Messages.preferences_page_auto_approve_reset); + resetButton.setLayoutData( + new GridData(SWT.FILL, SWT.TOP, true, false)); + resetButton.addListener(SWT.Selection, e -> onResetToDefaults()); + } + + private void onAdd() { + AddFileOperationRuleDialog dialog = + new AddFileOperationRuleDialog(getShell()); + if (dialog.open() == AddFileOperationRuleDialog.OK) { + String pattern = dialog.getPattern(); + if (isPatternExists(pattern)) { + MessageDialog.openWarning(getShell(), + Messages.preferences_page_file_op_auto_approve_duplicate_title, + Messages.preferences_page_file_op_auto_approve_duplicate_message); + return; + } + userRules.add(new FileOperationAutoApproveRule( + pattern, dialog.getDescription(), dialog.isAutoApprove())); + rebuildAllRules(); + tableViewer.refresh(); + updateButtonState(); + } + } + + private boolean isPatternExists(String pattern) { + return defaultRules.stream() + .anyMatch(r -> r.getPattern().equals(pattern)) + || userRules.stream() + .anyMatch(r -> r.getPattern().equals(pattern)); + } + + private void onRemove() { + IStructuredSelection sel = tableViewer.getStructuredSelection(); + if (!sel.isEmpty()) { + FileOperationAutoApproveRule rule = + (FileOperationAutoApproveRule) sel.getFirstElement(); + if (!rule.isDefault()) { + userRules.remove(rule); + rebuildAllRules(); + tableViewer.refresh(); + updateButtonState(); + } + } + } + + private void onToggle() { + IStructuredSelection sel = tableViewer.getStructuredSelection(); + if (!sel.isEmpty()) { + FileOperationAutoApproveRule rule = + (FileOperationAutoApproveRule) sel.getFirstElement(); + if (!rule.isDefault()) { + rule.setAutoApprove(!rule.isAutoApprove()); + tableViewer.refresh(); + updateButtonState(); + } + } + } + + private void onResetToDefaults() { + boolean confirmed = MessageDialog.openQuestion(getShell(), + Messages.preferences_page_file_op_auto_approve_reset_title, + Messages.preferences_page_auto_approve_reset_message); + if (confirmed) { + userRules.clear(); + resetDefaultRulesToOriginal(); + rebuildAllRules(); + tableViewer.refresh(); + updateButtonState(); + } + } + + /** + * Resets default rules' autoApprove to their original values. + * CLS defaults use {@code requiresConfirmation=true} → {@code autoApprove=false}. + * Local fallback defaults are already {@code autoApprove=false}. + */ + private void resetDefaultRulesToOriginal() { + for (FileOperationAutoApproveRule rule : defaultRules) { + rule.setAutoApprove(false); + } + } + + private void updateButtonState() { + boolean hasSelection = + !tableViewer.getStructuredSelection().isEmpty(); + FileOperationAutoApproveRule selected = hasSelection + ? (FileOperationAutoApproveRule) tableViewer + .getStructuredSelection().getFirstElement() + : null; + boolean isDefault = selected != null && selected.isDefault(); + + removeButton.setEnabled(hasSelection && !isDefault); + toggleButton.setEnabled(hasSelection && !isDefault); + if (hasSelection) { + toggleButton.setText(selected.isAutoApprove() + ? Messages.preferences_page_auto_approve_deny + : Messages.preferences_page_auto_approve_allow); + } else { + toggleButton.setText( + Messages.preferences_page_auto_approve_allow); + } + resetButton.setEnabled(!isMatchingDefaults()); + } + + /** + * Checks whether the current rule set matches defaults exactly + * (no user rules, all defaults at original autoApprove values). + */ + private boolean isMatchingDefaults() { + if (!userRules.isEmpty()) { + return false; + } + for (FileOperationAutoApproveRule rule : defaultRules) { + if (rule.isAutoApprove()) { + return false; + } + } + return true; + } + + /** Loads file-operation rules and unmatched-file-operation preference from the store. */ + public void loadFromPreferences(IPreferenceStore store) { + List savedRules = parseSavedRules(store); + + // Initialize with local fallback defaults + applyFallbackDefaults(); + + // Separate saved rules into defaults (override autoApprove) and user + Set defaultPatterns = defaultRules.stream() + .map(FileOperationAutoApproveRule::getPattern) + .collect(Collectors.toSet()); + userRules.clear(); + for (FileOperationAutoApproveRule saved : savedRules) { + if (defaultPatterns.contains(saved.getPattern())) { + // Restore toggled autoApprove for default rules + defaultRules.stream() + .filter(d -> d.getPattern().equals(saved.getPattern())) + .findFirst() + .ifPresent(d -> d.setAutoApprove(saved.isAutoApprove())); + } else { + userRules.add(saved); + } + } + + rebuildAllRules(); + tableViewer.setInput(allRules); + + unmatchedCheckbox.setSelection( + store.getBoolean(Constants.AUTO_APPROVE_UNMATCHED_FILE_OP)); + updateButtonState(); + + fetchDefaultRulesFromCls(); + } + + private List parseSavedRules( + IPreferenceStore store) { + String json = + store.getString(Constants.AUTO_APPROVE_FILE_OP_RULES); + if (StringUtils.isNotBlank(json) && !"[]".equals(json.trim())) { + try { + List loaded = + new Gson().fromJson(json, FILE_OP_RULES_TYPE); + if (loaded != null) { + return loaded; + } + } catch (Exception e) { + CopilotCore.LOGGER.error( + "Failed to parse file operation auto-approve rules", e); + } + } + return List.of(); + } + + private void applyFallbackDefaults() { + defaultRules.clear(); + for (FileOperationAutoApproveRule fallback + : FileOperationConfirmationHandler.FALLBACK_DEFAULT_RULES) { + defaultRules.add(new FileOperationAutoApproveRule( + fallback.getPattern(), fallback.getDescription(), + fallback.isAutoApprove(), true)); + } + } + + /** + * Fetches default file safety rules from CLS asynchronously. + * On success, merges CLS rules with local fallback and updates + * the table. On failure, keeps local fallback defaults. + */ + private void fetchDefaultRulesFromCls() { + CopilotLanguageServerConnection conn = + CopilotCore.getPlugin().getCopilotLanguageServer(); + if (conn == null) { + return; + } + conn.getDefaultFileSafetyRules().thenAccept(result -> { + if (result == null || result.getDefaultRules() == null) { + return; + } + SwtUtils.invokeOnDisplayThreadAsync(() -> { + if (isDisposed() || defaultRulesLoaded) { + return; + } + applyClsDefaults(result.getDefaultRules()); + }, FileOperationAutoApproveSection.this); + }).exceptionally(ex -> { + // CLS not available — keep local fallback defaults + CopilotCore.LOGGER.error( + "Failed to fetch default file safety rules from CLS", ex); + return null; + }); + } + + /** + * Applies CLS-provided default rules, merging with local fallback. + * CLS rules take priority; local fallbacks fill gaps. + */ + private void applyClsDefaults(List clsRules) { + // Preserve any user-toggled autoApprove on existing defaults + Map toggledDefaults = new HashMap<>(); + for (FileOperationAutoApproveRule existing : defaultRules) { + toggledDefaults.put(existing.getPattern(), existing.isAutoApprove()); + } + + defaultRules.clear(); + Set clsPatterns = new HashSet<>(); + for (FileSafetyRuleInfo clsRule : clsRules) { + String pattern = clsRule.getPattern(); + clsPatterns.add(pattern); + boolean autoApprove = !clsRule.isRequiresConfirmation(); + // Restore user toggle if they had changed this default + if (toggledDefaults.containsKey(pattern)) { + autoApprove = toggledDefaults.get(pattern); + } + String desc = clsRule.getDescription() != null + ? clsRule.getDescription() : ""; + defaultRules.add(new FileOperationAutoApproveRule( + pattern, desc, autoApprove, true)); + } + + // Add local fallbacks for patterns not in CLS + for (FileOperationAutoApproveRule fallback + : FileOperationConfirmationHandler.FALLBACK_DEFAULT_RULES) { + if (!clsPatterns.contains(fallback.getPattern())) { + boolean autoApprove = fallback.isAutoApprove(); + if (toggledDefaults.containsKey(fallback.getPattern())) { + autoApprove = toggledDefaults.get(fallback.getPattern()); + } + defaultRules.add(new FileOperationAutoApproveRule( + fallback.getPattern(), fallback.getDescription(), + autoApprove, true)); + } + } + + // Remove user rules that overlap with new defaults + Set allDefaultPatterns = defaultRules.stream() + .map(FileOperationAutoApproveRule::getPattern) + .collect(Collectors.toSet()); + userRules.removeIf(r -> allDefaultPatterns.contains(r.getPattern())); + + defaultRulesLoaded = true; + rebuildAllRules(); + tableViewer.refresh(); + updateButtonState(); + } + + /** Rebuilds the combined list shown in the table. */ + private void rebuildAllRules() { + allRules.clear(); + allRules.addAll(defaultRules); + allRules.addAll(userRules); + } + + /** Saves file-operation rules and unmatched-file-operation preference to the store. */ + public void saveToPreferences(IPreferenceStore store) { + // Save all rules (defaults + user) to preferences. + // On next load, defaults are re-identified by pattern matching. + store.setValue(Constants.AUTO_APPROVE_FILE_OP_RULES, + new Gson().toJson(allRules)); + store.setValue(Constants.AUTO_APPROVE_UNMATCHED_FILE_OP, + unmatchedCheckbox.getSelection()); + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/LanguageServerSettingManager.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/LanguageServerSettingManager.java index 5a633bf5..e3b78dad 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/LanguageServerSettingManager.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/LanguageServerSettingManager.java @@ -29,6 +29,7 @@ import com.microsoft.copilot.eclipse.core.CopilotCore; import com.microsoft.copilot.eclipse.core.FeatureFlags; import com.microsoft.copilot.eclipse.core.chat.CustomChatModeManager; +import com.microsoft.copilot.eclipse.core.chat.FileOperationAutoApproveRule; import com.microsoft.copilot.eclipse.core.chat.TerminalAutoApproveRule; import com.microsoft.copilot.eclipse.core.events.CopilotEventConstants; import com.microsoft.copilot.eclipse.core.lsp.CopilotLanguageServerConnection; @@ -98,7 +99,11 @@ public LanguageServerSettingManager(CopilotLanguageServerConnection conn, IProxy getSettings().getGithubSettings().getCopilotSettings().getAgent() .setAutoApproveUnmatchedTerminal( preferenceStore.getBoolean(Constants.AUTO_APPROVE_UNMATCHED_TERMINAL)); + getSettings().getGithubSettings().getCopilotSettings().getAgent() + .setAutoApproveUnmatchedFileOp( + preferenceStore.getBoolean(Constants.AUTO_APPROVE_UNMATCHED_FILE_OP)); syncTerminalRulesToCls(); + syncFileOperationRulesToCls(); // Set workspace context instructions when it is enabled if (preferenceStore.getBoolean(Constants.CUSTOM_INSTRUCTIONS_WORKSPACE_ENABLED)) { @@ -200,6 +205,16 @@ public void propertyChange(PropertyChangeEvent event) { syncTerminalRulesToCls(); singleSetting = new CopilotLanguageServerSettings(null, null, null, settings.getGithubSettings()); break; + case Constants.AUTO_APPROVE_UNMATCHED_FILE_OP: + settings.getGithubSettings().getCopilotSettings().getAgent() + .setAutoApproveUnmatchedFileOp( + preferenceStore.getBoolean(Constants.AUTO_APPROVE_UNMATCHED_FILE_OP)); + singleSetting = new CopilotLanguageServerSettings(null, null, null, settings.getGithubSettings()); + break; + case Constants.AUTO_APPROVE_FILE_OP_RULES: + syncFileOperationRulesToCls(); + singleSetting = new CopilotLanguageServerSettings(null, null, null, settings.getGithubSettings()); + break; default: return; } @@ -274,6 +289,30 @@ private void syncTerminalRulesToCls() { .getTools().getTerminal().setAutoApprove(rulesMap); } + /** + * Converts file-operation auto-approve rules from preference store JSON to the Map format + * expected by CLS and syncs them. + */ + private void syncFileOperationRulesToCls() { + String json = preferenceStore.getString(Constants.AUTO_APPROVE_FILE_OP_RULES); + Map rulesMap = new LinkedHashMap<>(); + if (StringUtils.isNotBlank(json)) { + try { + List rules = + new Gson().fromJson(json, + new TypeToken>() { + }.getType()); + if (rules != null) { + rules.forEach(r -> rulesMap.put(r.getPattern(), r.isAutoApprove())); + } + } catch (Exception e) { + CopilotCore.LOGGER.error("Failed to parse file-operation rules for CLS sync", e); + } + } + settings.getGithubSettings().getCopilotSettings().getAgent() + .getTools().getEdit().setAutoApprove(rulesMap); + } + /** * Initializes the MCP tools status from the preference store for built-in agent mode only. * Custom agent modes get their tool configuration from the LSP/file, not from preferences. 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 2296e15e..3c71c481 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 @@ -180,6 +180,23 @@ public class Messages extends NLS { public static String preferences_page_terminal_auto_approve_add_dialog_command; public static String preferences_page_terminal_auto_approve_add_dialog_placeholder; + // File Operations Auto Approve + public static String preferences_page_file_op_auto_approve_title; + public static String preferences_page_file_op_auto_approve_description; + public static String preferences_page_file_op_auto_approve_column_pattern; + public static String preferences_page_file_op_auto_approve_column_description; + public static String preferences_page_file_op_auto_approve_reset_title; + public static String preferences_page_file_op_auto_approve_unmatched; + public static String preferences_page_file_op_auto_approve_unmatched_note; + public static String preferences_page_file_op_auto_approve_add_dialog_title; + public static String preferences_page_file_op_auto_approve_add_dialog_message; + public static String preferences_page_file_op_auto_approve_add_dialog_pattern; + public static String preferences_page_file_op_auto_approve_add_dialog_description; + public static String preferences_page_file_op_auto_approve_add_dialog_pattern_hint; + public static String preferences_page_file_op_auto_approve_add_dialog_description_hint; + public static String preferences_page_file_op_auto_approve_duplicate_title; + public static String preferences_page_file_op_auto_approve_duplicate_message; + 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 31468a9d..dfc3122f 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 @@ -171,3 +171,20 @@ preferences_page_terminal_auto_approve_add_dialog_title=Add Terminal Command Rul preferences_page_terminal_auto_approve_add_dialog_message=Enter the command name or regex pattern (e.g., npm, git, /^apt-get\\b/) preferences_page_terminal_auto_approve_add_dialog_command=Command or Regex: preferences_page_terminal_auto_approve_add_dialog_placeholder=e.g., npm, git, /^apt-get\\b/ + +# File Operations Auto Approve +preferences_page_file_op_auto_approve_title=File Operations Auto Approve +preferences_page_file_op_auto_approve_description=Controls whether file operations generated by Copilot are approved automatically. Rules only apply to files within the workspace; operations on files outside the workspace always require confirmation. Set to Allow to auto approve operations on matching files; set to Deny to always require explicit approval. +preferences_page_file_op_auto_approve_column_pattern=Pattern +preferences_page_file_op_auto_approve_column_description=Description +preferences_page_file_op_auto_approve_reset_title=Reset File Operations Auto Approve Rules +preferences_page_file_op_auto_approve_unmatched=Auto approve file operations not covered by rules +preferences_page_file_op_auto_approve_unmatched_note=When enabled, file operations not covered by the rules above are automatically approved. File operations outside the workspace always require confirmation. +preferences_page_file_op_auto_approve_add_dialog_title=Add File Operation Auto Approve Rule +preferences_page_file_op_auto_approve_add_dialog_message=Enter the glob pattern (e.g., **/.idea/**/* or **/*.config) +preferences_page_file_op_auto_approve_add_dialog_pattern=Pattern: +preferences_page_file_op_auto_approve_add_dialog_description=Description: +preferences_page_file_op_auto_approve_add_dialog_pattern_hint=e.g., **/.idea/**/* or **/*.config +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.