Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import Foundation

struct FetchWebPageApprovalStorage {
private var approvals: [ConversationID: Set<String>] = [:]

mutating func allowURLs(conversationId: ConversationID, urls: [String]) {
guard !conversationId.isEmpty else { return }
let normalizedURLs = Set(urls.compactMap(normalize))
guard !normalizedURLs.isEmpty else { return }
approvals[conversationId, default: []].formUnion(normalizedURLs)
}

func areAllowed(conversationId: ConversationID, urls: [String]) -> Bool {
guard !conversationId.isEmpty else { return false }
let normalizedURLs = Set(urls.compactMap(normalize))
guard !normalizedURLs.isEmpty,
let approvedURLs = approvals[conversationId]
else {
return false
}
return normalizedURLs.isSubset(of: approvedURLs)
}

mutating func clear(conversationId: ConversationID) {
guard !conversationId.isEmpty else { return }
approvals.removeValue(forKey: conversationId)
}

private func normalize(_ url: String) -> String? {
let normalizedURL = url.trimmingCharacters(in: .whitespacesAndNewlines)
return normalizedURL.isEmpty ? nil : normalizedURL
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ public actor ToolAutoApprovalManager {
public enum AutoApproval: Equatable, Sendable {
case mcpTool(scope: AutoApprovalScope, serverName: String, toolName: String)
case mcpServer(scope: AutoApprovalScope, serverName: String)
case fetchWebPage(conversationId: ConversationID, urls: [String])
case sensitiveFile(
scope: AutoApprovalScope,
toolName: String,
Expand All @@ -18,6 +19,7 @@ public actor ToolAutoApprovalManager {
private var mcpStorage = MCPApprovalStorage()
private var sensitiveFileStorage = SensitiveFileApprovalStorage()
private var terminalStorage = TerminalApprovalStorage()
private var fetchWebPageStorage = FetchWebPageApprovalStorage()

public init() {}

Expand All @@ -39,6 +41,9 @@ public actor ToolAutoApprovalManager {
allowMCPServerGlobally(serverName: serverName)
}

case let .fetchWebPage(conversationId, urls):
allowFetchWebPage(conversationId: conversationId, urls: urls)

case let .sensitiveFile(scope, toolName, description, pattern):
switch scope {
case .session(let conversationId):
Expand Down Expand Up @@ -66,6 +71,16 @@ public actor ToolAutoApprovalManager {
}
}

// MARK: - Fetch webpage approvals

public func allowFetchWebPage(conversationId: ConversationID, urls: [String]) {
fetchWebPageStorage.allowURLs(conversationId: conversationId, urls: urls)
}

public func isFetchWebPageAllowed(conversationId: ConversationID, urls: [String]) -> Bool {
fetchWebPageStorage.areAllowed(conversationId: conversationId, urls: urls)
}

// MARK: - MCP approvals

public func allowMCPTool(conversationId: String, serverName: String, toolName: String) {
Expand Down Expand Up @@ -168,6 +183,7 @@ public actor ToolAutoApprovalManager {
mcpStorage.clear(scope: .session(conversationId))
sensitiveFileStorage.clear(scope: .session(conversationId))
terminalStorage.clear(scope: .session(conversationId))
fetchWebPageStorage.clear(conversationId: conversationId)
}

public func clearGlobalData() {
Expand All @@ -176,4 +192,3 @@ public actor ToolAutoApprovalManager {
terminalStorage.clear(scope: .global)
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,26 @@ extension ToolAutoApprovalManager {
name == ToolName.runInTerminal.rawValue
}

public nonisolated static func isFetchWebPageOperation(name: String) -> Bool {
name == ToolName.fetchWebPage.rawValue
}

public nonisolated static func extractFetchWebPageURLs(
from input: [String: AnyCodable]?
) -> [String] {
guard let urls = input?["urls"]?.value as? [String] else { return [] }
return normalizeFetchWebPageURLs(urls)
}

public nonisolated static func normalizeFetchWebPageURLs(_ urls: [String]) -> [String] {
var seen = Set<String>()
return urls.compactMap { url in
let normalizedURL = url.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedURL.isEmpty, seen.insert(normalizedURL).inserted else { return nil }
return normalizedURL
}
}

public nonisolated static func extractSensitiveFileConfirmationInfo(from message: String) -> SensitiveFileConfirmationInfo {
let fullRange = NSRange(message.startIndex ..< message.endIndex, in: message)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,17 @@ extension ChatService {
}
}

if ToolAutoApprovalManager.isFetchWebPageOperation(name: params.name) {
let urls = ToolAutoApprovalManager.extractFetchWebPageURLs(from: params.input)
let allowed = await ToolAutoApprovalManager.shared.isFetchWebPageAllowed(
conversationId: params.conversationId,
urls: urls
Comment on lines +61 to +65
)
if allowed {
return true
}
}

if let mcpServerName {
let allowed = await ToolAutoApprovalManager.shared.isMCPAllowed(
conversationId: params.conversationId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,9 @@ struct ToolConfirmationView: View {
private var mcpServerName: String? { ToolAutoApprovalManager.extractMCPServerName(from: titleText) }
private var conversationId: String { tool.invokeParams?.conversationId ?? "" }
private var invokeMessage: String { tool.invokeParams?.message ?? "" }
private var fetchWebPageURLs: [String] {
ToolAutoApprovalManager.extractFetchWebPageURLs(from: tool.invokeParams?.input)
}
private var isSensitiveFileOperation: Bool { ToolAutoApprovalManager.isSensitiveFileOperation(message: invokeMessage) }
private var sensitiveFileInfo: ToolAutoApprovalManager.SensitiveFileConfirmationInfo {
ToolAutoApprovalManager.extractSensitiveFileConfirmationInfo(from: invokeMessage)
Expand All @@ -117,13 +120,20 @@ struct ToolConfirmationView: View {
private var shouldShowSensitiveFileSplitButton: Bool {
mcpServerName == nil && isSensitiveFileOperation && !conversationId.isEmpty
}
private var shouldShowFetchWebPageSplitButton: Bool {
ToolAutoApprovalManager.isFetchWebPageOperation(name: toolName)
&& !conversationId.isEmpty
&& !fetchWebPageURLs.isEmpty
}

@ViewBuilder
private var confirmationActionView: some View {
if FeatureFlagNotifierImpl.shared.featureFlags.agentModeAutoApproval &&
CopilotPolicyNotifierImpl.shared.copilotPolicy.agentModeAutoApprovalEnabled {
if tool.isToolcallingLoopContinueTool {
continueButton
} else if shouldShowFetchWebPageSplitButton {
fetchWebPageSplitButton
} else if shouldShowSensitiveFileSplitButton {
sensitiveFileSplitButton
} else if shouldShowMCPSplitButton, let serverName = mcpServerName {
Expand Down Expand Up @@ -166,6 +176,38 @@ struct ToolConfirmationView: View {
.buttonStyle(.borderedProminent)
}

private var fetchWebPageMenuItems: [SplitButtonMenuItem] {
[
SplitButtonMenuItem(
title: fetchWebPageURLs.count == 1
? "Allow this URL in this Session"
: "Allow these URLs in this Session"
) {
chat.send(
.toolCallAcceptedWithApproval(
tool.id,
.fetchWebPage(
conversationId: conversationId,
urls: fetchWebPageURLs
)
)
)
},
]
}

private var fetchWebPageSplitButton: some View {
SplitButton(
title: "Allow Once",
isDisabled: false,
primaryAction: {
chat.send(.toolCallAccepted(tool.id))
},
menuItems: fetchWebPageMenuItems,
style: .prominent
)
}

private var sensitiveFileMenuItems: [SplitButtonMenuItem] {
var items: [SplitButtonMenuItem] = []

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

if ToolAutoApprovalManager.isFetchWebPageOperation(name: toolName),
!fetchWebPageURLs.isEmpty {
VStack(alignment: .leading, spacing: 4) {
Text(fetchWebPageURLs.count == 1 ? "URL" : "URLs")
.scaledFont(size: chatFontSize - 1, weight: .semibold)
.foregroundStyle(.primary)

ForEach(fetchWebPageURLs, id: \.self) { url in
Text(url)
.textSelection(.enabled)
.scaledFont(size: chatFontSize - 1)
.foregroundStyle(.primary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}

HStack {
Button(action: {
chat.send(.toolCallCancelled(tool.id))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,71 @@ class ToolAutoApprovalParsingHelpersTests: XCTestCase {
XCTAssertEqual(ToolAutoApprovalManager.extractTerminalCommandNames(from: "ls | grep match"), ["ls", "grep"])
XCTAssertEqual(ToolAutoApprovalManager.extractTerminalCommandNames(from: "ls &> out.txt"), ["ls"])
}

func testIsFetchWebPageOperation() {
XCTAssertTrue(ToolAutoApprovalManager.isFetchWebPageOperation(name: "fetch_webpage"))
XCTAssertFalse(ToolAutoApprovalManager.isFetchWebPageOperation(name: "run_in_terminal"))
}

func testNormalizeFetchWebPageURLs() {
XCTAssertEqual(
ToolAutoApprovalManager.normalizeFetchWebPageURLs(
[
" https://example.com/one ",
"",
"https://example.com/two",
"https://example.com/one",
]
),
["https://example.com/one", "https://example.com/two"]
)
}

func testFetchWebPageApprovalIsScopedToConversationAndURL() async {
let manager = ToolAutoApprovalManager()
let firstURL = "https://example.com/one"
let secondURL = "https://example.com/two"

let initiallyAllowed = await manager.isFetchWebPageAllowed(
conversationId: "conversation-1",
urls: [firstURL]
)
XCTAssertFalse(initiallyAllowed)

await manager.approve(
.fetchWebPage(
conversationId: "conversation-1",
urls: [" \(firstURL) "]
)
)

let allowedInApprovedConversation = await manager.isFetchWebPageAllowed(
conversationId: "conversation-1",
urls: [firstURL]
)
let allowedInOtherConversation = await manager.isFetchWebPageAllowed(
conversationId: "conversation-2",
urls: [firstURL]
)
let allowedForOtherURL = await manager.isFetchWebPageAllowed(
conversationId: "conversation-1",
urls: [secondURL]
)
let allowedForMixedURLs = await manager.isFetchWebPageAllowed(
conversationId: "conversation-1",
urls: [firstURL, secondURL]
)
XCTAssertTrue(allowedInApprovedConversation)
XCTAssertFalse(allowedInOtherConversation)
XCTAssertFalse(allowedForOtherURL)
XCTAssertFalse(allowedForMixedURLs)

await manager.clearConversationData(conversationId: "conversation-1")

let allowedAfterClearing = await manager.isFetchWebPageAllowed(
conversationId: "conversation-1",
urls: [firstURL]
)
XCTAssertFalse(allowedAfterClearing)
}
}
Loading