Skip to content

Commit 5b3a5b8

Browse files
committed
Pre-release 0.46.158
1 parent a45336d commit 5b3a5b8

34 files changed

+1100
-318
lines changed

Core/Package.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,7 @@ let package = Package(
214214
.target(
215215
name: "SuggestionWidget",
216216
dependencies: [
217+
"ChatService",
217218
"PromptToCodeService",
218219
"ConversationTab",
219220
"GitHubCopilotViewModel",

Core/Sources/ChatService/ChatService.swift

Lines changed: 16 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -149,30 +149,7 @@ public final class ChatService: ChatServiceType, ObservableObject {
149149

150150
private func subscribeToClientToolConfirmationEvent() {
151151
ClientToolHandlerImpl.shared.onClientToolConfirmationEvent.sink(receiveValue: { [weak self] (request, completion) in
152-
guard let params = request.params else { return }
153-
154-
// Check if this conversationId is valid (main conversation or subagent conversation)
155-
guard let validIds = self?.conversationTurnTracking.validConversationIds, validIds.contains(params.conversationId) else {
156-
return
157-
}
158-
159-
let parentTurnId = self?.conversationTurnTracking.turnParentMap[params.turnId]
160-
161-
let editAgentRounds: [AgentRound] = [
162-
AgentRound(roundId: params.roundId,
163-
reply: "",
164-
toolCalls: [
165-
AgentToolCall(id: params.toolCallId, name: params.name, status: .waitForConfirmation, invokeParams: params, title: params.title)
166-
]
167-
)
168-
]
169-
self?.appendToolCallHistory(turnId: params.turnId, editAgentRounds: editAgentRounds, parentTurnId: parentTurnId)
170-
self?.pendingToolCallRequests[params.toolCallId] = ToolCallRequest(
171-
requestId: request.id,
172-
turnId: params.turnId,
173-
roundId: params.roundId,
174-
toolCallId: params.toolCallId,
175-
completion: completion)
152+
self?.handleClientToolConfirmationEvent(request: request, completion: completion)
176153
}).store(in: &cancellables)
177154
}
178155

@@ -298,9 +275,23 @@ public final class ChatService: ChatServiceType, ObservableObject {
298275
}
299276

300277
// MARK: - Helper Methods for Tool Call Status Updates
278+
279+
/// Returns true if the `conversationId` belongs to the active conversation or any subagent conversations.
280+
func isConversationIdValid(_ conversationId: String) -> Bool {
281+
conversationTurnTracking.validConversationIds.contains(conversationId)
282+
}
283+
284+
/// Workaround: toolConfirmation request does not have parent turnId.
285+
func parentTurnIdForTurnId(_ turnId: String) -> String? {
286+
conversationTurnTracking.turnParentMap[turnId]
287+
}
288+
289+
func storePendingToolCallRequest(toolCallId: String, request: ToolCallRequest) {
290+
pendingToolCallRequests[toolCallId] = request
291+
}
301292

302293
/// Sends the confirmation response (accept/dismiss) back to the server
303-
private func sendToolConfirmationResponse(_ request: ToolCallRequest, accepted: Bool) {
294+
func sendToolConfirmationResponse(_ request: ToolCallRequest, accepted: Bool) {
304295
let toolResult = LanguageModelToolConfirmationResult(
305296
result: accepted ? .Accept : .Dismiss
306297
)
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import Foundation
2+
3+
public typealias ConversationID = String
4+
5+
public enum AutoApprovalScope: Hashable {
6+
case session(ConversationID)
7+
// Future scopes:
8+
// case workspace(String)
9+
// case global
10+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import Foundation
2+
3+
struct MCPApprovalStorage {
4+
private struct ServerApprovalState {
5+
var isServerAllowed: Bool = false
6+
var allowedTools: Set<String> = []
7+
}
8+
9+
private struct ConversationApprovalState {
10+
var serverApprovals: [String: ServerApprovalState] = [:]
11+
}
12+
13+
/// Storage for session-scoped approvals.
14+
private var approvals: [ConversationID: ConversationApprovalState] = [:]
15+
16+
mutating func allowTool(scope: AutoApprovalScope, serverName: String, toolName: String) {
17+
guard case .session(let conversationId) = scope else { return }
18+
let server = normalize(serverName)
19+
let tool = normalize(toolName)
20+
guard !conversationId.isEmpty, !server.isEmpty, !tool.isEmpty else { return }
21+
22+
approvals[conversationId, default: ConversationApprovalState()]
23+
.serverApprovals[server, default: ServerApprovalState()]
24+
.allowedTools
25+
.insert(tool)
26+
}
27+
28+
mutating func allowServer(scope: AutoApprovalScope, serverName: String) {
29+
guard case .session(let conversationId) = scope else { return }
30+
let server = normalize(serverName)
31+
guard !conversationId.isEmpty, !server.isEmpty else { return }
32+
33+
approvals[conversationId, default: ConversationApprovalState()]
34+
.serverApprovals[server, default: ServerApprovalState()]
35+
.isServerAllowed = true
36+
}
37+
38+
func isAllowed(scope: AutoApprovalScope, serverName: String, toolName: String) -> Bool {
39+
guard case .session(let conversationId) = scope else { return false }
40+
let server = normalize(serverName)
41+
let tool = normalize(toolName)
42+
guard !conversationId.isEmpty, !server.isEmpty, !tool.isEmpty else { return false }
43+
44+
guard let conversationState = approvals[conversationId],
45+
let serverState = conversationState.serverApprovals[server] else { return false }
46+
47+
if serverState.isServerAllowed { return true }
48+
return serverState.allowedTools.contains(tool)
49+
}
50+
51+
mutating func clear(scope: AutoApprovalScope) {
52+
guard case .session(let conversationId) = scope else { return }
53+
guard !conversationId.isEmpty else { return }
54+
approvals.removeValue(forKey: conversationId)
55+
}
56+
57+
private func normalize(_ value: String) -> String {
58+
value.trimmingCharacters(in: .whitespacesAndNewlines)
59+
}
60+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import Foundation
2+
3+
struct SensitiveFileApprovalStorage {
4+
private struct ToolApprovalState {
5+
var allowedFiles: Set<String> = []
6+
}
7+
8+
private struct ConversationApprovalState {
9+
var toolApprovals: [String: ToolApprovalState] = [:]
10+
}
11+
12+
/// Storage for session-scoped approvals.
13+
private var approvals: [ConversationID: ConversationApprovalState] = [:]
14+
15+
mutating func allowFile(scope: AutoApprovalScope, toolName: String, fileKey: String) {
16+
guard case .session(let conversationId) = scope else { return }
17+
let tool = normalize(toolName)
18+
let key = normalize(fileKey)
19+
guard !conversationId.isEmpty, !tool.isEmpty, !key.isEmpty else { return }
20+
21+
approvals[conversationId, default: ConversationApprovalState()]
22+
.toolApprovals[tool, default: ToolApprovalState()]
23+
.allowedFiles
24+
.insert(key)
25+
}
26+
27+
func isAllowed(scope: AutoApprovalScope, toolName: String, fileKey: String) -> Bool {
28+
guard case .session(let conversationId) = scope else { return false }
29+
let tool = normalize(toolName)
30+
let key = normalize(fileKey)
31+
guard !conversationId.isEmpty, !tool.isEmpty, !key.isEmpty else { return false }
32+
33+
return approvals[conversationId]?.toolApprovals[tool]?.allowedFiles.contains(key) == true
34+
}
35+
36+
mutating func clear(scope: AutoApprovalScope) {
37+
guard case .session(let conversationId) = scope else { return }
38+
guard !conversationId.isEmpty else { return }
39+
approvals.removeValue(forKey: conversationId)
40+
}
41+
42+
private func normalize(_ value: String) -> String {
43+
value.trimmingCharacters(in: .whitespacesAndNewlines)
44+
}
45+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import Foundation
2+
3+
public actor ToolAutoApprovalManager {
4+
public static let shared = ToolAutoApprovalManager()
5+
6+
public enum AutoApproval: Equatable, Sendable {
7+
case mcpTool(conversationId: String, serverName: String, toolName: String)
8+
case mcpServer(conversationId: String, serverName: String)
9+
case sensitiveFile(conversationId: String, toolName: String, fileKey: String)
10+
}
11+
12+
private var mcpStorage = MCPApprovalStorage()
13+
private var sensitiveFileStorage = SensitiveFileApprovalStorage()
14+
15+
public init() {}
16+
17+
public func approve(_ approval: AutoApproval) {
18+
switch approval {
19+
case let .mcpTool(conversationId, serverName, toolName):
20+
allowMCPTool(conversationId: conversationId, serverName: serverName, toolName: toolName)
21+
case let .mcpServer(conversationId, serverName):
22+
allowMCPServer(conversationId: conversationId, serverName: serverName)
23+
case let .sensitiveFile(conversationId, toolName, fileKey):
24+
allowSensitiveFile(conversationId: conversationId, toolName: toolName, fileKey: fileKey)
25+
}
26+
}
27+
28+
// MARK: - MCP approvals
29+
30+
public func allowMCPTool(conversationId: String, serverName: String, toolName: String) {
31+
mcpStorage.allowTool(scope: .session(conversationId), serverName: serverName, toolName: toolName)
32+
}
33+
34+
public func allowMCPServer(conversationId: String, serverName: String) {
35+
mcpStorage.allowServer(scope: .session(conversationId), serverName: serverName)
36+
}
37+
38+
public func isMCPAllowed(
39+
conversationId: String,
40+
serverName: String,
41+
toolName: String
42+
) -> Bool {
43+
mcpStorage.isAllowed(scope: .session(conversationId), serverName: serverName, toolName: toolName)
44+
}
45+
46+
// MARK: - Sensitive file approvals
47+
48+
public func allowSensitiveFile(conversationId: String, toolName: String, fileKey: String) {
49+
sensitiveFileStorage.allowFile(scope: .session(conversationId), toolName: toolName, fileKey: fileKey)
50+
}
51+
52+
public func isSensitiveFileAllowed(
53+
conversationId: String,
54+
toolName: String,
55+
fileKey: String
56+
) -> Bool {
57+
sensitiveFileStorage.isAllowed(scope: .session(conversationId), toolName: toolName, fileKey: fileKey)
58+
}
59+
60+
// MARK: - Cleanup
61+
62+
public func clearConversationData(conversationId: String?) {
63+
guard let conversationId else { return }
64+
mcpStorage.clear(scope: .session(conversationId))
65+
sensitiveFileStorage.clear(scope: .session(conversationId))
66+
}
67+
}
68+
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import Foundation
2+
3+
extension ToolAutoApprovalManager {
4+
private static let mcpToolCallPattern = try? NSRegularExpression(
5+
pattern: #"Confirm MCP Tool: .+ - (.+)\(MCP Server\)"#,
6+
options: []
7+
)
8+
9+
private static let sensitiveRuleDescriptionRegex = try? NSRegularExpression(
10+
pattern: #"^(.*?)\s*needs confirmation\."#,
11+
options: [.caseInsensitive]
12+
)
13+
14+
public nonisolated static func extractMCPServerName(from message: String) -> String? {
15+
let fullRange = NSRange(message.startIndex..<message.endIndex, in: message)
16+
17+
if let regex = mcpToolCallPattern,
18+
let match = regex.firstMatch(in: message, options: [], range: fullRange),
19+
match.numberOfRanges >= 2,
20+
let range = Range(match.range(at: 1), in: message) {
21+
return String(message[range]).trimmingCharacters(in: .whitespacesAndNewlines)
22+
}
23+
24+
return nil
25+
}
26+
27+
public nonisolated static func isSensitiveFileOperation(message: String) -> Bool {
28+
message.range(of: "sensitive files", options: [.caseInsensitive, .diacriticInsensitive]) != nil
29+
}
30+
31+
public nonisolated static func sensitiveFileKey(from message: String) -> String {
32+
let fullRange = NSRange(message.startIndex..<message.endIndex, in: message)
33+
34+
// TODO: Update confirmation message in CLS to include rules
35+
if let regex = sensitiveRuleDescriptionRegex,
36+
let match = regex.firstMatch(in: message, options: [], range: fullRange),
37+
match.numberOfRanges >= 2,
38+
let range = Range(match.range(at: 1), in: message) {
39+
let description = String(message[range]).trimmingCharacters(in: .whitespacesAndNewlines)
40+
if !description.isEmpty {
41+
return description.lowercased()
42+
}
43+
}
44+
45+
return "sensitive files"
46+
}
47+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import Foundation
2+
import ConversationServiceProvider
3+
import JSONRPC
4+
5+
extension ChatService {
6+
typealias ToolConfirmationCompletion = (AnyJSONRPCResponse) -> Void
7+
8+
func handleClientToolConfirmationEvent(
9+
request: InvokeClientToolConfirmationRequest,
10+
completion: @escaping ToolConfirmationCompletion
11+
) {
12+
guard let params = request.params else { return }
13+
guard isConversationIdValid(params.conversationId) else { return }
14+
15+
Task { [weak self] in
16+
guard let self else { return }
17+
let shouldAutoApprove = await shouldAutoApprove(params: params)
18+
let parentTurnId = parentTurnIdForTurnId(params.turnId)
19+
20+
let toolCallStatus: AgentToolCall.ToolCallStatus = shouldAutoApprove
21+
? .accepted
22+
: .waitForConfirmation
23+
24+
appendToolCallHistory(
25+
turnId: params.turnId,
26+
editAgentRounds: makeEditAgentRounds(params: params, status: toolCallStatus),
27+
parentTurnId: parentTurnId
28+
)
29+
30+
let toolCallRequest = ToolCallRequest(
31+
requestId: request.id,
32+
turnId: params.turnId,
33+
roundId: params.roundId,
34+
toolCallId: params.toolCallId,
35+
completion: completion
36+
)
37+
38+
if shouldAutoApprove {
39+
sendToolConfirmationResponse(toolCallRequest, accepted: true)
40+
} else {
41+
storePendingToolCallRequest(toolCallId: params.toolCallId, request: toolCallRequest)
42+
}
43+
}
44+
}
45+
46+
private func shouldAutoApprove(params: InvokeClientToolParams) async -> Bool {
47+
let mcpServerName = ToolAutoApprovalManager.extractMCPServerName(from: params.title ?? "")
48+
let confirmationMessage = params.message ?? ""
49+
50+
if let mcpServerName {
51+
let allowed = await ToolAutoApprovalManager.shared.isMCPAllowed(
52+
conversationId: params.conversationId,
53+
serverName: mcpServerName,
54+
toolName: params.name
55+
)
56+
57+
if allowed {
58+
return true
59+
}
60+
}
61+
62+
if ToolAutoApprovalManager.isSensitiveFileOperation(message: confirmationMessage) {
63+
let fileKey = ToolAutoApprovalManager.sensitiveFileKey(from: confirmationMessage)
64+
let allowed = await ToolAutoApprovalManager.shared.isSensitiveFileAllowed(
65+
conversationId: params.conversationId,
66+
toolName: params.name,
67+
fileKey: fileKey
68+
)
69+
70+
if allowed {
71+
return true
72+
}
73+
}
74+
75+
return false
76+
}
77+
78+
func makeEditAgentRounds(params: InvokeClientToolParams, status: AgentToolCall.ToolCallStatus) -> [AgentRound] {
79+
[
80+
AgentRound(
81+
roundId: params.roundId,
82+
reply: "",
83+
toolCalls: [
84+
AgentToolCall(
85+
id: params.toolCallId,
86+
name: params.name,
87+
status: status,
88+
invokeParams: params,
89+
title: params.title
90+
)
91+
]
92+
)
93+
]
94+
}
95+
}

0 commit comments

Comments
 (0)