Skip to content

Commit 5f35dd6

Browse files
committed
Pre-release 0.51.182
1 parent 583249e commit 5f35dd6

8 files changed

Lines changed: 357 additions & 44 deletions

File tree

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import Foundation
2+
3+
struct FetchWebPageApprovalStorage {
4+
private var approvals: [ConversationID: Set<String>] = [:]
5+
6+
mutating func allowURLs(conversationId: ConversationID, urls: [String]) {
7+
guard !conversationId.isEmpty else { return }
8+
let normalizedURLs = Set(urls.compactMap(normalize))
9+
guard !normalizedURLs.isEmpty else { return }
10+
approvals[conversationId, default: []].formUnion(normalizedURLs)
11+
}
12+
13+
func areAllowed(conversationId: ConversationID, urls: [String]) -> Bool {
14+
guard !conversationId.isEmpty else { return false }
15+
let normalizedURLs = Set(urls.compactMap(normalize))
16+
guard !normalizedURLs.isEmpty,
17+
let approvedURLs = approvals[conversationId]
18+
else {
19+
return false
20+
}
21+
return normalizedURLs.isSubset(of: approvedURLs)
22+
}
23+
24+
mutating func clear(conversationId: ConversationID) {
25+
guard !conversationId.isEmpty else { return }
26+
approvals.removeValue(forKey: conversationId)
27+
}
28+
29+
private func normalize(_ url: String) -> String? {
30+
let normalizedURL = url.trimmingCharacters(in: .whitespacesAndNewlines)
31+
return normalizedURL.isEmpty ? nil : normalizedURL
32+
}
33+
}

