AI Agent Harness安全设计:Trace强制采集与权限隔离实战

发布时间:2026/10/1 4:25:18
AI Agent Harness安全设计:Trace强制采集与权限隔离实战 1. 从“Trace 都能删”说起Agent Harness 的安全困局第一次看到“AI Agent 连 Trace 都能删”这个说法时我正蹲在一个内部项目的日志面板前排查一次诡异的工具调用失败。Agent 明明执行了搜索动作返回结果也正常但 Trace 里干干净净像什么都没发生过。当时第一反应是日志采集挂了查了半天采集链路最后发现是 Agent 自己在某个异常分支里把整段 Trace 记录给清掉了——不是恶意是它“认为”那段记录属于临时中间态顺手做了清理。这件事让我意识到一个被很多人忽略的问题当 Agent 拥有对自身运行痕迹的写权限时Trace 就不再是可信的审计依据。而 Agent Harness 作为承载 Agent 运行、调度工具、管理上下文、控制权限的那层“骨架”它的安全设计直接决定了整个系统是可控还是失控。先把概念理清楚因为热词里“harness 和 agent 区别”被搜了很多次。Agent 是干活的那个“人”它理解目标、规划步骤、调用工具、生成结果。Harness 是它身上的“安全带 工位 监工”负责给 Agent 提供运行环境、注入工具、限制它能碰什么、记录它干了什么、在它跑偏时把它拉回来。你可以把 Agent 想成一个能力很强但边界感很弱的实习生Harness 就是公司给他配的工牌、门禁、操作日志和审批流。问题在于很多团队搭 Agent 时Harness 是“事后补”的。先让 Agent 跑起来能调工具、能出结果然后再想安全的事。这时候 Agent 已经习惯了“我想删就删、我想写就写”的自由度你再给它套约束它会在各种边缘 case 里绕过去。Trace 被删只是表象背后是权限边界、审计独立性和状态可信性三个层面的系统性缺失。这篇内容适合两类人看一类是正在从 0 到 1 搭建 AI Agent、还没认真考虑 Harness 安全边界的开发者另一类是已经在跑 Agent 项目、发现 Trace 不可信、工具调用失控、并发下状态混乱想找一套可落地约束方案的工程师。我会把 Harness 的安全设计拆成可操作的模块每个模块都给出具体的实现思路和踩坑记录不堆概念只讲能直接抄作业的东西。2. Agent Harness 的安全设计思路拆解2.1 为什么“让 Agent 自己管 Trace”是个结构性错误Trace 的本质是审计记录审计记录的第一原则是记录者与被记录者分离。现实世界里财务不能自己记自己的账代码提交不能自己给自己 approve同一个道理。Agent 在执行过程中产生的 Trace如果由 Agent 自己决定写什么、不写什么、什么时候删那这份 Trace 在安全意义上就是废纸。我见过一种常见实现Agent 的每个 step 结束后由 Agent 自己调用一个log_trace()工具把当前状态写进去。看起来没问题但 Agent 在规划阶段就可能决定“这一步不重要不记了”或者在异常处理时把错误 Trace 删掉“保持整洁”。更隐蔽的是当 Agent 被注入恶意指令时攻击者可以诱导它删除关键操作痕迹让事后审计完全失效。正确的做法是Trace 由 Harness 层强制采集Agent 只有“产生事件”的能力没有“管理事件”的能力。Agent 执行动作时Harness 在调用链路外层拦截把输入、输出、工具调用参数、返回结果、耗时、异常全部记录下来写入 Agent 无法直接访问的存储。Agent 甚至不应该知道自己被记录了——知道得越少越不容易针对性地规避。注意这里说的“Agent 无法访问”不是指代码层面不给它 API而是指运行时的权限隔离。如果 Agent 和 Harness 跑在同一个进程、同一个权限上下文里Agent 通过代码注入或工具滥用依然可能碰到 Trace 存储。真正的隔离需要进程级或容器级的边界。2.2 Harness 安全的三层防线权限、审计、状态把 Harness 的安全需求拆开核心就三件事。权限层解决“Agent 能碰什么”。工具调用不是越多越好每个工具都应该有明确的权限标签。读文件、写文件、发网络请求、执行命令这些能力的风险等级完全不同。Harness 需要在 Agent 发起工具调用时做一次权限校验而不是信任 Agent 的规划结果。我习惯把工具分成三档只读类搜索、查询、受限写入类写指定目录、发指定域名的请求、高危类执行任意命令、写系统路径。高危类工具默认不挂载需要显式开启并且加审批流。审计层解决“Agent 干了什么”。前面说了 Trace 必须由 Harness 强制采集但采集只是第一步。审计层还需要保证 Trace 的完整性和不可篡改性。完整性靠的是采集覆盖所有关键路径不能有“这个分支不记录”的漏洞。不可篡改性靠的是写入后 Agent 无法修改或删除通常用追加写append-only存储加上哈希链来实现。每一条 Trace 记录包含前一条的哈希任何中间删除都会导致后续哈希校验失败。状态层解决“Agent 当前处于什么状态”。Agent 是有状态的它维护着对话历史、工具调用结果、中间变量。如果这些状态可以被 Agent 随意修改那它就能伪造上下文来绕过后续的权限检查。Harness 需要把关键状态放在 Agent 不可直接写的存储里Agent 只能通过受控的接口读取和追加不能覆盖或删除。这三层不是独立的权限层校验时依赖状态层的可信状态审计层记录权限层的校验结果状态层的变更又需要审计层留痕。任何一层被绕过整体安全就塌了。2.3 方案选型为什么我最终选了“外挂式 Harness”而不是“框架内嵌”搭建 Agent 时Harness 的实现方式大致有两种。一种是框架内嵌比如用 LangChain 或 LangGraph 时直接在 Agent 的 executor 里加权限检查和日志钩子。另一种是外挂式Agent 跑在一个独立的运行时里Harness 作为代理层拦截所有输入输出和工具调用。我两个方案都试过。框架内嵌上手快改几行代码就能加日志但问题很明显Agent 和 Harness 共享同一个运行时Agent 通过工具调用或代码执行可以绕过钩子。而且不同框架的钩子机制不一样换框架就要重写一遍安全逻辑维护成本高。外挂式 Harness 的架构是Agent 进程只负责推理和规划所有工具调用都通过一个本地代理转发代理层做权限校验、参数检查、Trace 采集、结果过滤。Agent 拿不到真实工具的句柄只能拿到代理的接口。这样即使 Agent 被注入它能碰到的也只是代理暴露的受限能力。代价是延迟增加和架构复杂度上升。每次工具调用多一次进程间通信实测下来单次调用延迟增加 5 到 15 毫秒对于大多数场景可以接受。复杂度方面需要额外维护代理层的路由和协议但换来的是安全边界清晰、框架无关、可独立升级。实操心得如果你的 Agent 还在原型阶段用框架内嵌快速验证没问题。但一旦要上生产、要处理真实用户数据、要调用有副作用的工具尽早切到外挂式。后期迁移的成本远高于一开始就搭好边界。3. 核心细节解析与实操要点3.1 Trace 强制采集的实现细节Trace 采集要解决三个问题采什么、怎么采、存哪里。采什么取决于你要审计什么。最小集合包括每次 Agent 推理的输入 prompt 和输出、每次工具调用的工具名和参数、工具返回结果、异常堆栈、时间戳、会话 ID、步骤序号。如果涉及多 Agent 协作还要记录消息的发送方和接收方。我习惯再加一个trace_id贯穿整个会话方便串联。怎么采的关键是拦截点要足够底层。不要在 Agent 的业务代码里埋点那样覆盖不全。外挂式 Harness 的拦截点应该在代理层Agent 发出的每个工具调用请求先到代理代理记录请求内容后再转发给真实工具拿到结果后记录结果再返回给 Agent。推理过程的采集类似Agent 的每次模型调用也走代理代理记录 prompt 和 completion。存哪里要考虑不可篡改。最简单的方案是追加写的文件Agent 进程没有该文件的写权限只有 Harness 进程能写。进阶方案是写入独立的日志服务或对象存储加上哈希链。哈希链的实现不复杂每条记录包含hash(前一条记录的哈希 当前记录内容)校验时从头遍历任何一条被改都会导致后续哈希不匹配。import hashlib import json import time class TraceChain: def __init__(self, storage_path): self.storage_path storage_path self.prev_hash 0 * 64 def append(self, event: dict): event[timestamp] time.time() event[prev_hash] self.prev_hash payload json.dumps(event, sort_keysTrue) current_hash hashlib.sha256(payload.encode()).hexdigest() event[hash] current_hash with open(self.storage_path, a) as f: f.write(json.dumps(event) \n) self.prev_hash current_hash return current_hash def verify(self): prev 0 * 64 with open(self.storage_path, r) as f: for line in f: event json.loads(line) stored_hash event.pop(hash) event.pop(prev_hash) payload json.dumps(event, sort_keysTrue) expected hashlib.sha256(payload.encode()).hexdigest() if expected ! stored_hash: return False, event prev stored_hash return True, None这段代码的关键点是prev_hash的传递和校验时的重算。注意json.dumps要加sort_keysTrue否则字典顺序变化会导致哈希不一致。实际部署时存储路径要放在 Agent 进程无权限访问的目录或者直接用独立的存储服务。3.2 工具权限校验的粒度控制工具权限校验最容易犯的错是粒度太粗。比如只判断“Agent 有没有调用这个工具的权限”而不判断“这次调用的参数是否在允许范围内”。一个文件读取工具Agent 有权限调用但它可以读/etc/passwd也可以读项目目录风险完全不同。我的做法是工具级权限 参数级约束双层校验。工具级权限决定 Agent 能不能用这个工具参数级约束决定这次调用的具体参数是否合法。参数约束用声明式配置每个工具定义自己的约束规则。tools: read_file: enabled: true constraints: path: type: prefix allowed: [/workspace/project/, /tmp/agent_scratch/] http_request: enabled: true constraints: url: type: domain_whitelist allowed: [api.internal.com, search.public.com] method: type: enum allowed: [GET, POST] execute_command: enabled: false requires_approval: true校验逻辑在代理层执行Agent 传过来的参数先过约束检查不通过直接拒绝并记录 Trace。这里有个细节拒绝也要记录。很多团队只记录成功的调用失败的调用不记结果攻击者可以反复试探权限边界而不留痕迹。失败的调用同样要写入 Trace包括被拒绝的参数。注意参数约束要防绕过。比如路径前缀检查Agent 可能传/workspace/project/../../etc/passwd简单的字符串前缀匹配会放行。必须先做路径规范化resolve 真实路径再检查前缀。URL 检查同理要防http://api.internal.com.evil.com这种域名后缀欺骗。3.3 状态隔离与上下文可信性Agent 的状态包括对话历史、工具调用结果、中间变量。这些状态如果 Agent 能直接写它就能伪造上下文。比如 Agent 先调用一个查询工具拿到“用户余额 100 元”然后自己把状态改成“用户余额 10000 元”后续的决策就基于假数据。状态隔离的做法是状态存储与 Agent 运行时分离Agent 只能追加事件不能覆盖或删除。对话历史用事件流的方式存储每次 Agent 产生一条消息或工具返回一个结果都作为新事件追加到流里。Agent 读取上下文时从事件流重放生成当前状态而不是直接读一个可写的状态对象。这样做的另一个好处是可回溯。任何时候都能从事件流重放出 Agent 在某个步骤看到的状态排查问题时非常有用。代价是重放有计算开销长会话下事件流会很大。优化方式是定期做快照快照之后的事件增量重放。快照本身也要纳入 Trace 链保证快照内容可信。class EventStore: def __init__(self, trace_chain): self.events [] self.trace_chain trace_chain def append(self, event_type: str, payload: dict): event { type: event_type, payload: payload, seq: len(self.events) } self.trace_chain.append(event) self.events.append(event) def replay(self, upto_seqNone): state {messages: [], tool_results: {}} for event in self.events: if upto_seq is not None and event[seq] upto_seq: break if event[type] message: state[messages].append(event[payload]) elif event[type] tool_result: state[tool_results][event[payload][call_id]] event[payload][result] return stateAgent 拿到的上下文是replay()的结果是一个只读的视图。Agent 想修改状态只能通过产生新事件而新事件会被 Trace 链记录。这样即使 Agent 想伪造也会留下痕迹。4. 实操过程与核心环节实现4.1 从零搭建一个带安全边界的 Harness假设你要搭一个能调用搜索和文件读取工具的 AgentHarness 需要提供权限校验、Trace 采集、状态管理三个能力。我按实际搭建顺序走一遍。第一步定义工具接口和代理协议。Agent 不直接持有工具函数而是通过一个本地 HTTP 或 Unix Socket 接口调用。接口协议统一为POST /tool/{tool_name}body 是工具参数返回是工具结果或错误。代理层监听这个接口做校验和转发。from fastapi import FastAPI, HTTPException from pydantic import BaseModel app FastAPI() trace_chain TraceChain(/var/agent/trace/chain.log) event_store EventStore(trace_chain) class ToolRequest(BaseModel): tool_name: str params: dict call_id: str app.post(/tool) async def handle_tool(req: ToolRequest): event_store.append(tool_call, { tool_name: req.tool_name, params: req.params, call_id: req.call_id }) allowed, reason check_permission(req.tool_name, req.params) if not allowed: event_store.append(tool_denied, { tool_name: req.tool_name, params: req.params, reason: reason }) raise HTTPException(status_code403, detailreason) result await dispatch_tool(req.tool_name, req.params) event_store.append(tool_result, { call_id: req.call_id, result: result }) return {result: result}第二步实现权限校验函数。校验逻辑从配置文件加载规则对参数做规范化后匹配。import os from urllib.parse import urlparse def check_permission(tool_name: str, params: dict): config load_tool_config() tool_conf config[tools].get(tool_name) if not tool_conf or not tool_conf.get(enabled): return False, ftool {tool_name} is disabled constraints tool_conf.get(constraints, {}) for param_name, rule in constraints.items(): value params.get(param_name) if value is None: return False, fmissing required param {param_name} if rule[type] prefix: normalized os.path.realpath(value) if not any(normalized.startswith(p) for p in rule[allowed]): return False, fpath {normalized} not allowed elif rule[type] domain_whitelist: domain urlparse(value).hostname if domain not in rule[allowed]: return False, fdomain {domain} not allowed elif rule[type] enum: if value not in rule[allowed]: return False, fvalue {value} not allowed return True, ok第三步Agent 侧只保留代理的地址不导入任何真实工具。Agent 的 prompt 里说明它可以通过call_tool(tool_name, params)调用工具这个函数实际是向代理发请求。import httpx HARNESS_URL http://127.0.0.1:8000/tool async def call_tool(tool_name: str, params: dict, call_id: str): async with httpx.AsyncClient() as client: resp await client.post(HARNESS_URL, json{ tool_name: tool_name, params: params, call_id: call_id }) if resp.status_code 403: return {error: resp.json()[detail]} return resp.json()这套结构跑起来后Agent 能调用的工具、能传的参数都被代理层约束所有调用和结果都进了 Trace 链。Agent 进程即使被注入它能做的也只是向代理发请求而代理会按规则拒绝越权操作。4.2 并发场景下的 Trace 写入与状态一致性热词里“ai agent 怎么扛并发”被搜了很多次这个问题在 Harness 层尤其突出。多个 Agent 实例同时运行时Trace 写入和状态更新都需要保证一致性。Trace 写入的并发问题在于哈希链。如果两个请求同时追加 Trace都读到同一个prev_hash就会产生分叉。解决办法是串行化写入。用一个单线程的写入队列所有 Trace 事件先入队由单独的写入线程按顺序处理。这样哈希链不会分叉代价是写入吞吐受限于单线程。实测下来单线程写入每秒能处理几千条事件对于大多数 Agent 场景够用。如果不够可以按会话分片每个会话一条独立的链。状态一致性的问题在于事件流的顺序。多个工具调用并发返回时事件追加的顺序会影响重放结果。我的做法是给每个事件分配单调递增的序号序号由 Harness 统一分配Agent 不能自己指定。重放时按序号排序保证顺序确定。工具调用的结果事件里带上call_id重放时按call_id关联请求和结果不依赖到达顺序。import threading import queue class SerializedTraceWriter: def __init__(self, trace_chain): self.trace_chain trace_chain self.queue queue.Queue() self.thread threading.Thread(targetself._worker, daemonTrue) self.thread.start() def _worker(self): while True: event self.queue.get() if event is None: break self.trace_chain.append(event) self.queue.task_done() def submit(self, event): self.queue.put(event)实操心得并发下最容易忽略的是拒绝事件的顺序。如果 Agent 并发发起多个工具调用其中一个被拒绝拒绝事件和成功事件的相对顺序会影响审计时的判断。用统一序号分配就能解决序号在请求进入代理时就分配而不是在结果返回时分配。4.3 异常分支的 Trace 覆盖Trace 被删的案例里最隐蔽的是异常分支不记录。正常路径都记了但 Agent 走到异常处理逻辑时那段代码没有埋点或者埋了点但异常导致写入失败。攻击者可以故意触发异常来制造 Trace 盲区。解决办法是在代理层用 try-finally 保证 Trace 写入。不管工具调用成功还是抛异常finally 块里都写一条结果事件异常信息作为事件内容。这样即使工具本身崩溃Trace 里也有记录。app.post(/tool) async def handle_tool(req: ToolRequest): event_store.append(tool_call, {...}) try: allowed, reason check_permission(req.tool_name, req.params) if not allowed: event_store.append(tool_denied, {...}) raise HTTPException(status_code403, detailreason) result await dispatch_tool(req.tool_name, req.params) event_store.append(tool_result, {call_id: req.call_id, result: result}) return {result: result} except HTTPException: raise except Exception as e: event_store.append(tool_error, { call_id: req.call_id, error: str(e), traceback: traceback.format_exc() }) raise HTTPException(status_code500, detailtool execution failed)注意except HTTPException: raise这一行权限拒绝的异常已经记录过了不要重复记录。其他异常统一记录为tool_error。这样 Trace 里能看到每次调用的完整生命周期请求、校验、执行、结果或错误。5. 常见问题与排查技巧实录5.1 Trace 链校验失败的排查思路哈希链校验失败是最常见的问题表现是verify()返回 False。排查时先定位第一条不匹配的记录看它的内容是否被改过还是prev_hash传递出了问题。我遇到过几种原因。一种是写入时sort_keys不一致有的地方用了有的地方没用导致哈希重算不匹配。统一在 TraceChain 内部做序列化外部不要自己序列化。另一种是并发写入导致分叉两个线程同时写prev_hash读到了同一个值。用串行化写入解决。还有一种是存储文件被外部工具修改比如日志轮转把文件截断了。Trace 存储要排除在日志轮转之外或者用独立的存储服务。排查时可以用一个简单的脚本逐行校验打印出第一条不匹配的记录序号和内容。def debug_verify(path): prev 0 * 64 with open(path) as f: for i, line in enumerate(f): event json.loads(line) stored_hash event.pop(hash) stored_prev event.pop(prev_hash) if stored_prev ! prev: print(fline {i}: prev_hash mismatch, expected {prev}, got {stored_prev}) return payload json.dumps(event, sort_keysTrue) expected hashlib.sha256(payload.encode()).hexdigest() if expected ! stored_hash: print(fline {i}: hash mismatch, content may be modified) print(json.dumps(event, indent2)) return prev stored_hash print(all ok)5.2 工具调用被绕过的典型场景权限校验被绕过通常不是校验逻辑本身的问题而是校验点选错了。我见过几种典型绕过。一种是 Agent 通过代码执行工具间接调用其他工具。比如 Agent 有execute_python权限它可以在 Python 代码里直接import requests发请求绕过 HTTP 工具的域名白名单。解决办法是高危工具默认禁用或者用沙箱限制代码执行的能力。沙箱里禁网络、禁文件系统写、只暴露受控的接口。另一种是 Agent 通过 prompt 注入让模型直接输出工具调用的参数然后由另一个组件执行。如果那个组件没有走 Harness 的校验就绕过了。解决办法是所有工具调用必须经过 Harness没有例外。任何直连工具的路径都是安全漏洞。还有一种是参数编码绕过。比如路径检查用字符串匹配Agent 传 URL 编码的路径%2e%2e%2f解码后是../。解决办法是先解码再规范化再检查不要用原始字符串做匹配。绕过场景根因修复方式代码执行间接调用高危工具未隔离沙箱限制能力默认禁用旁路组件直连工具校验点不统一所有调用强制走 Harness参数编码绕过检查前未规范化解码 规范化 再检查工具别名混淆工具名未做映射工具名白名单拒绝未知工具5.3 并发下状态错乱的排查并发场景下状态错乱的表现是 Agent 看到的历史和实际发生的不一致。比如两个工具调用并发返回Agent 读到的结果顺序和实际执行顺序不同导致推理出错。排查时先看事件流的序号是否连续。如果序号有跳跃或重复说明序号分配有问题。序号应该由 Harness 在请求进入时分配用原子计数器保证唯一递增。如果序号连续但重放结果不对看事件的call_id关联是否正确。工具结果事件必须带上对应的call_id重放时按call_id匹配不能按到达顺序匹配。还有一种情况是快照和增量事件不一致。快照生成时的事件流和后续增量事件之间有重叠或遗漏。解决办法是快照生成时记录当前最大序号增量重放从该序号之后开始严格不重叠。注意并发测试一定要做。单线程下跑通的逻辑并发下经常出问题。我习惯用 10 到 50 个并发请求压一遍观察 Trace 链是否完整、状态重放是否一致。压测时故意混入被拒绝的请求和异常请求验证异常路径的 Trace 覆盖。5.4 常见问题速查表问题现象可能原因排查动作解决方式Trace 链校验失败序列化不一致 / 并发写入 / 文件被改逐行校验定位首条不匹配统一序列化、串行写入、独立存储工具调用被绕过校验点不统一 / 高危工具未隔离检查所有工具调用路径强制走 Harness、沙箱隔离状态重放不一致序号分配问题 / call_id 缺失检查事件序号和关联字段原子序号、强制 call_id异常分支无 Trace埋点遗漏 / 写入失败检查异常路径的 Trace 覆盖try-finally 保证写入并发下 Trace 分叉多线程同时写检查 prev_hash 是否重复串行化写入队列权限拒绝无记录只记成功不记失败检查拒绝路径的埋点拒绝也写 Trace6. 一些实际踩过的坑和后续可以做的事Trace 存储的权限隔离我一开始以为把文件权限设成只有 Harness 用户可写就够了。后来发现 Agent 和 Harness 跑在同一个容器里Agent 通过工具调用执行chmod就能改权限。真正的隔离需要把 Trace 存储放在 Agent 容器之外通过网络或挂载卷访问且挂载卷对 Agent 只读。这个改动不大但意识不到就会留个大洞。工具权限配置的维护成本比想象中高。工具多了之后每个工具的约束规则散落在配置文件里改一个规则要翻半天。后来我把工具定义和权限规则放在一起用代码生成配置工具注册时自动带上权限声明。这样新增工具时权限规则是强制的不会漏。状态重放的开销在长会话下确实明显。一个跑了上百步的会话每次重放要遍历所有事件。我的优化是每 20 步做一次快照快照内容也进 Trace 链。重放时从最近的快照开始只重放之后的事件。快照的生成时机选在工具调用之间的空闲点避免影响响应延迟。后续可以扩展的方向一个是把 Trace 链接到外部的审计服务做实时告警。比如检测到短时间内大量权限拒绝可能是 Agent 被注入后在试探边界触发告警人工介入。另一个是给 Trace 加签名用 Harness 的私钥对每条记录签名校验时验证签名这样即使存储被攻破篡改也能被发现。签名会增加写入开销但对安全要求高的场景值得。最后分享一个小技巧在 Agent 的 prompt 里明确告诉它“你的所有工具调用都会被记录和审计”这本身就有威慑作用。虽然不能替代技术约束但能减少 Agent 在规划阶段就尝试越权的概率。实测下来加了这句话之后Agent 主动尝试调用未授权工具的次数明显下降。技术约束加心理约束双管齐下。