:会话持久化与安全防线)
会话持久化SessionStoreAgent Loop 每次运行会产生一组 messages 数组。如果不保存进程退出就没了。SessionStore 负责把这些对话历史持久化到磁盘下次启动时恢复。SessionStore 是什么SessionStore是一个actor所有方法都需要await调用。默认把会话存在~/.open-agent-sdk/sessions/目录下每个 session 一个子目录里面放一个transcript.json。let sessionStore SessionStore() // 默认路径 let sessionStore SessionStore(sessionsDir: /custom/path) // 自定义路径五个核心操作SessionStore 提供五个核心方法覆盖会话的完整生命周期。save— 保存会话。把 messages 数组和元数据序列化成 JSON 写入磁盘try await sessionStore.save( sessionId: my-session, messages: messages, metadata: PartialSessionMetadata( cwd: /project, model: claude-sonnet-4-6, summary: 代码分析会话, tag: analysis, firstPrompt: 分析项目结构 ) )存储结构长这样~/.open-agent-sdk/sessions/ my-session/ transcript.json // { metadata: {...}, messages: [...] }文件权限是0600目录权限是0700——只有当前用户能读写。每次 save 会保留第一次创建时的createdAt时间戳只更新updatedAt。load— 加载会话。从磁盘读取 transcript.json反序列化为SessionDataif let data try await sessionStore.load(sessionId: my-session) { print(Messages: \(data.metadata.messageCount)) print(Model: \(data.metadata.model)) // data.messages 是 [[String: Any]] 数组 }load 支持分页参数limit和offset不需要加载全部消息时可以只取尾部// 只加载最近 50 条消息 let recent try await sessionStore.load(sessionId: my-session, limit: 50, offset: nil)list— 列出所有会话按updatedAt降序排列最近的在前let sessions try await sessionStore.list(limit: 10) for session in sessions { print(\(session.id) — \(session.summary ?? (无标题)) [\(session.messageCount) 条消息]) }SessionMetadata包含 id、cwd、model、createdAt、updatedAt、messageCount以及可选的 summary、tag、firstPrompt、gitBranch、fileSize。fork— 分叉会话。从已有会话复制消息到新 session可以指定截断点// 完整复制 let newId try await sessionStore.fork(sourceSessionId: my-session) // 只复制前 10 条消息 let truncatedId try await sessionStore.fork( sourceSessionId: my-session, upToMessageIndex: 10 ) // 指定新 session ID let customId try await sessionStore.fork( sourceSessionId: my-session, newSessionId: forked-session )delete— 删除整个会话目录let deleted try await sessionStore.delete(sessionId: my-session)此外还有rename改标题和tag打标签两个辅助方法。会话恢复的三种模式把 SessionStore 注入 Agent 后SDK 提供三种恢复策略1. 指定 sessionId 恢复最直接的方式给定一个 session IDAgent 启动时从 SessionStore 加载历史消息追加到 messages 数组前面let agent createAgent(options: AgentOptions( apiKey: apiKey, model: claude-sonnet-4-6, sessionStore: sessionStore, sessionId: my-session // 指定恢复哪个 session ))2. continueRecentSession — 自动接续最近的会话不知道 session ID 时让 SDK 自动找最近的一个let agent createAgent(options: AgentOptions( apiKey: apiKey, model: claude-sonnet-4-6, sessionStore: sessionStore, continueRecentSession: true // 自动加载最近的 session ))内部实现是调sessionStore.list()取第一个已按 updatedAt 降序排列把它的 ID 作为恢复目标。3. forkSession resumeSessionAt — 分叉并截断在已有会话的基础上分叉一个新分支还可以截断到指定消息let agent createAgent(options: AgentOptions( apiKey: apiKey, model: claude-sonnet-4-6, sessionStore: sessionStore, sessionId: my-session, forkSession: true, // 复制到新 session resumeSessionAt: msg-uuid-123 // 截断到这条消息 ))SDK 内部的解析顺序是先continueRecentSession确定 session ID再forkSession创建分叉再resumeSessionAt截断历史。这三个选项可以独立使用也可以组合。SessionStore 的安全细节SessionStore 在 session ID 校验上做了路径遍历防护private func validateSessionId(_ sessionId: String) throws { guard !sessionId.isEmpty else { throw SDKError.sessionError(message: Session ID must not be empty) } let forbidden [/, \\, ..] for component in forbidden { if sessionId.contains(component) { throw SDKError.sessionError(message: Session ID contains invalid character: \(component)) } } }session ID 里不能包含/、\、..——防止攻击者通过构造 ID 来读写预期之外的路径。二、权限控制PermissionPolicy会话持久化解决了记住的问题权限控制解决的是能做什么的问题。六种 PermissionModeSDK 定义了 6 种权限模式模式行为default每次工具执行前询问用户plan只读工具直接执行写操作需要确认auto自动执行所有工具危险操作除外acceptEdits文件编辑自动执行其他操作需要确认dontAsk不询问用户根据上下文自动判断bypassPermissions跳过所有权限检查let agent createAgent(options: AgentOptions( apiKey: apiKey, model: claude-sonnet-4-6, permissionMode: .plan // 只读工具直接跑写操作要确认 ))canUseTool 回调比 PermissionMode 更细粒度permissionMode是全局开关粒度比较粗。如果你需要按工具名称或工具属性做精细控制用canUseTool回调let agent createAgent(options: AgentOptions( apiKey: apiKey, model: claude-sonnet-4-6, permissionMode: .bypassPermissions, canUseTool: { tool, input, context in if tool.name Bash { return CanUseToolResult.deny(Bash is not allowed) } return nil // nil 表示我没意见交给 permissionMode 决定 } ))canUseTool返回CanUseToolResult?。返回nil表示该回调没有意见交给下一个检查环节返回非 nil 结果时SDK 用回调的决定不再看permissionMode。CanUseToolResult有三个工厂方法CanUseToolResult.allow() // 允许 CanUseToolResult.deny(原因) // 拒绝 CanUseToolResult.allowWithInput(modifiedInput) // 允许但修改输入参数allowWithInput比较少见但很实用——你可以在权限检查时修改工具的输入参数。比如把文件写入路径重定向到安全目录。策略模式可组合的权限规则直接写闭包虽然灵活但不方便复用。SDK 提供了PermissionPolicy协议把权限判断封装成可组合的策略public protocol PermissionPolicy: Sendable { func evaluate( tool: ToolProtocol, input: Any, context: ToolContext ) async - CanUseToolResult? }SDK 内置了四个策略ToolNameAllowlistPolicy— 白名单只允许指定的工具let policy ToolNameAllowlistPolicy(allowedToolNames: [Read, Glob, Grep]) // Write、Edit、Bash 等工具全部被拒绝ToolNameDenylistPolicy— 黑名单拒绝指定的工具let policy ToolNameDenylistPolicy(deniedToolNames: [Bash, Write]) // 其他工具正常执行ReadOnlyPolicy— 只允许只读工具isReadOnly truelet policy ReadOnlyPolicy() // Read、Glob、Grep、WebSearch 等只读工具允许 // Write、Edit、Bash 等变更工具被拒绝CompositePolicy— 组合多个策略按顺序评估let policy CompositePolicy(policies: [ ToolNameDenylistPolicy(deniedToolNames: [Bash]), ReadOnlyPolicy() ]) // 先检查黑名单Bash 被拒绝再检查只读策略CompositePolicy 的评估规则任何子策略返回 deny整体 deny短路子策略返回 nil没意见跳过所有子策略都 allow 或没意见整体 allow用canUseTool(policy:)桥接函数把策略转成回调let policy CompositePolicy(policies: [ ToolNameDenylistPolicy(deniedToolNames: [Bash]), ReadOnlyPolicy() ]) let agent createAgent(options: AgentOptions( apiKey: apiKey, model: claude-sonnet-4-6, permissionMode: .bypassPermissions, canUseTool: canUseTool(policy: policy) ))三、沙盒机制SandboxSettings SandboxChecker权限控制管的是这个工具能不能执行沙盒管的是这个操作在不在允许范围内。比如 Bash 工具通过了权限检查但你还得确保它不会rm -rf /。SandboxSettings 的配置项let sandbox SandboxSettings( // 路径控制 allowedReadPaths: [/project/], allowedWritePaths: [/project/build/], deniedPaths: [/etc/, /var/], // 命令控制 deniedCommands: [rm, sudo], // 黑名单 // allowedCommands: [git, swift], // 白名单和黑名单二选一 // 行为控制 allowNestedSandbox: false, autoAllowBashIfSandboxed: false, // 沙箱激活时自动批准 Bash allowUnsandboxedCommands: false, enableWeakerNestedSandbox: false, // 网络控制 network: SandboxNetworkConfig( allowedDomains: [api.example.com], allowLocalBinding: false ) )路径和命令各有两种模式路径allowedReadPaths/allowedWritePaths是白名单空数组全部允许deniedPaths是黑名单优先级更高命令allowedCommands是白名单设为非 nil 就只允许列出的命令deniedCommands是黑名单。allowedCommands优先级高于deniedCommandsSandboxChecker 的执行逻辑SandboxChecker是一个无状态的枚举类提供isPathAllowed、checkPath、isCommandAllowed、checkCommand四个静态方法。isXxx返回 BoolcheckXxx不通过时抛出SDKError.permissionDenied。路径检查用前缀匹配加段边界保证// /project/ 匹配 /project/src/file.swift // /project/ 不匹配 /project-backup/file.swift SandboxChecker.isPathAllowed(/project/src/main.swift, for: .read, settings: sandbox) // - true SandboxChecker.isPathAllowed(/project-backup/old.swift, for: .read, settings: sandbox) // - false段边界不匹配实现关键在于SandboxPathNormalizer——先把路径规范化解析..、.、symlink再做前缀比较时保证尾部有/来强制段边界。// 路径遍历攻击会被 normalize 掉 let normalized SandboxPathNormalizer.normalize(/project/src/../../etc/passwd) // - /etc/passwd然后被 deniedPaths 拦截命令检查分三个阶段Shell 元字符检测——识别bash -c cmd、$(cmd)、cmd等绕过模式Basename 提取——从/usr/bin/rm -rf /tmp提取出rm白名单/黑名单匹配// 黑名单里有 rm SandboxChecker.isCommandAllowed(rm -rf /tmp, settings: blocklist) // - false // 路径形式的命令也能识别 SandboxChecker.isCommandAllowed(/usr/bin/rm -rf /tmp, settings: blocklist) // - false提取 basename 得到 rm // 反斜杠绕过 SandboxChecker.isCommandAllowed(\\rm -rf /tmp, settings: blocklist) // - false去掉前导 \ 后得到 rm // 引号绕过 SandboxChecker.isCommandAllowed(\rm\ -rf /tmp, settings: blocklist) // - false去掉引号后得到 rm // 子 shell 绕过 SandboxChecker.isCommandAllowed(bash -c \rm -rf /tmp\, settings: blocklist) // - false递归检查内部命令对于无法可靠解析的命令比如多层嵌套的bash -c bash -c rm ...默认拒绝。命令参数中的文件路径也会被提取并检查——如果命令里出现了deniedPaths中的路径命令也会被拒绝。autoAllowBashIfSandboxed这个选项是沙盒和权限系统的桥梁。当autoAllowBashIfSandboxed true时Bash 工具会跳过canUseTool权限回调检查但仍然经过SandboxChecker.checkCommand()的命令过滤。设计思路是如果你已经配了完善的沙盒规则Bash 命令能做什么已经被限制住了不需要再弹一次权限确认。四、Hook 系统20 生命周期事件前三个系统解决的是能不能做的问题Hook 系统解决的是做了之后要知道和做之前要干预的问题。20 个 HookEventSDK 定义了 24 个生命周期事件事件触发时机preToolUse工具执行前postToolUse工具执行成功后postToolUseFailure工具执行失败后sessionStartAgent 会话开始sessionEndAgent 会话结束stopAgent Loop 停止subagentStart子 Agent 启动subagentStop子 Agent 完成userPromptSubmit用户提交 promptpermissionRequest权限检查发生permissionDenied权限被拒绝taskCreated任务创建taskCompleted任务完成configChange配置变更cwdChanged工作目录变更fileChanged文件变更notification通知事件preCompact对话压缩前postCompact对话压缩后teammateIdle团队成员空闲setupAgent 初始化worktreeCreate工作树创建worktreeRemove工作树移除函数 Hook vs Shell HookHook 有两种实现方式函数回调和 Shell 命令。函数 Hook— Swift 闭包适合进程内逻辑await registry.register(.preToolUse, definition: HookDefinition( handler: { input in // input 是 HookInput包含 event、toolName、toolInput、sessionId 等 return HookOutput(message: 拦截成功, block: true) } ))Shell Hook— 外部命令适合集成非 Swift 脚本await registry.register(.preToolUse, definition: HookDefinition( command: python3 /path/to/check.py // HookInput 通过 stdin JSON 传入 ))Shell Hook 通过ShellHookExecutor执行用/bin/bash -c启动进程把HookInput序列化为 JSON 写入 stdin从 stdout 读取HookOutputJSON。Shell 命令的标准输出如果不是合法 JSON会被包装成HookOutput(message: stdout)。Shell Hook 的环境变量里会注入HOOK_EVENT、HOOK_TOOL_NAME、HOOK_SESSION_ID、HOOK_CWD方便脚本直接用环境变量判断上下文。HookRegistry 的注册与执行HookRegistry是一个actor内部维护[HookEvent: [HookDefinition]]映射let registry HookRegistry() // 注册函数 Hook await registry.register(.preToolUse, definition: HookDefinition( handler: { input in return HookOutput(message: Bash blocked, block: true) }, matcher: Bash // 只匹配 Bash 工具 )) // 注册 Shell Hook await registry.register(.postToolUse, definition: HookDefinition( command: /usr/bin/logger Tool executed, timeout: 5000 // 5 秒超时 )) // 执行所有注册在某事件上的 Hook let results await registry.execute(.preToolUse, input: hookInput) // results: [HookOutput]包含所有匹配的 Hook 的返回值matcher 过滤每个 HookDefinition 可以设一个matcher正则表达式。执行时先检查input.toolName是否匹配 matcher不匹配就跳过这个 Hook。matcher为 nil 时匹配所有工具。超时处理函数 Hook 用withThrowingTaskGroup实现超时——把实际执行和Task.sleep放在同一个 TaskGroup 里谁先完成用谁。超时的 Hook 不影响其他 Hook 执行。Shell Hook 通过DispatchQueue.asyncAfter设置超时到时间就 terminate 进程。执行顺序同一事件上的 Hook 按注册顺序串行执行。HookOutput 的能力HookOutput可以做这些事HookOutput( message: 日志消息, // 附加信息 block: true, // 拦截操作 notification: HookNotification( // 发送通知 title: 警告, body: 检测到危险操作, level: .warning ), permissionUpdate: PermissionUpdate( // 动态修改权限 tool: Bash, behavior: .deny ), systemMessage: 请在沙箱内操作, // 注入系统消息 reason: 安全策略, // 拦截原因 updatedInput: [command: echo safe], // 修改工具输入 decision: .block // 显式 approve/block )其中block: true会阻止工具执行返回一个错误结果给 LLM。permissionUpdate可以在 Hook 运行时动态修改工具权限。updatedInput可以替换工具的输入参数。五、实战组合构建一个安全的 Agent四个子系统各有分工SessionStore— 记住对话历史PermissionPolicy— 控制工具能不能执行SandboxSettings— 限制操作范围HookRegistry— 审计和拦截下面用一个完整的例子展示怎么把它们组合起来import Foundation import OpenAgentSDK // 1. 创建 SessionStore let sessionStore SessionStore() // 2. 创建 HookRegistry注册审计和安全拦截 let hookRegistry HookRegistry() // 记录所有工具执行 await hookRegistry.register(.postToolUse, definition: HookDefinition( handler: { input in if let toolName input.toolName { print([审计] 工具 \(toolName) 执行完成) } return nil } )) // 拦截 Bash 中的危险命令 await hookRegistry.register(.preToolUse, definition: HookDefinition( handler: { input in return HookOutput( message: Bash 被安全策略拦截, block: true, decision: .block ) }, matcher: Bash )) // 记录权限拒绝事件 await hookRegistry.register(.permissionDenied, definition: HookDefinition( handler: { input in print([安全告警] 权限被拒绝: \(input.error ?? unknown)) return nil } )) // 会话生命周期追踪 await hookRegistry.register(.sessionStart, definition: HookDefinition( handler: { _ in print([会话] 开始); return nil } )) await hookRegistry.register(.sessionEnd, definition: HookDefinition( handler: { _ in print([会话] 结束); return nil } )) // 3. 配置沙盒限制路径和命令 let sandbox SandboxSettings( allowedReadPaths: [/project/], allowedWritePaths: [/project/src/, /project/tests/], deniedPaths: [/etc/, /var/, /tmp/], deniedCommands: [rm, sudo, chmod, chown], autoAllowBashIfSandboxed: false, allowNestedSandbox: false ) // 4. 配置权限策略只读 排除 Bash let policy CompositePolicy(policies: [ ToolNameDenylistPolicy(deniedToolNames: [Bash]), ReadOnlyPolicy() ]) // 5. 创建 Agent注入所有组件 let agent createAgent(options: AgentOptions( apiKey: sk-..., model: claude-sonnet-4-6, systemPrompt: 你是一个代码分析助手。只能读取文件不能修改。, maxTurns: 10, permissionMode: .bypassPermissions, canUseTool: canUseTool(policy: policy), sessionStore: sessionStore, sessionId: analysis-session, hookRegistry: hookRegistry, sandbox: sandbox )) // 6. 执行查询 let result await agent.prompt(分析项目中的 Swift 源文件结构) print(result.text) // 7. 后续恢复会话 let resumedAgent createAgent(options: AgentOptions( apiKey: sk-..., model: claude-sonnet-4-6, permissionMode: .bypassPermissions, canUseTool: canUseTool(policy: policy), sessionStore: sessionStore, sessionId: analysis-session, // 同一个 session ID自动恢复历史 hookRegistry: hookRegistry, sandbox: sandbox )) let continued await resumedAgent.prompt(继续分析测试文件) print(continued.text)这个 Agent 的安全特性权限层CompositePolicy 确保只有只读工具能执行Bash 被黑名单排除沙盒层即使工具通过了权限检查也受路径限制——只能读/project/下的文件不能碰/etc/、/var/Hook 层所有工具执行被记录审计Bash 调用被 preToolUse Hook 二次拦截会话层对话自动保存和恢复重启后能继续之前的工作多层防御的好处是即使某一层的配置有疏漏其他层还能兜底。比如你误把 Bash 加进了白名单Hook 的 matcher 还能拦截即使 Hook 没拦住沙盒的命令过滤还能挡。小结SessionStore、PermissionPolicy、SandboxSettings、HookRegistry 四个系统各管一件事但组合起来就是一套完整的安全框架SessionStore 的 actor 隔离和 session ID 校验保证了存储安全PermissionPolicy 的策略组合提供了灵活的权限管理SandboxChecker 的路径规范化和段边界匹配防止目录穿越HookRegistry 的 matcher 过滤和超时机制确保了 Hook 系统的可靠性下一篇看 SDK 的多 LLM 提供商怎么同时支持 Anthropic、OpenAI 和其他 LLMProvider 协议的设计以及运行时切换模型的机制。GitHubterryso/open-agent-sdk-swift合集: Open Agent SDK分类: Agent标签: BMAD, ClaudeCode, OpenAgentSDK, Swift免责声明本内容来自平台创作者博客园系信息发布平台仅提供信息存储空间服务。好文要顶 关注我 收藏该文 微信分享四眼蒙面侠粉丝 - 21 关注 - 11加关注10« 上一篇 深入 Open Agent SDK四多 Agent 协作——子代理、团队与任务编排» 下一篇 深入 Open Agent SDK六多 LLM 提供商与运行时控制posted 2026-04-29 11:42 四眼蒙面侠 阅读(209) 评论(0) 收藏 举报刷新页