Core/Sources/ChatService/ToolCalls/AutoApproval/ToolAutoApprovalManager.swift

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ public actor ToolAutoApprovalManager {
66
public enum AutoApproval: Equatable, Sendable {
77
case mcpTool(scope: AutoApprovalScope, serverName: String, toolName: String)
88
case mcpServer(scope: AutoApprovalScope, serverName: String)
9+
case fetchWebPage(conversationId: ConversationID, urls: [String])
910
case sensitiveFile(
1011
scope: AutoApprovalScope,
1112
toolName: String,
@@ -18,6 +19,7 @@ public actor ToolAutoApprovalManager {
1819
private var mcpStorage = MCPApprovalStorage()
1920
private var sensitiveFileStorage = SensitiveFileApprovalStorage()
2021
private var terminalStorage = TerminalApprovalStorage()
22+
private var fetchWebPageStorage = FetchWebPageApprovalStorage()
2123

2224
public init() {}
2325

@@ -39,6 +41,9 @@ public actor ToolAutoApprovalManager {
3941
allowMCPServerGlobally(serverName: serverName)
4042
}
4143

44+
case let .fetchWebPage(conversationId, urls):
45+
allowFetchWebPage(conversationId: conversationId, urls: urls)
46+
4247
case let .sensitiveFile(scope, toolName, description, pattern):
4348
switch scope {
4449
case .session(let conversationId):
@@ -66,6 +71,16 @@ public actor ToolAutoApprovalManager {
6671
}
6772
}
6873

74+
// MARK: - Fetch webpage approvals
75+
76+
public func allowFetchWebPage(conversationId: ConversationID, urls: [String]) {
77+
fetchWebPageStorage.allowURLs(conversationId: conversationId, urls: urls)
78+
}
79+
80+
public func isFetchWebPageAllowed(conversationId: ConversationID, urls: [String]) -> Bool {
81+
fetchWebPageStorage.areAllowed(conversationId: conversationId, urls: urls)
82+
}
83+
6984
// MARK: - MCP approvals
7085

7186
public func allowMCPTool(conversationId: String, serverName: String, toolName: String) {
@@ -168,6 +183,7 @@ public actor ToolAutoApprovalManager {
168183
mcpStorage.clear(scope: .session(conversationId))
169184
sensitiveFileStorage.clear(scope: .session(conversationId))
170185
terminalStorage.clear(scope: .session(conversationId))
186+
fetchWebPageStorage.clear(conversationId: conversationId)
171187
}
172188

173189
public func clearGlobalData() {
@@ -176,4 +192,3 @@ public actor ToolAutoApprovalManager {
176192
terminalStorage.clear(scope: .global)
177193
}
178194
}
179-

Core/Sources/ChatService/ToolCalls/AutoApproval/ToolAutoApprovalParsingHelpers.swift

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,26 @@ extension ToolAutoApprovalManager {
5757
name == ToolName.runInTerminal.rawValue
5858
}
5959

60+
public nonisolated static func isFetchWebPageOperation(name: String) -> Bool {
61+
name == ToolName.fetchWebPage.rawValue
62+
}
63+
64+
public nonisolated static func extractFetchWebPageURLs(
65+
from input: [String: AnyCodable]?
66+
) -> [String] {
67+
guard let urls = input?["urls"]?.value as? [String] else { return [] }
68+
return normalizeFetchWebPageURLs(urls)
69+
}
70+
71+
public nonisolated static func normalizeFetchWebPageURLs(_ urls: [String]) -> [String] {
72+
var seen = Set<String>()
73+
return urls.compactMap { url in
74+
let normalizedURL = url.trimmingCharacters(in: .whitespacesAndNewlines)
75+
guard !normalizedURL.isEmpty, seen.insert(normalizedURL).inserted else { return nil }
76+
return normalizedURL
77+
}
78+
}
79+
6080
public nonisolated static func extractSensitiveFileConfirmationInfo(from message: String) -> SensitiveFileConfirmationInfo {
6181
let fullRange = NSRange(message.startIndex ..< message.endIndex, in: message)
6282

Core/Sources/ChatService/ToolCalls/ClientToolConfirmationEventHandler.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,17 @@ extension ChatService {
5858
}
5959
}
6060

61+
if ToolAutoApprovalManager.isFetchWebPageOperation(name: params.name) {
62+
let urls = ToolAutoApprovalManager.extractFetchWebPageURLs(from: params.input)
63+
let allowed = await ToolAutoApprovalManager.shared.isFetchWebPageAllowed(
64+
conversationId: params.conversationId,
65+
urls: urls
66+
)
67+
if allowed {
68+
return true
69+
}
70+
}
71+
6172
if let mcpServerName {
6273
let allowed = await ToolAutoApprovalManager.shared.isMCPAllowed(
6374
conversationId: params.conversationId,

Core/Sources/ConversationTab/Views/ConversationAgentProgressView/ConversationAgentProgressView.swift

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,9 @@ struct ToolConfirmationView: View {
108108
private var mcpServerName: String? { ToolAutoApprovalManager.extractMCPServerName(from: titleText) }
109109
private var conversationId: String { tool.invokeParams?.conversationId ?? "" }
110110
private var invokeMessage: String { tool.invokeParams?.message ?? "" }
111+
private var fetchWebPageURLs: [String] {
112+
ToolAutoApprovalManager.extractFetchWebPageURLs(from: tool.invokeParams?.input)
113+
}
111114
private var isSensitiveFileOperation: Bool { ToolAutoApprovalManager.isSensitiveFileOperation(message: invokeMessage) }
112115
private var sensitiveFileInfo: ToolAutoApprovalManager.SensitiveFileConfirmationInfo {
113116
ToolAutoApprovalManager.extractSensitiveFileConfirmationInfo(from: invokeMessage)
@@ -117,13 +120,20 @@ struct ToolConfirmationView: View {
117120
private var shouldShowSensitiveFileSplitButton: Bool {
118121
mcpServerName == nil && isSensitiveFileOperation && !conversationId.isEmpty
119122
}
123+
private var shouldShowFetchWebPageSplitButton: Bool {
124+
ToolAutoApprovalManager.isFetchWebPageOperation(name: toolName)
125+
&& !conversationId.isEmpty
126+
&& !fetchWebPageURLs.isEmpty
127+
}
120128

121129
@ViewBuilder
122130
private var confirmationActionView: some View {
123131
if FeatureFlagNotifierImpl.shared.featureFlags.agentModeAutoApproval &&
124132
CopilotPolicyNotifierImpl.shared.copilotPolicy.agentModeAutoApprovalEnabled {
125133
if tool.isToolcallingLoopContinueTool {
126134
continueButton
135+
} else if shouldShowFetchWebPageSplitButton {
136+
fetchWebPageSplitButton
127137
} else if shouldShowSensitiveFileSplitButton {
128138
sensitiveFileSplitButton
129139
} else if shouldShowMCPSplitButton, let serverName = mcpServerName {
@@ -166,6 +176,38 @@ struct ToolConfirmationView: View {
166176
.buttonStyle(.borderedProminent)
167177
}
168178

179+
private var fetchWebPageMenuItems: [SplitButtonMenuItem] {
180+
[
181+
SplitButtonMenuItem(
182+
title: fetchWebPageURLs.count == 1
183+
? "Allow this URL in this Session"
184+
: "Allow these URLs in this Session"
185+
) {
186+
chat.send(
187+
.toolCallAcceptedWithApproval(
188+
tool.id,
189+
.fetchWebPage(
190+
conversationId: conversationId,
191+
urls: fetchWebPageURLs
192+
)
193+
)
194+
)
195+
},
196+
]
197+
}
198+
199+
private var fetchWebPageSplitButton: some View {
200+
SplitButton(
201+
title: "Allow Once",
202+
isDisabled: false,
203+
primaryAction: {
204+
chat.send(.toolCallAccepted(tool.id))
205+
},
206+
menuItems: fetchWebPageMenuItems,
207+
style: .prominent
208+
)
209+
}
210+
169211
private var sensitiveFileMenuItems: [SplitButtonMenuItem] {
170212
var items: [SplitButtonMenuItem] = []
171213

@@ -325,6 +367,23 @@ struct ToolConfirmationView: View {
325367
ThemedMarkdownText(text: tool.invokeParams?.message ?? "", chat: chat)
326368
.frame(maxWidth: .infinity, alignment: .leading)
327369

370+
if ToolAutoApprovalManager.isFetchWebPageOperation(name: toolName),
371+
!fetchWebPageURLs.isEmpty {
372+
VStack(alignment: .leading, spacing: 4) {
373+
Text(fetchWebPageURLs.count == 1 ? "URL" : "URLs")
374+
.scaledFont(size: chatFontSize - 1, weight: .semibold)
375+
.foregroundStyle(.primary)
376+
377+
ForEach(fetchWebPageURLs, id: \.self) { url in
378+
Text(url)
379+
.textSelection(.enabled)
380+
.scaledFont(size: chatFontSize - 1)
381+
.foregroundStyle(.primary)
382+
}
383+
}
384+
.frame(maxWidth: .infinity, alignment: .leading)
385+
}
386+
328387
HStack {
329388
Button(action: {
330389
chat.send(.toolCallCancelled(tool.id))

Core/Tests/ChatServiceTests/ToolAutoApprovalParsingHelpersTests.swift

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,4 +39,71 @@ class ToolAutoApprovalParsingHelpersTests: XCTestCase {
3939
XCTAssertEqual(ToolAutoApprovalManager.extractTerminalCommandNames(from: "ls | grep match"), ["ls", "grep"])
4040
XCTAssertEqual(ToolAutoApprovalManager.extractTerminalCommandNames(from: "ls &> out.txt"), ["ls"])
4141
}
42+
43+
func testIsFetchWebPageOperation() {
44+
XCTAssertTrue(ToolAutoApprovalManager.isFetchWebPageOperation(name: "fetch_webpage"))
45+
XCTAssertFalse(ToolAutoApprovalManager.isFetchWebPageOperation(name: "run_in_terminal"))
46+
}
47+
48+
func testNormalizeFetchWebPageURLs() {
49+
XCTAssertEqual(
50+
ToolAutoApprovalManager.normalizeFetchWebPageURLs(
51+
[
52+
" https://example.com/one ",
53+
"",
54+
"https://example.com/two",
55+
"https://example.com/one",
56+
]
57+
),
58+
["https://example.com/one", "https://example.com/two"]
59+
)
60+
}
61+
62+
func testFetchWebPageApprovalIsScopedToConversationAndURL() async {
63+
let manager = ToolAutoApprovalManager()
64+
let firstURL = "https://example.com/one"
65+
let secondURL = "https://example.com/two"
66+
67+
let initiallyAllowed = await manager.isFetchWebPageAllowed(
68+
conversationId: "conversation-1",
69+
urls: [firstURL]
70+
)
71+
XCTAssertFalse(initiallyAllowed)
72+
73+
await manager.approve(
74+
.fetchWebPage(
75+
conversationId: "conversation-1",
76+
urls: [" \(firstURL) "]
77+
)
78+
)
79+
80+
let allowedInApprovedConversation = await manager.isFetchWebPageAllowed(
81+
conversationId: "conversation-1",
82+
urls: [firstURL]
83+
)
84+
let allowedInOtherConversation = await manager.isFetchWebPageAllowed(
85+
conversationId: "conversation-2",
86+
urls: [firstURL]
87+
)
88+
let allowedForOtherURL = await manager.isFetchWebPageAllowed(
89+
conversationId: "conversation-1",
90+
urls: [secondURL]
91+
)
92+
let allowedForMixedURLs = await manager.isFetchWebPageAllowed(
93+
conversationId: "conversation-1",
94+
urls: [firstURL, secondURL]
95+
)
96+
XCTAssertTrue(allowedInApprovedConversation)
97+
XCTAssertFalse(allowedInOtherConversation)
98+
XCTAssertFalse(allowedForOtherURL)
99+
XCTAssertFalse(allowedForMixedURLs)
100+
101+
await manager.clearConversationData(conversationId: "conversation-1")
102+
103+
let allowedAfterClearing = await manager.isFetchWebPageAllowed(
104+
conversationId: "conversation-1",
105+
urls: [firstURL]
106+
)
107+
XCTAssertFalse(allowedAfterClearing)
108+
}
42109
}

0 commit comments

Comments
 (0)