Adopt AgentKit approval policies

This commit is contained in:
Kaede Akino committed 2026-09-14 17:33:57 +08:00
1 parent c56060a7d5
commit ad4e72e35d
42 files changed
+537 -383

No files matched your search

@@ -148,6 +148,21 @@ struct AssistantTranscriptPresentationTests {
#expect(conversation.transcript.count == 1)
}
@Test func approvalPolicyDenialFinishesAsDeniedRatherThanFailed() {
let value = card(state: .proposed)
let conversation = ConversationModel()
conversation.handle(.toolProposed(value.invocation, value.descriptor))
conversation.handle(
.toolFinished(
value.invocation,
AgentToolResult(
callID: value.invocation.call.id, content: "Denied", isError: true,
metadata: [AgentToolResult.approvalPolicyDeniedKey: .bool(true)]
)))
#expect(conversation.toolCards.first?.state == .denied)
}
@Test func resultProjectionUsesTheInjectedPresenterAndRetainsFullCopyOutput() throws {
var value = card(state: .completed)
value.result = AgentToolResult(callID: value.invocation.call.id, content: "{\"answer\":42}")
@@ -193,7 +208,7 @@ struct AssistantTranscriptPresentationTests {
runID: UUID(), call: .init(id: UUID().uuidString, name: "inspect", arguments: .object([:]))),
descriptor: .init(
name: "inspect", summary: "Inspect", inputSchema: .object([:]), target: .host,
safety: .locallyReadOnly, concurrency: .parallel),
approvalPolicy: .approve, concurrency: .parallel),
state: state
)
}
+206 -145
View File
@@ -17,150 +17,6 @@
}
}
},
"`%@` changes system, file, or external state." : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "`%@`会更改系统、文件或外部状态。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "`%@`會更改系統、檔案或外部狀態。"
}
}
}
},
"Command analysis exceeded the supported complexity limit." : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "命令分析超出了支持的复杂度限制。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "指令分析超出了支援的複雜度限制。"
}
}
}
},
"Caution" : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "谨慎"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "謹慎"
}
}
}
},
"Contains shell syntax that cannot be proven read-only." : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "包含无法证明为只读的Shell语法。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "包含無法證明為唯讀的Shell語法。"
}
}
}
},
"Force-overwrites `%@`." : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "强制覆盖`%@`。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "強制覆寫`%@`。"
}
}
}
},
"Runs a command on another host." : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "在另一台主机上运行命令。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "在另一台主機上執行指令。"
}
}
}
},
"Stops, disables, or removes service state." : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "停止、禁用或移除服务状态。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "停止、停用或移除服務狀態。"
}
}
}
},
"Writes directly to a device or kernel control path." : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "直接写入设备或内核控制路径。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "直接寫入裝置或核心控制路徑。"
}
}
}
},
"Writes to `%@`." : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "写入`%@`。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "寫入`%@`。"
}
}
}
},
" Copy" : {
"localizations" : {
"zh-Hans" : {
@@ -1182,6 +1038,7 @@
},
"%lld/%lld tools" : {
"comment" : "A tool count for a MCP server. The first argument is the number of enabled tools. The second argument is the total number of tools.",
"extractionState" : "stale",
"isCommentAutoGenerated" : true,
"localizations" : {
"en" : {
@@ -1323,6 +1180,23 @@
}
}
},
"`%@` changes system, file, or external state." : {
"extractionState" : "stale",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "`%@`会更改系统、文件或外部状态。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "`%@`會更改系統、檔案或外部狀態。"
}
}
}
},
"`%@` is not a recognised read-only command." : {
"extractionState" : "stale",
"localizations" : {
@@ -2736,6 +2610,7 @@
}
},
"Always Ask" : {
"extractionState" : "stale",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
@@ -2752,6 +2627,7 @@
}
},
"Always Ask requires approval for every call. Follow Permissions uses the conversation's permission mode and safety checks." : {
"extractionState" : "stale",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
@@ -3146,6 +3022,22 @@
}
}
},
"Approve" : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "批准"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "核准"
}
}
}
},
"Archive" : {
"localizations" : {
"zh-Hans" : {
@@ -3263,6 +3155,22 @@
}
}
},
"Ask" : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "询问"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "詢問"
}
}
}
},
"Ask a question or describe what you want to do." : {
"localizations" : {
"zh-Hans" : {
@@ -4171,6 +4079,23 @@
}
}
},
"Caution" : {
"extractionState" : "stale",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "谨慎"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "謹慎"
}
}
}
},
"Certificate" : {
"localizations" : {
"zh-Hans" : {
@@ -5064,6 +4989,23 @@
}
}
},
"Command analysis exceeded the supported complexity limit." : {
"extractionState" : "stale",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "命令分析超出了支持的复杂度限制。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "指令分析超出了支援的複雜度限制。"
}
}
}
},
"Command Generator is already reading the current input." : {
"extractionState" : "stale",
"localizations" : {
@@ -5860,6 +5802,23 @@
}
}
},
"Contains shell syntax that cannot be proven read-only." : {
"extractionState" : "stale",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "包含无法证明为只读的Shell语法。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "包含無法證明為唯讀的Shell語法。"
}
}
}
},
"Content" : {
"extractionState" : "stale",
"localizations" : {
@@ -7379,6 +7338,22 @@
}
}
},
"Deny refuses the call. Ask uses the conversation's manual or automatic approval. Approve runs without approval and is unavailable for tools marked destructive." : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "“拒绝”会拒绝调用;“询问”使用会话的手动或自动审批;“批准”无需审批即可运行,且不适用于标记为破坏性的工具。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "「拒絕」會拒絕呼叫;「詢問」使用工作階段的手動或自動核准;「核准」無需核准即可執行,且不適用於標示為破壞性的工具。"
}
}
}
},
"Derived from the private key" : {
"localizations" : {
"zh-Hans" : {
@@ -9933,6 +9908,7 @@
}
},
"Follow Permissions" : {
"extractionState" : "stale",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
@@ -10062,6 +10038,23 @@
}
}
},
"Force-overwrites `%@`." : {
"extractionState" : "stale",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "强制覆盖`%@`。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "強制覆寫`%@`。"
}
}
}
},
"Fork" : {
"localizations" : {
"zh-Hans" : {
@@ -19201,6 +19194,23 @@
}
}
},
"Runs a command on another host." : {
"extractionState" : "stale",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "在另一台主机上运行命令。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "在另一台主機上執行指令。"
}
}
}
},
"Runs with elevated privileges." : {
"extractionState" : "stale",
"localizations" : {
@@ -21683,6 +21693,23 @@
}
}
},
"Stops, disables, or removes service state." : {
"extractionState" : "stale",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "停止、禁用或移除服务状态。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "停止、停用或移除服務狀態。"
}
}
}
},
"Store in" : {
"localizations" : {
"zh-Hans" : {
@@ -26946,6 +26973,40 @@
}
}
},
"Writes directly to a device or kernel control path." : {
"extractionState" : "stale",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "直接写入设备或内核控制路径。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "直接寫入裝置或核心控制路徑。"
}
}
}
},
"Writes to `%@`." : {
"extractionState" : "stale",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "写入`%@`。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "寫入`%@`。"
}
}
}
},
"Written" : {
"extractionState" : "stale",
"localizations" : {
@@ -27066,4 +27127,4 @@
}
},
"version" : "1.2"
}
}
@@ -2438,7 +2438,9 @@ public final class ConversationModel: Identifiable {
// an error to the *model*, which is what makes the turn
// replayable, but the card kept being relabelled `Failed` the
// instant it was refused, so `denied` never reached the screen.
if card.state != .denied {
if result.isApprovalPolicyDenied {
card.state = .denied
} else if card.state != .denied {
if result.isInterrupted {
card.state = .interrupted
} else {
@@ -2691,7 +2693,7 @@ extension ConversationModel {
let descriptor = AgentToolDescriptor(
name: "ssh_exec",
summary: text("Run a command on a remote host.", "在远程主机上运行命令。", "在遠端主機上執行指令。"),
inputSchema: .object([:]), target: .host, safety: .locallyReadOnly,
inputSchema: .object([:]), target: .host, approvalPolicy: .approve,
concurrency: .parallel,
presentation: AgentToolDescriptor.Presentation(
symbol: "apple.terminal", activity: .argument(key: "command", fallback: "command"),
@@ -0,0 +1,29 @@
import AgentKit
nonisolated extension MCPTool {
public var allowsTermindPreapproval: Bool {
annotations.destructiveHint != true
}
/// Termind never treats a remotely flagged destructive tool as pre-approved.
/// The hint cannot authorize anything, but it may tighten a local choice.
public var termindApprovalPolicy: AgentToolDescriptor.ApprovalPolicy {
!allowsTermindPreapproval && accessPolicy == .approve ? .ask : accessPolicy
}
public mutating func enforceTermindApprovalPolicy() {
accessPolicy = termindApprovalPolicy
}
}
nonisolated extension MCPServer {
public func enforcingTermindApprovalPolicies() -> MCPServer {
var server = self
server.tools = tools.map { tool in
var tool = tool
tool.enforceTermindApprovalPolicy()
return tool
}
return server
}
}
@@ -42,9 +42,9 @@ nonisolated public enum TermindAgentPolicy {
user's approval; one that destroys something is refused outright. SFTP reads, fetch and \
search all work.
What is refused is changing anything: writing or deleting remote files, pushing from \
the scratch workspace, and every MCP tool. Do not attempt those, and never describe an \
action as taken — you have taken none.
Direct remote file writes, deletes, and pushes from the scratch workspace are refused. \
MCP tools follow their saved policy: Deny refuses the call, Ask sends it through the \
conversation's approval mode, and Approve runs it without another approval step.
Draft the plan in the scratch workspace with scratch_write, and revise it there with \
scratch_replace rather than rewriting it. That way a revision costs a diff instead of \
@@ -78,7 +78,7 @@ nonisolated public enum TermindAgentPolicy {
version: Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0")
}
/// Plan mode with the one rule the runtime cannot write for us.
/// Plan mode with the host rules the runtime cannot write for us.
///
/// `ssh_exec` is judged on what the command would *do*, not on who has to
/// authorize it — and that difference is the point of the mode rather than a
@@ -88,15 +88,18 @@ nonisolated public enum TermindAgentPolicy {
/// Refusing those would leave the agent guessing at the state it is supposed
/// to be planning against, which is worse than useless.
///
/// So the bar is *destructive*, not *provably safe*. A command the classifier
/// So the SSH bar is *destructive*, not *provably safe*. A command the classifier
/// cannot vouch for still meets the approval gate exactly as it would outside
/// plan mode — the user sees it under Ask for approval, Security Review sees
/// it under Approve for me. Full access skips that, as it does everywhere
/// else; plan mode is a posture, not a sandbox, and the one thing it refuses
/// outright even there is a command that destroys something.
/// else; plan mode is a posture, not a sandbox, and the one SSH command it
/// refuses outright even there is one that destroys something. MCP calls
/// also proceed to the ordinary approval gate, where their saved Deny, Ask,
/// or Approve policy remains authoritative.
public static func planModeHook(isPlanning: Bool) -> AgentPlanModeHook {
var hook = AgentPlanModeHook(isPlanning: isPlanning)
hook.hostDecision = { context in
if context.descriptor.target == .mcp { return .proceed }
guard context.invocation.call.name == sshExecToolName else { return nil }
let command =
context.invocation.call.arguments
@@ -86,7 +86,7 @@ public final class AIConfigurationModel {
self.webSearchPersistence = webSearchPersistence
self.modelProviders = modelProviders
self.endpoints = endpoints
self.mcpServers = mcpServers
self.mcpServers = mcpServers.map { $0.enforcingTermindApprovalPolicies() }
self.roles = roles
self.lastSelectedModel = lastSelectedModel
self.lastSelectedPermissionMode =
@@ -110,7 +110,7 @@ public final class AIConfigurationModel {
self.endpoints = loaded.endpoints
self.roles = loaded.roles
self.lastSelectedModel = loaded.lastSelectedModel
self.mcpServers = loaded.mcpServers
self.mcpServers = loaded.mcpServers.map { $0.enforcingTermindApprovalPolicies() }
} catch {
storageError = error.localizedDescription
}
@@ -238,10 +238,11 @@ public final class AIConfigurationModel {
}
}
/// Every tool the model may be offered: enabled tools on enabled servers.
/// Every discovered tool on enabled servers. Denied tools stay visible to
/// the model so an attempted call receives an explicit policy refusal.
public var availableTools: [(server: MCPServer, tool: MCPTool)] {
usableMCPServers.flatMap { server in
server.enabledTools.map { (server, $0) }
server.tools.map { (server, $0) }
}
}
@@ -277,7 +278,7 @@ public final class AIConfigurationModel {
endpoints = snapshot.endpoints
roles = snapshot.roles
lastSelectedModel = localSelection.flatMap { lookup($0) == nil ? nil : $0 }
mcpServers = snapshot.mcpServers
mcpServers = snapshot.mcpServers.map { $0.enforcingTermindApprovalPolicies() }
mcpStatuses = mcpStatuses.filter { id, _ in
previousServers[id] == mcpServers.first { $0.id == id }
}
@@ -556,6 +557,7 @@ public final class AIConfigurationModel {
// MARK: - MCP writes
public func upsert(_ server: MCPServer) {
let server = server.enforcingTermindApprovalPolicies()
mutatePersistedState {
if let index = mcpServers.firstIndex(where: { $0.id == server.id }) {
mcpServers[index] = server
@@ -605,9 +607,9 @@ public final class AIConfigurationModel {
)
mcpServers[index].tools = discovered.map { tool in
guard let old = previous[tool.id] else { return tool }
var merged = tool
merged.accessPolicy = old.accessPolicy
if let old = previous[tool.id] { merged.accessPolicy = old.accessPolicy }
merged.enforceTermindApprovalPolicy()
return merged
}
mcpServers[index].negotiatedVersion = version
@@ -616,9 +618,12 @@ public final class AIConfigurationModel {
}
public func setToolAccessPolicy(
_ policy: MCPTool.AccessPolicy, tool toolID: String, on serverID: MCPServer.ID
_ policy: AgentToolDescriptor.ApprovalPolicy, tool toolID: String, on serverID: MCPServer.ID
) {
mutate(toolID, on: serverID) { $0.accessPolicy = policy }
mutate(toolID, on: serverID) {
$0.accessPolicy = policy
$0.enforceTermindApprovalPolicy()
}
}
private func mutate(
@@ -621,7 +621,7 @@ public final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable {
name: row.toolName, summary: row.toolName,
inputSchema: .object(["type": .string("object")]),
target: row.targetHostID == nil ? .local : .host,
safety: .requiresAuthorization, presentation: result?.toolPresentation
approvalPolicy: .ask, presentation: result?.toolPresentation
)
let review = reviews.filter { $0.invocationID == row.id }
.sorted { $0.createdAt < $1.createdAt }
@@ -8,7 +8,7 @@ import struct TermindDomain.Host
nonisolated public protocol SFTPToolDefinition: AgentToolDefinition, AgentToolSchemaBuilding {
static var name: String { get }
static var summary: String { get }
static var safety: AgentToolDescriptor.Safety { get }
static var approvalPolicy: AgentToolDescriptor.ApprovalPolicy { get }
static var concurrency: AgentToolDescriptor.Concurrency { get }
static var extraProperties: [String: AgentJSONValue] { get }
static var extraRequired: [String] { get }
@@ -23,7 +23,7 @@ nonisolated public protocol SFTPToolDefinition: AgentToolDefinition, AgentToolSc
nonisolated extension SFTPToolDefinition {
public static var concurrency: AgentToolDescriptor.Concurrency {
safety == .locallyReadOnly ? .parallel : .sequential
approvalPolicy == .approve ? .parallel : .sequential
}
public static var extraProperties: [String: AgentJSONValue] { [:] }
public static var extraRequired: [String] { [] }
@@ -36,7 +36,7 @@ nonisolated extension SFTPToolDefinition {
return Self.descriptor(
Self.name, Self.summary, properties: properties,
required: ["host_id", "path"] + Self.extraRequired,
target: .host, safety: Self.safety, concurrency: Self.concurrency,
target: .host, approvalPolicy: Self.approvalPolicy, concurrency: Self.concurrency,
presentation: Self.presentation
)
}
@@ -46,7 +46,7 @@ nonisolated extension SFTPToolDefinition {
let arguments = try Arguments(invocation)
checked.targetHostID = try arguments.uuid("host_id")
_ = try AgentToolSupport.normalizeRemotePath(arguments.string("path"))
return AgentToolPreflight(invocation: checked, safety: Self.safety)
return AgentToolPreflight(invocation: checked, approvalPolicy: Self.approvalPolicy)
}
public func execute(
@@ -69,7 +69,7 @@ nonisolated extension SFTPToolDefinition {
nonisolated public struct SFTPListTool: SFTPToolDefinition {
public static let name = "sftp_list"
public static let summary = "List a remote directory."
public static let safety: AgentToolDescriptor.Safety = .locallyReadOnly
public static let approvalPolicy: AgentToolDescriptor.ApprovalPolicy = .approve
public static let presenter = AgentToolDetailPresenter(
id: "builtin.sftp_list", present: present
)
@@ -96,7 +96,7 @@ nonisolated public struct SFTPListTool: SFTPToolDefinition {
nonisolated public struct SFTPStatTool: SFTPToolDefinition {
public static let name = "sftp_stat"
public static let summary = "Read remote file metadata."
public static let safety: AgentToolDescriptor.Safety = .locallyReadOnly
public static let approvalPolicy: AgentToolDescriptor.ApprovalPolicy = .approve
public static let presenter = AgentToolDetailPresenter(
id: "builtin.sftp_stat", present: present
)
@@ -123,7 +123,7 @@ nonisolated public struct SFTPStatTool: SFTPToolDefinition {
nonisolated public struct SFTPReadTool: SFTPToolDefinition {
public static let name = "sftp_read"
public static let summary = "Read a bounded UTF-8 remote file inline."
public static let safety: AgentToolDescriptor.Safety = .locallyReadOnly
public static let approvalPolicy: AgentToolDescriptor.ApprovalPolicy = .approve
public static let presenter = AgentToolDetailPresenter(
id: "builtin.sftp_read", present: present
)
@@ -157,7 +157,7 @@ nonisolated public struct SFTPReadTool: SFTPToolDefinition {
nonisolated public struct SFTPWriteTool: SFTPToolDefinition {
public static let name = "sftp_write"
public static let summary = "Write a bounded UTF-8 remote file atomically."
public static let safety: AgentToolDescriptor.Safety = .requiresAuthorization
public static let approvalPolicy: AgentToolDescriptor.ApprovalPolicy = .ask
public static let extraProperties = ["content": string(max: 131_072), "overwrite": boolean()]
public static let extraRequired = ["content"]
public static let presenter = AgentToolDetailPresenter(
@@ -190,7 +190,7 @@ nonisolated public struct SFTPWriteTool: SFTPToolDefinition {
nonisolated public struct SFTPMkdirTool: SFTPToolDefinition {
public static let name = "sftp_mkdir"
public static let summary = "Create a remote directory."
public static let safety: AgentToolDescriptor.Safety = .requiresAuthorization
public static let approvalPolicy: AgentToolDescriptor.ApprovalPolicy = .ask
public static let presenter = AgentToolDetailPresenter(
id: "builtin.sftp_mkdir", present: present,
presentArguments: presentArguments
@@ -219,7 +219,7 @@ nonisolated public struct SFTPMkdirTool: SFTPToolDefinition {
nonisolated public struct SFTPMoveTool: SFTPToolDefinition {
public static let name = "sftp_move"
public static let summary = "Move or rename a remote path."
public static let safety: AgentToolDescriptor.Safety = .requiresAuthorization
public static let approvalPolicy: AgentToolDescriptor.ApprovalPolicy = .ask
public static let extraProperties = ["destination": string(max: 4_096), "overwrite": boolean()]
public static let extraRequired = ["destination"]
public static let presenter = AgentToolDetailPresenter(
@@ -256,7 +256,7 @@ nonisolated public struct SFTPMoveTool: SFTPToolDefinition {
nonisolated public struct SFTPChmodTool: SFTPToolDefinition {
public static let name = "sftp_chmod"
public static let summary = "Change remote POSIX permissions."
public static let safety: AgentToolDescriptor.Safety = .requiresAuthorization
public static let approvalPolicy: AgentToolDescriptor.ApprovalPolicy = .ask
public static let extraProperties = ["mode": string(max: 4)]
public static let extraRequired = ["mode"]
public static let presenter = AgentToolDetailPresenter(
@@ -292,7 +292,7 @@ nonisolated public struct SFTPChmodTool: SFTPToolDefinition {
nonisolated public struct SFTPDeleteTool: SFTPToolDefinition {
public static let name = "sftp_delete"
public static let summary = "Permanently delete one remote file or empty directory."
public static let safety: AgentToolDescriptor.Safety = .requiresAuthorization
public static let approvalPolicy: AgentToolDescriptor.ApprovalPolicy = .ask
public static let extraProperties = ["kind": enumeration(["file", "directory"])]
public static let extraRequired = ["kind"]
public static let presenter = AgentToolDetailPresenter(
@@ -12,7 +12,7 @@ nonisolated public struct ListHostsTool: AgentToolDefinition, AgentToolSchemaBui
public var descriptor: AgentToolDescriptor {
Self.descriptor(
"list_hosts", "List Vault and temporary Termind SSH targets without credentials.",
properties: [:], required: [], target: .host, safety: .locallyReadOnly,
properties: [:], required: [], target: .host, approvalPolicy: .approve,
concurrency: .parallel,
presentation: .init(
symbol: "server.rack", activity: .semanticLabel(.hosts), output: .json,
@@ -65,7 +65,7 @@ nonisolated public struct SSHWaitTool: AgentToolDefinition, AgentToolSchemaBuild
"handle": Self.string(max: 64),
"timeout_seconds": Self.integer(min: 1, max: 900),
],
required: ["handle"], target: .host, safety: .locallyReadOnly,
required: ["handle"], target: .host, approvalPolicy: .approve,
concurrency: .parallel,
presentation: .init(
symbol: "clock.arrow.circlepath", activity: .semanticLabel(.remoteCommand),
@@ -119,7 +119,7 @@ nonisolated public struct TerminalSuggestTool: AgentToolDefinition, AgentToolSch
"command": Self.string(max: 32_768),
"explanation": Self.string(max: 4_096),
],
required: ["command"], target: .user, safety: .locallyReadOnly,
required: ["command"], target: .user, approvalPolicy: .approve,
concurrency: .parallel,
presentation: .init(
symbol: "terminal", activity: .semanticArgument(key: "command", fallback: .command),
@@ -247,7 +247,7 @@ nonisolated public struct SSHExecTool: AgentToolDefinition, AgentToolSchemaBuild
"timeout_seconds": Self.integer(min: 1, max: 900),
"stdin": Self.string(max: 131_072), "secret_handle": Self.string(),
"stdin_purpose": Self.string(max: 256),
], required: ["host_id", "command"], target: .host, safety: .requiresAuthorization,
], required: ["host_id", "command"], target: .host, approvalPolicy: .ask,
presentation: .init(
symbol: "terminal",
activity: .semanticArgument(key: "command", fallback: .sshCommand),
@@ -291,11 +291,11 @@ nonisolated public struct SSHExecTool: AgentToolDefinition, AgentToolSchemaBuild
let safe = assessment.risk == .safe
return AgentToolPreflight(
invocation: checked,
safety: safe ? .locallyReadOnly : .requiresAuthorization,
approvalPolicy: safe ? .approve : .ask,
reasons: assessment.reasons,
// Whether a command may run beside another is a property of
// the command, not of `ssh_exec`, and it is decided by the
// same locally-proven evidence that just decided `safety`.
// same locally-proven evidence that just decided `approvalPolicy`.
// A command the classifier cannot vouch for makes its whole
// turn serial, so nothing observes the host while it is
// being changed.
@@ -28,7 +28,7 @@ nonisolated public struct ScratchPullTool: AgentToolDefinition, AgentToolSchemaB
"host_id": Self.string(), "remote_path": Self.string(max: 4_096),
"scratch_path": Self.string(max: AgentScratchWorkspace.Limits.pathBytes),
], required: ["host_id", "remote_path", "scratch_path"], target: .host,
safety: .locallyContained, concurrency: .parallel,
approvalPolicy: .approve, concurrency: .parallel,
presentation: .init(
symbol: "arrow.down.doc",
activity: .semanticArgument(key: "remote_path", fallback: .remoteFile),
@@ -43,7 +43,7 @@ nonisolated public struct ScratchPullTool: AgentToolDefinition, AgentToolSchemaB
checked.targetHostID = try arguments.uuid("host_id")
_ = try AgentToolSupport.normalizeRemotePath(arguments.string("remote_path"))
_ = try AgentScratchWorkspace.normalize(arguments.string("scratch_path"))
return AgentToolPreflight(invocation: checked, safety: .locallyContained)
return AgentToolPreflight(invocation: checked, approvalPolicy: .approve)
}
public func execute(_ invocation: AgentToolInvocation, context: AgentToolExecutionContext) async throws
@@ -97,7 +97,7 @@ nonisolated public struct ScratchPushTool: AgentToolDefinition, AgentToolSchemaB
"scratch_path": Self.string(max: AgentScratchWorkspace.Limits.pathBytes),
"remote_path": Self.string(max: 4_096), "overwrite": Self.boolean(),
], required: ["host_id", "scratch_path", "remote_path"], target: .host,
safety: .requiresAuthorization,
approvalPolicy: .ask,
presentation: .init(
symbol: "arrow.up.doc",
activity: .semanticArgument(key: "remote_path", fallback: .remoteFile),
@@ -114,7 +114,7 @@ nonisolated public struct ScratchPushTool: AgentToolDefinition, AgentToolSchemaB
let staged = try arguments.string("scratch_path")
let file = try await workspace.data(at: staged)
return AgentToolPreflight(
invocation: checked, safety: .requiresAuthorization,
invocation: checked, approvalPolicy: .ask,
reasons: Self.evidence(path: staged, file: file),
executionMetadata: ["scratch_sha256": .string(file.sha256)]
)
@@ -25,7 +25,7 @@ nonisolated public struct GetMetricsTool: AgentToolDefinition, AgentToolSchemaBu
"type": .string("string"),
"enum": .array(Self.sections.sorted().map(AgentJSONValue.string)),
]), max: 4),
], required: ["host_id"], target: .host, safety: .locallyReadOnly, concurrency: .parallel,
], required: ["host_id"], target: .host, approvalPolicy: .approve, concurrency: .parallel,
presentation: .init(
symbol: "gauge.with.dots.needle.67percent", activity: .semanticLabel(.metrics),
output: .json, actionKind: .read
@@ -38,7 +38,7 @@ nonisolated public struct GetMetricsTool: AgentToolDefinition, AgentToolSchemaBu
let arguments = try Arguments(invocation)
checked.targetHostID = try arguments.uuid("host_id")
_ = try Self.includes(arguments)
return AgentToolPreflight(invocation: checked, safety: .locallyReadOnly)
return AgentToolPreflight(invocation: checked, approvalPolicy: .approve)
}
public func execute(_ invocation: AgentToolInvocation, context: AgentToolExecutionContext) async throws
+10 -7
View File
@@ -165,8 +165,8 @@ tool capability receive no tools; only recognized chat-purpose models appear in
conversation pickers, and unknown purpose values safely default to chat.
A batch is concurrent only if every call is parallel. Preflight may tighten this:
`ssh_exec` is parallel only when local command classification proves it safe; MCP
destructive hints or Ask First make a call sequential. Results always enter the
`ssh_exec` is parallel only when local command classification proves it safe; an MCP
destructive hint makes a call sequential. Results always enter the
transcript in model order, and every call gets an answer even after cancellation or
refusal. Approvals/questions are queued one at a time; state derives from outstanding
work. Authorization is a structural executor stage and cannot be removed by hooks.
@@ -176,14 +176,17 @@ commands, then applies argument- and subcommand-aware rules. Only explicitly pro
read forms are locally read-only; unknown tools, options, embedded programs, excessive
complexity, and unsupported shell syntax fail closed to a state-changing assessment.
Permission modes share all validation:
Every call has an approval policy before the run's permission mode is consulted:
- Ask for approval allows proven `locallyReadOnly`/`locallyContained` work and asks
for everything else.
- Approve for me sends other actions to Security Review; missing credentials,
- Deny refuses every permission mode while keeping the tool registered and visible
to the model; Approve runs without another approval step.
- Ask for approval sends Ask calls to the user. Approve for me sends them to Security
Review; missing credentials,
timeout, transport failure, or malformed review fails closed and is recorded.
- Full access skips action approval/review but retains host-key, secret, schema,
- Full access pre-approves Ask calls but retains host-key, secret, schema,
path, fetch, sandbox, and capability boundaries.
- Termind downgrades Approve to Ask for MCP tools carrying a destructive hint. MCP
tools otherwise follow Deny, Ask, or Approve in both acting and planning runs.
Skills are trusted procedures only because users install them through Settings;
tools, MCP servers, and fetched pages cannot write or enable them. `allowed-tools`
@@ -16,7 +16,7 @@ final class MobileMCPEditorModel {
private let discover: Discover
private var task: Task<Void, Never>?
private var generation = UUID()
private var policies: [String: MCPTool.AccessPolicy] = [:]
private var policies: [String: AgentToolDescriptor.ApprovalPolicy] = [:]
init(
ai: AIConfigurationModel, server: MCPServer,
@@ -77,7 +77,8 @@ final class MobileMCPEditorModel {
var seen = Set<String>()
draft.tools = outcome.tools.filter { seen.insert($0.id).inserted }.map {
var tool = $0
tool.accessPolicy = policies[tool.id] ?? .alwaysAsk
tool.accessPolicy = policies[tool.id] ?? .ask
tool.enforceTermindApprovalPolicy()
return tool
}
draft.negotiatedVersion = outcome.protocolVersion
+10 -8
View File
@@ -146,8 +146,8 @@ private struct MobileMCPStatusBadge: View {
Text("Disabled")
case .notConnected:
Text("Not Connected")
case .tools(let enabled, let total):
Text("\(enabled)/\(total) tools")
case .tools(let count):
Text("\(count) tools")
}
}
@@ -164,7 +164,7 @@ nonisolated enum MobileMCPServerStatus: Equatable {
case conflict
case disabled
case notConnected
case tools(enabled: Int, total: Int)
case tools(count: Int)
init(server: MCPServer, hasNamespaceConflict: Bool) {
if hasNamespaceConflict {
@@ -174,7 +174,7 @@ nonisolated enum MobileMCPServerStatus: Equatable {
} else if server.lastConnectedAt == nil {
self = .notConnected
} else {
self = .tools(enabled: server.enabledTools.count, total: server.tools.count)
self = .tools(count: server.tools.count)
}
}
}
@@ -261,14 +261,16 @@ struct MobileMCPEditor: View {
Text(tool.summary).font(.caption).foregroundStyle(.secondary).lineLimit(2)
}
Picker("Permission", selection: $tool.accessPolicy) {
Text("Disabled").tag(MCPTool.AccessPolicy.disabled)
Text("Always Ask").tag(MCPTool.AccessPolicy.alwaysAsk)
Text("Follow Permissions").tag(MCPTool.AccessPolicy.followPermissions)
Text("Deny").tag(AgentToolDescriptor.ApprovalPolicy.deny)
Text("Ask").tag(AgentToolDescriptor.ApprovalPolicy.ask)
Text("Approve")
.tag(AgentToolDescriptor.ApprovalPolicy.approve)
.disabled(!tool.allowsTermindPreapproval)
}
}
}
Text(
"Always Ask requires approval for every call. Follow Permissions uses the conversation's permission mode and safety checks."
"Deny refuses the call. Ask uses the conversation's manual or automatic approval. Approve runs without approval and is unavailable for tools marked destructive."
)
.font(.caption).foregroundStyle(.secondary)
}
+6 -7
View File
@@ -186,7 +186,7 @@ struct MobileAISettingsTests {
@Test func mcpDiscoveryUsesDraftAndPreservesPermissionsUntilSave() async throws {
var tool = MCPTool(id: "existing")
tool.accessPolicy = .disabled
tool.accessPolicy = .deny
let server = MCPServer(name: "Gateway", namespaceID: "", url: "https://original.invalid/mcp", tools: [tool])
let ai = AIConfigurationModel()
let editor = MobileMCPEditorModel(
@@ -203,10 +203,10 @@ struct MobileAISettingsTests {
editor.connect()
while editor.running { await Task.yield() }
#expect(ai.mcpServers.isEmpty)
#expect(editor.draft.tools.map(\.accessPolicy) == [.disabled, .alwaysAsk])
#expect(editor.draft.tools.map(\.accessPolicy) == [.deny, .ask])
try editor.save()
#expect(ai.mcpServer(server.id)?.namespaceID == "gateway")
#expect(ai.mcpServer(server.id)?.tools.map(\.accessPolicy) == [.disabled, .alwaysAsk])
#expect(ai.mcpServer(server.id)?.tools.map(\.accessPolicy) == [.deny, .ask])
}
@Test func mcpCancellationAndRemoteDeletionCannotRestoreResults() async {
@@ -345,12 +345,11 @@ struct MobileAISettingsTests {
server.isEnabled = true
server.lastConnectedAt = Date(timeIntervalSince1970: 1)
var disabled = MCPTool(id: "disabled")
disabled.accessPolicy = .disabled
server.tools = [MCPTool(id: "active"), disabled]
let denied = MCPTool(id: "denied", accessPolicy: .deny)
server.tools = [MCPTool(id: "active"), denied]
#expect(
MobileMCPServerStatus(server: server, hasNamespaceConflict: false)
== .tools(enabled: 1, total: 2))
== .tools(count: 2))
}
}
+1 -1
View File
@@ -197,7 +197,7 @@ struct MobileAssistantRenderingTests {
summary: "Write a file",
inputSchema: .object([:]),
target: .local,
safety: .requiresAuthorization
approvalPolicy: .ask
),
userIntent: text(
"Enable maintenance mode.", "启用维护模式。", "啟用維護模式。"),
+1 -1
View File
@@ -83,7 +83,7 @@ struct MobileAssistantTests {
summary: "Write data",
inputSchema: .object([:]),
target: .local,
safety: .requiresAuthorization
approvalPolicy: .ask
),
userIntent: "Update the requested data",
localReasons: (0..<8).map { "Reason \($0) requires confirmation." }
@@ -111,7 +111,7 @@ final class ToolAuditModel {
hostID: hostID,
hostDisplayName: hostID.flatMap(hostName),
summary: Self.summary(for: invocation.call),
risk: Self.risk(for: invocation.call, descriptor: descriptor),
risk: Self.risk(for: invocation.call),
bytesWritten: Self.bytesWritten(for: invocation.call),
startedAt: invocation.createdAt
)
@@ -210,9 +210,7 @@ final class ToolAuditModel {
return content.lengthOfBytes(using: .utf8)
}
private static func risk(
for call: AgentToolCall, descriptor: AgentToolDescriptor
) -> ExternalToolCall.Risk {
private static func risk(for call: AgentToolCall) -> ExternalToolCall.Risk {
if call.name == "ssh_exec",
let command = call.arguments.objectValue?["command"]?.stringValue
{
@@ -222,7 +220,12 @@ final class ToolAuditModel {
case .destructive: return .destructive
}
}
if descriptor.safety.isAutoAllowed { return .readOnly }
if [
"list_hosts", "get_metrics", "request_user_secret", "ssh_wait",
"sftp_list", "sftp_stat", "sftp_read",
].contains(call.name) {
return .readOnly
}
return call.name == "sftp_delete" ? .destructive : .modifiesState
}
}
@@ -245,7 +245,7 @@ struct MCPServerEditorPage: View {
} footer: {
if !server.tools.isEmpty {
Text(
"Always Ask requires approval for every call. Follow Permissions uses the conversation's permission mode and safety checks."
"Deny refuses the call. Ask uses the conversation's manual or automatic approval. Approve runs without approval and is unavailable for tools marked destructive."
)
.font(.caption)
.foregroundStyle(.secondary)
@@ -338,10 +338,7 @@ struct MCPServerEditorPage: View {
// MARK: - Tool row
/// One discovered tool, with its two switches.
///
/// "Ask First" rather than "Requires Approval": the label describes what will
/// happen to you, not the internal state.
/// One discovered tool and the local policy applied when the model calls it.
private struct ToolRow: View {
let tool: MCPTool
let serverID: MCPServer.ID
@@ -375,8 +372,10 @@ private struct ToolRow: View {
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
Picker("Permission", selection: permission) {
ForEach(MCPTool.AccessPolicy.allCases) { option in
Text(option.displayName).tag(option)
ForEach(AgentToolDescriptor.ApprovalPolicy.allCases) { option in
Text(option.displayName)
.tag(option)
.disabled(option == .approve && !tool.allowsTermindPreapproval)
}
}
.labelsHidden()
@@ -389,7 +388,7 @@ private struct ToolRow: View {
.help(Text(tool.summary.isEmpty ? qualifiedToolName : tool.summary))
}
private var permission: Binding<MCPTool.AccessPolicy> {
private var permission: Binding<AgentToolDescriptor.ApprovalPolicy> {
Binding(
get: { tool.accessPolicy },
set: { policy in
@@ -399,12 +398,12 @@ private struct ToolRow: View {
}
}
extension MCPTool.AccessPolicy {
extension AgentToolDescriptor.ApprovalPolicy {
fileprivate var displayName: LocalizedStringKey {
switch self {
case .disabled: "Disabled"
case .alwaysAsk: "Always Ask"
case .followPermissions: "Follow Permissions"
case .deny: "Deny"
case .ask: "Ask"
case .approve: "Approve"
}
}
}
+1 -1
View File
@@ -87,7 +87,7 @@ private struct MCPServerRow: View {
if server.tools.isEmpty {
CardChip(text: String(localized: "Not Connected"), symbol: "questionmark.circle")
} else {
CardChip(text: "\(server.enabledTools.count)/\(server.tools.count)", symbol: "wrench.and.screwdriver")
CardChip(text: "\(server.tools.count)", symbol: "wrench.and.screwdriver")
}
Toggle("", isOn: enabled)
@@ -365,7 +365,10 @@ actor MCPServerProtocol {
}
private static func toolJSON(_ descriptor: AgentToolDescriptor) -> AgentJSONValue {
let readOnly = descriptor.safety.isAutoAllowed
let readOnly = [
"list_hosts", "get_metrics", "request_user_secret", "ssh_wait",
"sftp_list", "sftp_stat", "sftp_read",
].contains(descriptor.name)
let destructive = ["ssh_exec", "sftp_write", "sftp_move", "sftp_chmod", "sftp_delete"]
.contains(descriptor.name)
let openWorld = !["list_hosts", "ssh_wait", "request_user_secret"]
+4 -4
View File
@@ -33,7 +33,7 @@ struct AIProviderPersistenceTests {
title: "Search", readOnlyHint: true, destructiveHint: false,
idempotentHint: true, openWorldHint: false
),
accessPolicy: .followPermissions
accessPolicy: .approve
)
let server = MCPServer(
name: "Work Gateway",
@@ -73,7 +73,7 @@ struct AIProviderPersistenceTests {
let container = try makeContainer("MCPRediscovery")
var server = MCPServer(name: "Gateway")
server.tools = [
MCPTool(id: "existing", accessPolicy: .followPermissions)
MCPTool(id: "existing", accessPolicy: .approve)
]
let store = AIConfigurationModel(
modelContext: container.mainContext, persistsSecrets: false
@@ -90,8 +90,8 @@ struct AIProviderPersistenceTests {
modelContext: container.mainContext, persistsSecrets: false
)
let tools = try #require(reloaded.mcpServer(server.id)?.tools)
#expect(tools.first { $0.id == "existing" }?.accessPolicy == .followPermissions)
#expect(tools.first { $0.id == "new" }?.accessPolicy == .alwaysAsk)
#expect(tools.first { $0.id == "existing" }?.accessPolicy == .approve)
#expect(tools.first { $0.id == "new" }?.accessPolicy == .ask)
#expect(reloaded.mcpServer(server.id)?.negotiatedVersion == "2025-06-18")
#expect(reloaded.mcpServer(server.id)?.lastConnectedAt != nil)
}
+3 -3
View File
@@ -63,7 +63,7 @@ private func gatedTool(_ name: String, log: CallLog) -> AnyAgentTool {
AnyAgentTool(
descriptor: AgentToolDescriptor(
name: name, summary: name, inputSchema: queueSchema,
target: .host, safety: .requiresAuthorization, concurrency: .parallel
target: .host, approvalPolicy: .ask, concurrency: .parallel
)
) { invocation, _ in
await log.start(name)
@@ -77,7 +77,7 @@ private func hangingTool(_ name: String, log: CallLog) -> AnyAgentTool {
AnyAgentTool(
descriptor: AgentToolDescriptor(
name: name, summary: name, inputSchema: queueSchema,
target: .local, safety: .locallyReadOnly, concurrency: .parallel
target: .local, approvalPolicy: .approve, concurrency: .parallel
)
) { invocation, _ in
await log.start(name)
@@ -203,7 +203,7 @@ struct AgentApprovalQueueTests {
invocation: invocation,
descriptor: AgentToolDescriptor(
name: "review", summary: "review", inputSchema: queueSchema,
target: .host, safety: .requiresAuthorization
target: .host, approvalPolicy: .ask
),
userIntent: "go"
)
+1 -1
View File
@@ -308,7 +308,7 @@ struct AgentBackgroundCommandTests {
#expect(wait != nil)
// It reads a buffer and a task. Nothing it does reaches the host, so
// stopping to ask permission for it would be asking about nothing.
#expect(wait?.descriptor.safety == .locallyReadOnly)
#expect(wait?.descriptor.approvalPolicy == .approve)
#expect(wait?.descriptor.concurrency == .parallel)
// No kill: closing a channel does not stop a remote process, so a tool
// by that name would promise what it cannot do.
@@ -154,7 +154,7 @@ struct AgentInterruptedRunRecoveryTests {
invocation: invocation,
descriptor: AgentToolDescriptor(
name: "ssh_exec", summary: "exec", inputSchema: .object([:]),
target: .host, safety: .requiresAuthorization
target: .host, approvalPolicy: .ask
),
state: "started", result: nil
)
+6 -6
View File
@@ -43,7 +43,7 @@ struct AgentPersistenceTests {
)
let descriptor = AgentToolDescriptor(
name: call.name, summary: "Search", inputSchema: .object([:]),
target: .network, safety: .locallyReadOnly, presentation: presentation
target: .network, approvalPolicy: .approve, presentation: presentation
)
var result = AgentToolResult(
callID: call.id,
@@ -307,7 +307,7 @@ struct AgentPersistenceTests {
)
let descriptor = AgentToolDescriptor(
name: "web_search", summary: "Search", inputSchema: .object([:]),
target: .network, safety: .locallyReadOnly
target: .network, approvalPolicy: .approve
)
try await repository.record(.toolProposed(invocation, descriptor), runID: snapshot.id)
try await repository.record(
@@ -357,7 +357,7 @@ struct AgentPersistenceTests {
invocation,
AgentToolDescriptor(
name: "ssh_exec", summary: "exec", inputSchema: .object([:]),
target: .host, safety: .requiresAuthorization
target: .host, approvalPolicy: .ask
)),
runID: snapshot.id
)
@@ -423,7 +423,7 @@ struct AgentPersistenceTests {
invocation,
AgentToolDescriptor(
name: "fetch", summary: "fetch", inputSchema: .object([:]),
target: .network, safety: .locallyReadOnly
target: .network, approvalPolicy: .approve
)),
runID: snapshot.id
)
@@ -610,7 +610,7 @@ struct AgentPersistenceTests {
invocation,
AgentToolDescriptor(
name: "ssh_exec", summary: "exec", inputSchema: .object([:]),
target: .host, safety: .requiresAuthorization
target: .host, approvalPolicy: .ask
)),
runID: invocation.runID
)
@@ -651,7 +651,7 @@ struct AgentPersistenceTests {
invocation: invocation,
descriptor: AgentToolDescriptor(
name: "list_hosts", summary: "List", inputSchema: .object([:]),
target: .local, safety: .locallyReadOnly
target: .local, approvalPolicy: .approve
),
userIntent: "List hosts"
)
+43 -20
View File
@@ -45,13 +45,14 @@ private let emptySchema = AgentJSONValue.object([
private func spyTool(
_ name: String,
safety: AgentToolDescriptor.Safety,
approvalPolicy: AgentToolDescriptor.ApprovalPolicy,
target: AgentToolDescriptor.Target = .local,
ran: RanFlag
) -> AnyAgentTool {
AnyAgentTool(
descriptor: AgentToolDescriptor(
name: name, summary: name, inputSchema: emptySchema,
target: .local, safety: safety, concurrency: .parallel
target: target, approvalPolicy: approvalPolicy, concurrency: .parallel
),
execute: { invocation, _ in
await ran.mark()
@@ -61,7 +62,7 @@ private func spyTool(
}
/// An `ssh_exec` stand-in whose preflight is the real one: the classifier
/// decides its safety, exactly as `AgentBuiltInSSHTools` does.
/// decides its approval policy, exactly as `AgentBuiltInSSHTools` does.
private func execTool(ran: RanFlag) -> AnyAgentTool {
let schema = AgentJSONValue.object([
"type": .string("object"),
@@ -72,14 +73,14 @@ private func execTool(ran: RanFlag) -> AnyAgentTool {
return AnyAgentTool(
descriptor: AgentToolDescriptor(
name: TermindAgentPolicy.sshExecToolName, summary: "exec",
inputSchema: schema, target: .host, safety: .requiresAuthorization
inputSchema: schema, target: .host, approvalPolicy: .ask
),
preflight: { invocation in
let command = invocation.call.arguments.objectValue?["command"]?.stringValue ?? ""
let safe = CommandRiskClassifier.assess(command).risk == .safe
return AgentToolPreflight(
invocation: invocation,
safety: safe ? .locallyReadOnly : .requiresAuthorization
approvalPolicy: safe ? .approve : .ask
)
},
execute: { invocation, _ in
@@ -148,7 +149,7 @@ struct AgentPlanModeTests {
func planModeRefusesAToolThatChangesSomethingOutsideTermind() async {
let ran = RanFlag()
let events = await drain(
tools: [spyTool("sftp_write", safety: .requiresAuthorization, ran: ran)],
tools: [spyTool("sftp_write", approvalPolicy: .ask, ran: ran)],
turns: callingTurns(["sftp_write"]), isPlanning: true
)
@@ -163,7 +164,7 @@ struct AgentPlanModeTests {
func theSameToolRunsOnceThePostureIsOff() async {
let ran = RanFlag()
_ = await drain(
tools: [spyTool("sftp_write", safety: .requiresAuthorization, ran: ran)],
tools: [spyTool("sftp_write", approvalPolicy: .ask, ran: ran)],
turns: callingTurns(["sftp_write"]), isPlanning: false
)
@@ -174,7 +175,7 @@ struct AgentPlanModeTests {
func readsAreUntouchedWhilePlanning() async {
let ran = RanFlag()
let events = await drain(
tools: [spyTool("sftp_read", safety: .locallyReadOnly, ran: ran)],
tools: [spyTool("sftp_read", approvalPolicy: .approve, ran: ran)],
turns: callingTurns(["sftp_read"]), isPlanning: true
)
@@ -188,7 +189,7 @@ struct AgentPlanModeTests {
func containedWritesAreAllowedSoThePlanCanBeDrafted() async {
let ran = RanFlag()
_ = await drain(
tools: [spyTool("scratch_edit", safety: .locallyContained, ran: ran)],
tools: [spyTool("scratch_edit", approvalPolicy: .approve, ran: ran)],
turns: callingTurns(["scratch_edit"]), isPlanning: true
)
@@ -235,6 +236,28 @@ struct AgentPlanModeTests {
#expect(await ran.didRun)
}
@Test
func mcpPoliciesRemainAuthoritativeWhilePlanning() async {
let asked = RanFlag()
let approved = RanFlag()
let denied = RanFlag()
let events = await drain(
tools: [
spyTool("mcp_ask", approvalPolicy: .ask, target: .mcp, ran: asked),
spyTool("mcp_approve", approvalPolicy: .approve, target: .mcp, ran: approved),
spyTool("mcp_deny", approvalPolicy: .deny, target: .mcp, ran: denied),
],
turns: callingTurns(["mcp_ask", "mcp_approve", "mcp_deny"]),
isPlanning: true
)
#expect(await asked.didRun)
#expect(await approved.didRun)
#expect(await denied.didRun == false)
#expect(results(events).count == 3)
#expect(results(events).contains { $0.isApprovalPolicyDenied })
}
@Test
@MainActor
func sshExecPreflightUsesClassifierForApprovalAndConcurrency() async throws {
@@ -268,12 +291,12 @@ struct AgentPlanModeTests {
}
let read = try await tool.preflight(invocation("systemctl status nginx"))
#expect(read.safety == .locallyReadOnly)
#expect(read.approvalPolicy == .approve)
#expect(read.concurrency == .parallel)
#expect(read.reasons.isEmpty)
let write = try await tool.preflight(invocation("systemctl restart nginx"))
#expect(write.safety == .requiresAuthorization)
#expect(write.approvalPolicy == .ask)
#expect(write.concurrency == .sequential)
#expect(!write.reasons.isEmpty)
}
@@ -318,21 +341,21 @@ struct AgentPlanModeTests {
func askingTheUserIsNotBlockedWhilePlanning() async {
let ran = RanFlag()
_ = await drain(
tools: [spyTool("request_user_input", safety: .locallyReadOnly, ran: ran)],
tools: [spyTool("request_user_input", approvalPolicy: .approve, ran: ran)],
turns: callingTurns(["request_user_input"]), isPlanning: true
)
#expect(await ran.didRun)
}
/// Blocked by name rather than by safety: `manage_tasks` changes nothing, so
/// the safety gate has no reason to stop it, and it still has no business
/// Blocked by name rather than by approval policy: `manage_tasks` changes
/// nothing, so the approval gate has no reason to stop it, and it still has no business
/// running before anything has been agreed to.
@Test
func theTaskListIsRefusedWhilePlanningEvenThoughItIsReadOnly() async {
let ran = RanFlag()
let events = await drain(
tools: [spyTool("manage_tasks", safety: .locallyReadOnly, ran: ran)],
tools: [spyTool("manage_tasks", approvalPolicy: .approve, ran: ran)],
turns: callingTurns(["manage_tasks"]), isPlanning: true
)
@@ -344,7 +367,7 @@ struct AgentPlanModeTests {
func theTaskListRunsOutsidePlanMode() async {
let ran = RanFlag()
_ = await drain(
tools: [spyTool("manage_tasks", safety: .locallyReadOnly, ran: ran)],
tools: [spyTool("manage_tasks", approvalPolicy: .approve, ran: ran)],
turns: callingTurns(["manage_tasks"]), isPlanning: false
)
@@ -359,8 +382,8 @@ struct AgentPlanModeTests {
let ran = RanFlag()
let events = await drain(
tools: [
spyTool("sftp_read", safety: .locallyReadOnly, ran: ran),
spyTool("sftp_write", safety: .requiresAuthorization, ran: ran),
spyTool("sftp_read", approvalPolicy: .approve, ran: ran),
spyTool("sftp_write", approvalPolicy: .ask, ran: ran),
],
turns: callingTurns(["sftp_read", "sftp_write"]), isPlanning: true
)
@@ -377,7 +400,7 @@ struct AgentPlanModeTests {
tools: [
spyTool(
AgentPlanModeHook.presentPlanToolName,
safety: .locallyReadOnly, ran: ran
approvalPolicy: .approve, ran: ran
)
],
turns: callingTurns([AgentPlanModeHook.presentPlanToolName]),
@@ -401,7 +424,7 @@ struct AgentPlanModeTests {
tools: [
spyTool(
AgentPlanModeHook.presentPlanToolName,
safety: .locallyReadOnly, ran: ran
approvalPolicy: .approve, ran: ran
)
],
turns: callingTurns([AgentPlanModeHook.presentPlanToolName]),
+27 -33
View File
@@ -231,7 +231,7 @@ struct AgentRuntimeTests {
"type": .string("object"), "properties": .object([:]),
"required": .array([]), "additionalProperties": .bool(false),
]),
target: .local, safety: .locallyReadOnly
target: .local, approvalPolicy: .approve
)
) { invocation, _ in
AgentToolResult(callID: invocation.call.id, content: "ok")
@@ -313,7 +313,7 @@ struct AgentRuntimeTests {
descriptor: AgentToolDescriptor(
name: name, summary: name,
inputSchema: .object(["type": .string("object")]),
target: .local, safety: .locallyReadOnly
target: .local, approvalPolicy: .approve
)
) { invocation, _ in
await recorder.append(name)
@@ -341,7 +341,7 @@ struct AgentRuntimeTests {
descriptor: AgentToolDescriptor(
name: "wait", summary: "wait",
inputSchema: .object(["type": .string("object")]),
target: .local, safety: .locallyReadOnly
target: .local, approvalPolicy: .approve
)
) { invocation, _ in
await gate.beginAndWait()
@@ -501,7 +501,7 @@ struct AgentRuntimeTests {
"required": .array([.string("path")]),
"additionalProperties": .bool(false),
]),
target: .host, safety: .requiresAuthorization
target: .host, approvalPolicy: .ask
)
) { invocation, _ in
await executions.increment()
@@ -542,7 +542,7 @@ struct AgentRuntimeTests {
"type": .string("object"), "properties": .object([:]),
"required": .array([]), "additionalProperties": .bool(false),
]),
target: .host, safety: .locallyReadOnly
target: .host, approvalPolicy: .approve
)
) { invocation, _ in
#expect(invocation.call.arguments == .object([:]))
@@ -581,7 +581,7 @@ struct AgentRuntimeTests {
"type": .string("object"), "properties": .object([:]),
"required": .array([]), "additionalProperties": .bool(false),
]),
target: .host, safety: .locallyReadOnly
target: .host, approvalPolicy: .approve
)
) { invocation, _ in
#expect(invocation.call.arguments == .object([:]))
@@ -604,7 +604,7 @@ struct AgentRuntimeTests {
func approvalModesRouteOnlyApproveForMeToReviewer() async {
let descriptor = AgentToolDescriptor(
name: "write", summary: "write", inputSchema: .object([:]),
target: .host, safety: .requiresAuthorization
target: .host, approvalPolicy: .ask
)
let invocation = AgentToolInvocation(
runID: UUID(), call: AgentToolCall(id: "1", name: "write", arguments: .object([:]))
@@ -654,20 +654,20 @@ struct AgentRuntimeTests {
"type": .string("object"), "properties": .object([:]),
"required": .array([]), "additionalProperties": .bool(false),
]),
target: .network, safety: .locallyReadOnly
target: .network, approvalPolicy: .approve
)
let tool = AnyAgentTool(
descriptor: descriptor,
preflight: { invocation in
AgentToolPreflight(
invocation: invocation, safety: .locallyReadOnly,
invocation: invocation, approvalPolicy: .approve,
executionMetadata: ["scope": .string("public")]
)
},
execute: { invocation, context in
#expect(context.preflightMetadata["scope"] == .string("public"))
var escalation = descriptor
escalation.safety = .requiresAuthorization
escalation.approvalPolicy = .ask
let decision = await context.authorize(
AgentApprovalRequest(
invocation: invocation, descriptor: escalation,
@@ -709,7 +709,7 @@ struct AgentRuntimeTests {
func reviewerFailureAndCriticalRiskFailClosed() async {
let descriptor = AgentToolDescriptor(
name: "delete", summary: "delete", inputSchema: .object([:]),
target: .host, safety: .requiresAuthorization
target: .host, approvalPolicy: .ask
)
let request = AgentApprovalRequest(
invocation: AgentToolInvocation(
@@ -750,7 +750,7 @@ struct AgentRuntimeTests {
),
descriptor: AgentToolDescriptor(
name: "write", summary: "write", inputSchema: .object([:]),
target: .host, safety: .requiresAuthorization
target: .host, approvalPolicy: .ask
),
userIntent: "change it"
)
@@ -826,7 +826,7 @@ struct AgentRuntimeTests {
"type": .string("object"), "properties": .object([:]),
"required": .array([]), "additionalProperties": .bool(false),
]),
target: .host, safety: .locallyReadOnly
target: .host, approvalPolicy: .approve
)
) { invocation, _ in
await executed.append(origin)
@@ -873,7 +873,7 @@ struct AgentRuntimeTests {
"type": .string("object"), "properties": .object([:]),
"required": .array([]), "additionalProperties": .bool(false),
]),
target: .local, safety: .locallyReadOnly
target: .local, approvalPolicy: .approve
)
) { invocation, _ in
AgentToolResult(callID: invocation.call.id, content: "ok")
@@ -917,30 +917,24 @@ struct AgentRuntimeTests {
}
@Test
func mcpToolAccessPoliciesControlExposureAndApproval() throws {
func mcpToolAccessPoliciesControlApprovalWithoutHidingTools() throws {
var server = MCPServer(name: "Gateway")
var destructive = MCPTool(id: "destructive", accessPolicy: .ask)
destructive.annotations.destructiveHint = true
server.tools = [
MCPTool(id: "disabled", accessPolicy: .disabled),
MCPTool(id: "always-ask", accessPolicy: .alwaysAsk),
MCPTool(id: "follow-permissions", accessPolicy: .followPermissions),
MCPTool(id: "denied", accessPolicy: .deny),
MCPTool(id: "asked", accessPolicy: .ask),
MCPTool(id: "approved", accessPolicy: .approve),
destructive,
]
let descriptors = AgentMCPTools.make(servers: [(server: server, bearerToken: nil)])
.map(\.descriptor)
#expect(descriptors.count == 2)
#expect(!descriptors.contains { $0.name == "disabled" })
let alwaysAsk = try #require(descriptors.first { $0.name == "always-ask" })
#expect(alwaysAsk.safety == .requiresAuthorization)
#expect(alwaysAsk.alwaysAskUser)
#expect(alwaysAsk.concurrency == .sequential)
let followPermissions = try #require(
descriptors.first { $0.name == "follow-permissions" }
)
#expect(followPermissions.safety == .requiresAuthorization)
#expect(!followPermissions.alwaysAskUser)
#expect(followPermissions.concurrency == .parallel)
#expect(descriptors.count == 4)
#expect(descriptors.first { $0.name == "denied" }?.approvalPolicy == .deny)
#expect(descriptors.first { $0.name == "asked" }?.approvalPolicy == .ask)
#expect(descriptors.first { $0.name == "approved" }?.approvalPolicy == .approve)
#expect(descriptors.first { $0.name == "destructive" }?.concurrency == .sequential)
}
@Test
@@ -984,7 +978,7 @@ private func hangingTool(_ gate: AsyncGate, onCancel: Recorder? = nil) -> AnyAge
"type": .string("object"), "properties": .object([:]),
"required": .array([]), "additionalProperties": .bool(false),
]),
target: .local, safety: .locallyReadOnly, concurrency: .parallel
target: .local, approvalPolicy: .approve, concurrency: .parallel
)
) { invocation, _ in
await gate.begin()
+21 -39
View File
@@ -472,12 +472,10 @@ struct AgentScratchTests {
}
}
// MARK: - Safety
// MARK: - Approval policy
/// `.locallyContained` has to mean the same thing in every mode, or it is just
/// `.requiresAuthorization` with extra steps.
@Test
func containedActionsSkipTheGateInEveryPermissionMode() async {
func approvedActionsSkipTheGateInEveryPermissionMode() async {
for mode in AgentPermissionMode.allCases {
let reviewer = CountingSecurityReviewer()
let broker = AgentApprovalBroker(
@@ -485,24 +483,22 @@ struct AgentScratchTests {
manualApproval: { _ in .deny("a person was asked") }
)
let allowed = await broker.authorize(
Fixture.request(safety: .locallyContained), mode: mode
Fixture.request(approvalPolicy: .approve), mode: mode
)
#expect(allowed == .allow, "\(mode) gated a contained action")
#expect(await reviewer.count == 0, "\(mode) sent a contained action to review")
#expect(allowed == .allow, "\(mode) gated an approved action")
#expect(await reviewer.count == 0, "\(mode) sent an approved action to review")
}
}
/// The other half of the claim: adding a third case widened nothing. An action
/// that needs authorization still reaches whichever gate the mode nominates.
@Test
func remoteWritesStillReachTheGate() async {
func askedRemoteWritesReachTheSelectedGate() async {
let asked = CountingSecurityReviewer()
let manual = AgentApprovalBroker(
reviewer: asked, manualApproval: { _ in .deny("denied by the user") }
)
#expect(
await manual.authorize(
Fixture.request(safety: .requiresAuthorization), mode: .askForApproval
Fixture.request(approvalPolicy: .ask), mode: .askForApproval
) == .deny("denied by the user")
)
// Ask for approval asks the person, not the reviewer.
@@ -515,19 +511,12 @@ struct AgentScratchTests {
)
#expect(
await automatic.authorize(
Fixture.request(safety: .requiresAuthorization), mode: .approveForMe
Fixture.request(approvalPolicy: .ask), mode: .approveForMe
) == .allow
)
#expect(await reviewed.count == 1)
}
@Test
func autoAllowIsAPropertyOfTheCaseAndNotOfTheCallSite() {
#expect(AgentToolDescriptor.Safety.locallyReadOnly.isAutoAllowed)
#expect(AgentToolDescriptor.Safety.locallyContained.isAutoAllowed)
#expect(!AgentToolDescriptor.Safety.requiresAuthorization.isAutoAllowed)
}
// MARK: - Registry
/// A card records the presenter ID it was written with, and older cards record
@@ -573,19 +562,10 @@ struct AgentScratchTests {
"scratch_copy", "scratch_delete", "scratch_diff", "scratch_list", "scratch_move",
"scratch_read", "scratch_replace", "scratch_search", "scratch_write",
])
// Every purely local tool is parallel, and only the ones that change
// something claim to.
// Every purely local scratch tool is parallel and pre-approved.
#expect(local.allSatisfy { $0.descriptor.concurrency == .parallel })
#expect(local.allSatisfy { $0.descriptor.target == .local })
#expect(
local.filter { $0.descriptor.safety == .locallyContained }
.map(\.descriptor.name).sorted()
== [
"scratch_copy", "scratch_delete", "scratch_move", "scratch_replace",
"scratch_write",
]
)
#expect(!local.contains { $0.descriptor.safety == .requiresAuthorization })
#expect(local.allSatisfy { $0.descriptor.approvalPolicy == .approve })
let search = try #require(local.first { $0.descriptor.name == "scratch_search" })
let properties = search.descriptor.inputSchema.objectValue?["properties"]?.objectValue
#expect(
@@ -604,12 +584,12 @@ struct AgentScratchTests {
#expect(transfer.count == 2)
#expect(transfer.allSatisfy { $0.descriptor.target == .host })
#expect(
transfer.first { $0.descriptor.name == "scratch_push" }?.descriptor.safety
== .requiresAuthorization
transfer.first { $0.descriptor.name == "scratch_push" }?.descriptor.approvalPolicy
== .ask
)
#expect(
transfer.first { $0.descriptor.name == "scratch_pull" }?.descriptor.safety
== .locallyContained
transfer.first { $0.descriptor.name == "scratch_pull" }?.descriptor.approvalPolicy
== .approve
)
// A pull is a remote read landing in contained storage and may overlap
// anything; a push changes a host and takes its turn serial.
@@ -628,7 +608,7 @@ struct AgentScratchTests {
let withWeb = Fixture.scratchTools(workspace: workspace, web: Fixture.web())
let fetch = try #require(withWeb.first { $0.descriptor.name == "scratch_fetch" })
#expect(fetch.descriptor.target == .network)
#expect(fetch.descriptor.safety == .locallyContained)
#expect(fetch.descriptor.approvalPolicy == .approve)
#expect(fetch.descriptor.concurrency == .parallel)
}
@@ -644,7 +624,7 @@ struct AgentScratchTests {
let properties = fetch?.inputSchema.objectValue?["properties"]?.objectValue ?? [:]
#expect(properties["scratch_path"] == nil)
#expect(properties.keys.sorted() == ["format", "url"])
#expect(fetch?.safety == .locallyReadOnly)
#expect(fetch?.approvalPolicy == .approve)
#expect(!tools.contains { $0.descriptor.name.hasPrefix("scratch_") })
}
@@ -780,7 +760,7 @@ struct AgentScratchTests {
let preflight = try await tool.preflight(invocation)
let reasons = preflight.reasons.joined(separator: "\n")
#expect(preflight.safety == .requiresAuthorization)
#expect(preflight.approvalPolicy == .ask)
#expect(preflight.invocation.targetHostID == Fixture.hostID)
#expect(reasons.contains("app.conf"))
#expect(reasons.contains("\(body.utf8.count) bytes"))
@@ -1126,13 +1106,15 @@ private enum Fixture {
)
}
static func request(safety: AgentToolDescriptor.Safety) -> AgentApprovalRequest {
static func request(
approvalPolicy: AgentToolDescriptor.ApprovalPolicy
) -> AgentApprovalRequest {
AgentApprovalRequest(
invocation: invocation("t", [:]),
descriptor: AgentToolDescriptor(
name: "t", summary: "t",
inputSchema: .object(["type": .string("object")]),
target: .local, safety: safety
target: .local, approvalPolicy: approvalPolicy
),
userIntent: "do the thing"
)
+1 -3
View File
@@ -328,9 +328,7 @@ struct LoadSkillToolTests {
@Test
func readsNothingAndSoNeedsNoApproval() {
let descriptor = LoadSkillTool(skills: catalog).descriptor
#expect(descriptor.safety == .locallyReadOnly)
#expect(descriptor.safety.isAutoAllowed)
#expect(descriptor.safety.isAllowedWhilePlanning)
#expect(descriptor.approvalPolicy == .approve)
#expect(descriptor.concurrency == .parallel)
#expect(descriptor.target == .local)
}
+3 -3
View File
@@ -37,7 +37,7 @@ struct AgentToolPresentationTests {
descriptor: AgentToolDescriptor(
name: "ssh_exec", summary: "Execute over SSH",
inputSchema: .object([:]), target: .host,
safety: .requiresAuthorization,
approvalPolicy: .ask,
presentation: .init(
symbol: "terminal",
activity: .semanticArgument(key: "command", fallback: .sshCommand),
@@ -89,7 +89,7 @@ struct AgentToolPresentationTests {
descriptor: AgentToolDescriptor(
name: "remote_tool", summary: "Remote tool",
inputSchema: .object([:]), target: .mcp,
safety: .requiresAuthorization
approvalPolicy: .ask
),
userIntent: "Run it"
)
@@ -862,7 +862,7 @@ struct AgentToolPresentationTests {
descriptor: AgentToolDescriptor(
name: name, summary: name,
inputSchema: .object(["type": .string("object")]),
target: target, safety: .locallyReadOnly, presentation: presentation
target: target, approvalPolicy: .approve, presentation: presentation
)
)
}
+2 -2
View File
@@ -65,7 +65,7 @@ struct AgentToolProgressTests {
let tool = AnyAgentTool(
descriptor: AgentToolDescriptor(
name: "slow", summary: "slow", inputSchema: emptySchema,
target: .local, safety: .locallyReadOnly, concurrency: .parallel
target: .local, approvalPolicy: .approve, concurrency: .parallel
)
) { invocation, context in
context.report(AgentToolResult(callID: invocation.call.id, content: "10 of 20"))
@@ -105,7 +105,7 @@ struct AgentToolProgressTests {
)
let descriptor = AgentToolDescriptor(
name: "slow", summary: "slow", inputSchema: emptySchema,
target: .local, safety: .locallyReadOnly
target: .local, approvalPolicy: .approve
)
conversation.handle(.toolProposed(invocation, descriptor))
conversation.handle(.toolStarted(invocation))
+18 -18
View File
@@ -385,7 +385,7 @@ struct ChatComposerStateTests {
invocation,
AgentToolDescriptor(
name: "lookup", summary: "lookup", inputSchema: .object([:]),
target: .local, safety: .locallyReadOnly
target: .local, approvalPolicy: .approve
)))
conversation.handle(.toolStarted(invocation))
@@ -425,7 +425,7 @@ struct ChatComposerStateTests {
invocation,
AgentToolDescriptor(
name: "lookup", summary: "lookup", inputSchema: .object([:]),
target: .local, safety: .locallyReadOnly
target: .local, approvalPolicy: .approve
)))
let after = try #require(conversation.workGroups.first)
@@ -466,7 +466,7 @@ struct ChatComposerStateTests {
invocation,
AgentToolDescriptor(
name: "lookup", summary: "lookup", inputSchema: .object([:]),
target: .local, safety: .locallyReadOnly
target: .local, approvalPolicy: .approve
)))
let next = AgentTranscriptMessage(role: .assistant)
@@ -568,7 +568,7 @@ struct ChatComposerStateTests {
invocation,
AgentToolDescriptor(
name: "lookup", summary: "lookup", inputSchema: .object([:]),
target: .local, safety: .locallyReadOnly
target: .local, approvalPolicy: .approve
)))
conversation.handle(
.toolFinished(
@@ -755,7 +755,7 @@ struct ChatComposerStateTests {
"required": .array([.string("query")]),
"additionalProperties": .bool(false),
]),
target: .local, safety: .locallyReadOnly
target: .local, approvalPolicy: .approve
)
) { invocation, _ in
#expect(invocation.call.arguments == .object(["query": .string("hosts")]))
@@ -809,7 +809,7 @@ struct ChatComposerStateTests {
"required": .array([.string("query")]),
"additionalProperties": .bool(false),
]),
target: .local, safety: .locallyReadOnly
target: .local, approvalPolicy: .approve
)
) { invocation, _ in
AgentToolResult(callID: invocation.call.id, content: "host-a")
@@ -857,7 +857,7 @@ struct ChatComposerStateTests {
"type": .string("object"), "properties": .object([:]),
"required": .array([]), "additionalProperties": .bool(false),
]),
target: .local, safety: .locallyReadOnly
target: .local, approvalPolicy: .approve
)
) { invocation, _ in
await probe.markStarted()
@@ -925,7 +925,7 @@ struct ChatComposerStateTests {
"type": .string("object"), "properties": .object([:]),
"required": .array([]), "additionalProperties": .bool(false),
]),
target: .local, safety: .locallyReadOnly
target: .local, approvalPolicy: .approve
)
) { invocation, _ in
AgentToolResult(callID: invocation.call.id, content: "host-a")
@@ -970,7 +970,7 @@ struct ChatComposerStateTests {
)))
let descriptor = AgentToolDescriptor(
name: "lookup", summary: "lookup", inputSchema: .object([:]),
target: .local, safety: .locallyReadOnly
target: .local, approvalPolicy: .approve
)
let source = AgentTranscriptMessage(
role: .assistant,
@@ -1042,7 +1042,7 @@ struct ChatComposerStateTests {
conversation.handle(.messageStarted(source))
let descriptor = AgentToolDescriptor(
name: "web_search", summary: "search", inputSchema: .object([:]),
target: .network, safety: .locallyReadOnly
target: .network, approvalPolicy: .approve
)
let invocations = ["first", "second"].map { id in
AgentToolInvocation(
@@ -1106,7 +1106,7 @@ struct ChatComposerStateTests {
let descriptor = AgentToolDescriptor(
name: "lookup", summary: "lookup", inputSchema: .object([:]),
target: .local, safety: .locallyReadOnly
target: .local, approvalPolicy: .approve
)
let invocations = ["first", "second"].enumerated().map { offset, callID in
AgentToolInvocation(
@@ -1179,7 +1179,7 @@ struct ChatComposerStateTests {
let descriptor = AgentToolDescriptor(
name: "lookup", summary: "lookup", inputSchema: .object([:]),
target: .local, safety: .locallyReadOnly
target: .local, approvalPolicy: .approve
)
for (offset, call) in source.toolCalls.enumerated() {
conversation.handle(
@@ -1399,7 +1399,7 @@ struct ChatComposerStateTests {
"type": .string("object"), "properties": .object([:]),
"required": .array([]), "additionalProperties": .bool(false),
]),
target: .local, safety: .locallyReadOnly
target: .local, approvalPolicy: .approve
)
) { invocation, _ in
AgentToolResult(callID: invocation.call.id, content: "ok")
@@ -1460,7 +1460,7 @@ struct ChatComposerStateTests {
"type": .string("object"), "properties": .object([:]),
"required": .array([]), "additionalProperties": .bool(false),
]),
target: .local, safety: .locallyReadOnly
target: .local, approvalPolicy: .approve
)
) { invocation, _ in
AgentToolResult(callID: invocation.call.id, content: "ok")
@@ -1513,7 +1513,7 @@ struct ChatComposerStateTests {
)))
let lookup = AgentToolDescriptor(
name: "lookup", summary: "lookup", inputSchema: .object([:]),
target: .local, safety: .locallyReadOnly
target: .local, approvalPolicy: .approve
)
let suggestion = AnyAgentTool(TerminalSuggestTool()).descriptor
#expect(suggestion.presentation?.groupsWithAdjacentTools == false)
@@ -1701,7 +1701,7 @@ struct ChatComposerStateTests {
invocation,
AgentToolDescriptor(
name: "web_search", summary: "Provider search",
inputSchema: .object([:]), target: .network, safety: .locallyReadOnly
inputSchema: .object([:]), target: .network, approvalPolicy: .approve
)))
conversation.handle(
.toolFinished(
@@ -2144,7 +2144,7 @@ struct ChatComposerStateTests {
invocation,
AgentToolDescriptor(
name: "lookup", summary: "Lookup", inputSchema: .object([:]),
target: .local, safety: .locallyReadOnly
target: .local, approvalPolicy: .approve
)))
conversation.handle(
.toolFinished(
@@ -2197,7 +2197,7 @@ struct ChatComposerStateTests {
invocation,
AgentToolDescriptor(
name: "lookup", summary: "Lookup", inputSchema: .object([:]),
target: .local, safety: .locallyReadOnly
target: .local, approvalPolicy: .approve
)))
#expect(conversation.workGroups.count == 1)
+1 -1
View File
@@ -516,7 +516,7 @@ private func descriptor(name: String, readOnly: Bool) -> AgentToolDescriptor {
AgentToolDescriptor(
name: name, summary: name,
inputSchema: .object(["type": .string("object")]), target: .host,
safety: readOnly ? .locallyReadOnly : .requiresAuthorization
approvalPolicy: readOnly ? .approve : .ask
)
}
+1 -1
View File
@@ -173,7 +173,7 @@ struct GetMetricsToolTests {
let invocation = AgentToolInvocation(runID: UUID(), call: call)
let checked = try await tool.preflight(invocation)
#expect(checked.invocation.targetHostID == host.id)
#expect(tool.descriptor.safety == .locallyReadOnly)
#expect(tool.descriptor.approvalPolicy == .approve)
#expect(tool.descriptor.concurrency == .parallel)
let decision = await AgentPlanModeHook(isPlanning: true).willExecute(
AgentToolCallContext(
+9
View File
@@ -57,6 +57,15 @@ struct MCPServerSessionTests {
try responseObject(listed)["result"]?.objectValue?["tools"]?.arrayValue
)
#expect(tools.count == 13)
let readOnly = Set([
"list_hosts", "get_metrics", "request_user_secret", "ssh_wait",
"sftp_list", "sftp_stat", "sftp_read",
])
for tool in tools {
let name = try #require(tool.objectValue?["name"]?.stringValue)
let hint = tool.objectValue?["annotations"]?.objectValue?["readOnlyHint"]
#expect(hint == .bool(readOnly.contains(name)), "\(name)")
}
}
@Test
+1 -1
View File
@@ -111,7 +111,7 @@ struct ScrubberWebClientTests {
"url": .string("http://192.168.1.1/admin")
])
let preflight = try await FetchTool(web: fixtureClient()).preflight(invocation)
#expect(preflight.safety == .locallyReadOnly)
#expect(preflight.approvalPolicy == .approve)
#expect(preflight.reasons.isEmpty)
}
+28 -5
View File
@@ -193,7 +193,7 @@ struct SettingsNavigationTests {
@Test
func mcpToolAccessPolicyIsAtomicAndSurvivesRediscovery() throws {
var existingTool = MCPTool(id: "search")
existingTool.accessPolicy = .disabled
existingTool.accessPolicy = .deny
var server = MCPServer(name: "Gateway")
server.tools = [existingTool]
let store = AIConfigurationModel(mcpServers: [server], persistsSecrets: false)
@@ -204,13 +204,36 @@ struct SettingsNavigationTests {
)
let rediscovered = try #require(store.mcpServer(server.id))
#expect(rediscovered.tools.first { $0.id == "search" }?.accessPolicy == .disabled)
#expect(rediscovered.tools.first { $0.id == "new-tool" }?.accessPolicy == .alwaysAsk)
#expect(rediscovered.tools.first { $0.id == "search" }?.accessPolicy == .deny)
#expect(rediscovered.tools.first { $0.id == "new-tool" }?.accessPolicy == .ask)
store.setToolAccessPolicy(.followPermissions, tool: "search", on: server.id)
store.setToolAccessPolicy(.approve, tool: "search", on: server.id)
#expect(
store.mcpServer(server.id)?.tools.first { $0.id == "search" }?.accessPolicy
== .followPermissions
== .approve
)
}
@Test
func destructiveMCPToolsCannotRemainApprovedAndDeniedToolsStayAvailable() throws {
var destructive = MCPTool(id: "destroy", accessPolicy: .approve)
destructive.annotations.destructiveHint = true
let denied = MCPTool(id: "denied", accessPolicy: .deny)
let server = MCPServer(name: "Gateway", tools: [destructive, denied])
let store = AIConfigurationModel(mcpServers: [server], persistsSecrets: false)
let normalized = try #require(store.mcpServer(server.id))
#expect(!normalized.tools[0].allowsTermindPreapproval)
#expect(normalized.tools[0].accessPolicy == .ask)
#expect(store.availableTools.map(\.tool.id).contains("denied"))
store.setToolAccessPolicy(.approve, tool: "destroy", on: server.id)
#expect(store.mcpServer(server.id)?.tools[0].accessPolicy == .ask)
var newlyDestructive = MCPTool(id: "denied", accessPolicy: .ask)
newlyDestructive.annotations.destructiveHint = true
store.setToolAccessPolicy(.approve, tool: "denied", on: server.id)
store.setTools([newlyDestructive], version: "2025-06-18", for: server.id)
#expect(store.mcpServer(server.id)?.tools[0].accessPolicy == .ask)
}
}
+1 -1
View File
@@ -964,7 +964,7 @@ struct TranscriptListViewBridgeTests {
invocation: invocation,
descriptor: AgentToolDescriptor(
name: "lookup", summary: "lookup", inputSchema: .object([:]),
target: .local, safety: .locallyReadOnly
target: .local, approvalPolicy: .approve
)
)
card.state = .running
+4 -4
View File
@@ -18,8 +18,8 @@ struct WebSearchTests {
let tool = WebSearchTool(web: stubClient())
#expect(tool.descriptor.name == "web_search")
#expect(tool.descriptor.target == .network)
#expect(tool.descriptor.safety == .locallyReadOnly)
#expect(try await tool.preflight(invocation(query: "swift")).safety == .locallyReadOnly)
#expect(tool.descriptor.approvalPolicy == .approve)
#expect(try await tool.preflight(invocation(query: "swift")).approvalPolicy == .approve)
await #expect(throws: AgentToolError.self) {
try await tool.preflight(
@@ -73,7 +73,7 @@ struct WebSearchTests {
let descriptor = AgentToolDescriptor(
name: "web_search", summary: "Web search run by the model provider.",
inputSchema: .object(["type": .string("object")]),
target: .network, safety: .locallyReadOnly
target: .network, approvalPolicy: .approve
)
conversation.handle(.toolProposed(invocation, descriptor))
conversation.handle(
@@ -96,7 +96,7 @@ struct WebSearchTests {
let descriptor = AgentToolDescriptor(
name: "web_search", summary: "Search the public web.",
inputSchema: .object(["type": .string("object")]),
target: .network, safety: .locallyReadOnly
target: .network, approvalPolicy: .approve
)
conversation.handle(.toolProposed(invocation, descriptor))
conversation.handle(
+1 -1
View File
@@ -2513,7 +2513,7 @@
repositoryURL = "https://github.com/AkinoKaede/AgentKit.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 0.6.4;
minimumVersion = 0.7.0;
};
};
/* End XCRemoteSwiftPackageReference section */
@@ -6,8 +6,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/AkinoKaede/AgentKit.git",
"state" : {
"revision" : "8c1a72de2cc53675d34040aaa5f3ade61dfdefd1",
"version" : "0.6.4"
"revision" : "131f0bdf61a74245383331a15d871c1cfb6f5896",
"version" : "0.7.0"
}
},
{