深入解析 long-horizon-harness 的 policy 技能:用 JSONL 策略文件守护 Agent 工具调用
2026/9/15 23:07:26 网站建设 项目流程

深入解析 long-horizon-harness 的 policy 技能:用 JSONL 策略文件守护 Agent 工具调用

【免费下载链接】adk-samplesA collection of sample agents built with Agent Development Kit (ADK)项目地址: https://gitcode.com/GitHub_Trending/ad/adk-samples

导读

本文以adk-samples仓库中core/python/long-horizon-harness项目的内置policy技能文档为骨架,系统讲解 Long Horizon Agent 如何通过「默认种子 + 用户覆盖层」两级 JSONL 策略文件(.lha/policies.jsonl)来拦截破坏性或敏感的工具调用。读完本文,你将掌握策略规则的完整字段语义、argv 结构化命令分类器(deny/ask/None三级判决)的原理、/yolo审批模式与/grant一次性授权的边界,并能独立完成「查看生效策略、新增规则、删除规则」的完整实操流程。

policy 技能是什么:每次工具调用前的策略守门人

long-horizon-harness中,policies_guard是一个注册在before_tool_callback链路上的回调:每一次工具调用都会先经过它,由一个 JSONL 格式的策略文件决定该调用是否被允许。这个技能(core/python/long-horizon-harness/horizon/builtin_skills/policy/SKILL.md)存在的意义,就是让 Agent 能够查看当前工作区生效的策略、并指导用户如何增删规则——但永远不能自己修改策略文件。

这套设计有一个明确的安全前提:Agent 不能自我编辑自己的防护配置。策略文件所在目录.lha/本身就是被硬性拒绝的破坏性路径模式,无论是write/edit写入,还是bash里的sed -icp/mvrmchmod指向.lha/,都会命中种子规则中的destructive_commands_regex。详细动机记录在 security-model.md。

两级策略体系:默认种子与用户覆盖层

策略规则存放于两个层级,二者追加合并后生效:

默认种子(read-only,随 Agent 发布)

种子文件位于 default_policies.jsonl,打包在horizon/guardrails/目录内,由policies.py中的_read_default_seed()固定加载。它提供针对灾难性操作凭据读取的基线拦截,例如:

{"canonical_tool_name": "terminal", "destructive_commands": {"command": ["dd if=", "of=/dev/sd", "of=/dev/nvme", "of=/dev/hd", "mkfs.", "mkfs ", ":(){:|:&};:", "nc -l", "ncat -l", "socat ", "> /dev/sd", "> /dev/nvme", "> /dev/hd", "/dev/tcp/", "/dev/udp/", "shred ", "wipefs ", "cat ~/.ssh/", "cat ~/.aws/", "cat ~/.config/gcloud/", "cat ~/.kube/", "cat /etc/shadow", "cat /etc/sudoers"]}, "destructive_commands_regex": {"command": ["(>>?|\\btee\\b(?:\\s+-\\S+)*)\\s*\\S*\\.lha/", "\\bsed\\b[^|;&]*-i[^|;&]*\\.lha/", "\\b(cp|mv)\\b[^|;&]*\\.lha/", "\\b(rm|truncate|shred|unlink)\\b[^|;&]*(?:^|[\\s/'\"=])\\.lha(?:$|[/\\s'\";&|><])", "\\bchmod\\b[^|;&]*(?:^|[\\s/'\"=])\\.lha(?:$|[/\\s'\";&|><])"]}} {"canonical_tool_name": "write_file", "destructive_paths": ["/etc/sudoers", "/etc/shadow", "/etc/passwd"], "destructive_path_patterns": ["*/.ssh/*", "*/.aws/*", "*/.config/gcloud/*", "*/.kube/*", ".lha/*", "*/.lha/*"]} {"canonical_tool_name": "patch", "destructive_paths": ["/etc/sudoers", "/etc/shadow", "/etc/passwd"], "destructive_path_patterns": ["*/.ssh/*", "*/.aws/*", "*/.config/gcloud/*", "*/.kube/*", ".lha/*", "*/.lha/*"]}

可以看出,种子规则覆盖了四类风险:

  • 原始块设备写入dd if=of=/dev/sd*> /dev/sd*等字面量子串;
  • 危险系统工具mkfsshredwipefs、fork bomb:(){:|:&};:
  • 反向 shell 监听原语nc -lncat -lsocat/dev/tcp//dev/udp/
  • 凭据直读cat ~/.ssh/cat ~/.aws/cat ~/.kube/cat /etc/shadowcat /etc/sudoers等。

值得注意的演进:旧版种子中针对rm -rfchmod -R的脆弱的子串/正则规则已被移除,这两类操作现在交由 argv 结构化解析器 command_safety.py 分类处理(详见下文)。种子文件只保留「字面灾难命令 + 凭据读取」,减少误伤、提高可解释性。

用户覆盖层(per-workspace,可热加载)

用户覆盖层位于工作区根目录下的.lha/policies.jsonl。关键行为:

  • 追加语义load_policies()(policies.py)返回list(seed) + list(user),覆盖层规则只会叠加更多限制,不能削弱种子;
  • mtime 热加载:本地后端每次工具调用都检查文件的st_mtime,命中缓存则直接返回,否则重新解析——编辑文件后下一次工具调用即生效,无需重启会话;
  • 双后端支持load_policies_for_env()区分本地后端(宿主文件、按 mtime 热加载)与沙箱后端(覆盖层存在沙箱内部,通过env.read_file读取、按沙箱身份缓存)。关键实现见 _overlay.py:沙箱缓存必须用cache_identity()(沙箱名)而非裸working_dir做 key,否则共享/workspace路径的多个租户会互相读到对方的覆盖层。

硬性写保护

.lha/**/.lha/*同时是write_file/patchdestructive_path_patterns,并被bashdestructive_commands_regex覆盖(追加、sed -icp/mvrmchmod等命令形态)。因此 Agent只能读、不能写.lha/policies.jsonl——这是设计使然。

何时使用这个技能

policy技能适合以下两种用户请求场景:

  1. 用户询问当前生效的策略是什么;
  2. 用户想封锁某个命令或路径模式,或放宽(删除)某条覆盖层规则——两种情况都由 Agent 查看当前文件、把精确的改动方案交给用户手动执行,Agent 自己不动手。

此外要特别区分:如果用户想对刚刚被拦截的一次调用做一次性放行,应引导用户自己输入/grant <command>/grant是仅面向用户的斜杠命令,Agent 无法代为调用。底层机制见 policy_grants.py:授权记录写入session.state["_policy_grants"]policies_guard在求值规则之前先查匹配的 grant,命中即短路放行;grant 采用「签名子集」匹配模型,例如对{"command": "rm -rf build/"}的授权不会放行rm -rf /,保持授权尽量狭窄。

文件格式:一行一条规则的 JSONL DSL

.lha/policies.jsonl的格式约定:

  • 每行一个 JSON 对象;空行与#注释行被跳过(解析器见 _jsonl.py 的iter_jsonl_objects);
  • 每条规则必须包含canonical_tool_name字段,它决定了该规则作用于哪个工具(bashterminalwrite_filepatch等)。加载时若缺少该字段,规则会被静默丢弃(policies.py);
  • 一条规则可以携带以下一个或多个字段,各字段独立求值,互不依赖:
字段类型效果
destructivetrue无条件拦截该工具。
destructive_commands{arg: [substring, ...]}当字符串参数包含任一子串时拦截(大小写不敏感)。
destructive_commands_regex{arg: [regex, ...]}当任一正则命中字符串参数时拦截(大小写不敏感)。子串匹配过于粗糙时(需要锚点、交替、词边界)使用。
destructive_paths[prefix, ...]当路径形参数(pathfile_pathtarget_path)以任一前缀开头时拦截。
destructive_path_patterns[fnmatch-glob, ...]当路径形参数命中任一 fnmatch glob 时拦截。适合*/.ssh/*这类无法用单一前缀表达的 per-user 路径。

正则安全校验(ReDoS 防护)

destructive_commands_regex中的租户编写的正则(覆盖层 + grant 规则)在编译前会经过 _regex_safety.py 的启发式校验:safe_regex()拒绝长度超过 1000 字符的模式,并检测嵌套量词形态(如(…+)+(.*)*([a-z]+)+)。Python 的re没有匹配超时,这条防线能防止恶意/有缺陷的正则拖垮请求。被判定不安全的模式会被跳过并告警,而不是编译执行。注意:信任的内置种子规则不受此限制,只有用户自写的 overlay/grant 模式会被门控。

解析失败的行为

  • 无法解析的 JSONL 行:跳过并打 warning,fail closed(该行不产生任何拦截效果,但也不会让其余规则失效);
  • 无效正则:编译时报错被捕获并跳过(policies.py 中对re.error的处理);
  • 覆盖层文件缺失:视为空(read_overlay_textFileNotFoundError返回"")。

规则求值原理(源码视角)

policies_guard(policies.py)的执行顺序:

  1. grant 短路:若会话状态中存在匹配(tool_name, args)的 per-session 授权(find_matching_grant),直接放行;
  2. 逐条求值_evaluate(rule, tool_name, args)对每条规则按字段类型分别匹配——
    • 子串:pat.lower() in value.lower()(大小写不敏感);
    • 正则:re.search(pat, value, re.IGNORECASE)
    • 路径前缀:仅对_PATH_ARG_NAMES = {"path", "file_path", "target_path"}中的参数做startswith
    • 路径 glob:fnmatch.fnmatchcase(value, pat)
  3. 合成复查process(action="write")data载荷与process(action="spawn")command伪装成bash参数重新跑一遍 bash 规则——否则这些 shell 执行路径会因为canonical_tool_name永不为"bash"而绕过种子里dd if=、fork bomb、cat ~/.ssh/等拦截;
  4. argv 分类:提取 shell 命令交给command_safety.classify()判级(见下节);
  5. 拦截返回:统一返回{"error": ..., "confirmation_required": True, "matched_rule": ...}短路口字典(_block()),并在错误信息中提示用户可输入/grant <command>做会话级放行。

argv 结构化分类:deny / ask / None 三级判决

在覆盖层/种子规则运行之前command_safety.py先用标准库shlex把 shell 命令词法切分成 argv token(引号与操作符感知),再对 token 结构进行分析,而非对原始字符串做模式匹配。它返回一个粗粒度判决:

  • "deny"(灾难性,任何地方都硬拦截)——例如rm -rf /rm -rf /etcrm -rf $HOMErm -rf /*等对系统根/用户主目录的递归强制删除;
  • "ask"(有风险)——根 Agent会看到一个交互式审批卡;子代理/无头链则视为硬拒绝(通过policies_guardask_is_deny=True参数),保证无人值守场景不回归;
  • None(无意见)——交给常规权限流程决定。

从 command_safety.py 的实现看,分类器覆盖了相当精细的结构化场景:

危险目标判定(rm

_DANGEROUS_RM_TARGETS列出了一组系统/用户根目标(/~$HOME/etc/usr/bin/sbin/lib/boot/sys/proc/dev/var/root/home/Users),并配套_DANGEROUS_RM_PREFIXES前缀表与_HOME_CONTAINERS处理:/home/bob会清空整个用户,而/home/bob/build不会,所以只有「一层子目录」形态才被判定为用户根。只有当rm同时具备递归(-r/--recursive)与强制(-f/--force)时才进入危险判定;命中根目标返回deny,命中./*等当前目录/glob 形态返回ask

git 危险动词(_git_verdict

  • git push --force/-f/--force-with-leaseask(重写远端历史);
  • git push --delete/-d/--mirror+refspec强制推送 →ask
  • git reset --hardaskgit clean -faskgit filter-branch/filter-repoask

实现上通过_GIT_VALUE_FLAGS-C-c--git-dir等)跳过会消费下一个 token 的全局选项值,避免把路径名push误判为子命令。

云/基础设施 CLI 的不可逆删除(_CLOUD_DELETE

token 精确匹配(而非子串,避免把 SQL 里的DELETE或名为delete的资源误伤):

CLI危险动词
bqrm
gclouddeleterm
gsutilrm
kubectldelete
terraformdestroy
dockerrmrmiprune
gwsdelete

其他ask场景

  • find携带副作用动作(-delete-exec-execdir-ok-fls-fprint等);
  • mv/dev/null(销毁数据);
  • 管道进解释器(| sh| bash| python| perl| ruby| node| php);
  • sudo/su或经sudo/doas提权后的命令(_effective()会剥离环境变量前缀、解包sudo/doas/env及透明启动器nice/nohup/timeout/xargs等,检查真正的二进制);
  • 对系统/用户根做递归chmod/chown

另外,classify()无法解析的命令采取保守策略:直接返回("ask", "command could not be parsed")——宁可多问一次,也不放行。

审批模式:/yolo与四按钮审批卡

策略拦截属于硬拒绝层(Layer C);在它之下还有一层软询问层(Layer D)——permission_guard(permission_guard.py),负责判断「这次调用是否值得确认」。当一个调用通过了硬拒绝层但被ask判级时,根 Agent 会看到一个交互式四按钮审批卡

  1. Yes, once(仅此一次);
  2. Yes — allow <command> this session(本会话放行,写入 session grant);
  3. Always allow <command>(持久化到.lha/permissions.jsonl);
  4. Decline(拒绝)。

/yolo命令可在defaultyolo模式间切换。YOLO 模式自动批准 Layer-D 的交互式ask判决;它不会绕过 exfil guard(Layer A)或 Layer-C 硬拒绝规则(灾难命令 + 凭据读取 + 种子覆盖层)。适合在信任当前会话、希望减少「危险但不灾难」操作弹窗时使用。审批模式是会话级状态(session state 中的approval_mode键),不会跨会话持久化。

无头/子代理链路的行为差异:permission_guard中的set_headless_mode()标记无头上下文——无头 routine 运行中,shell 命令的ask会自动放行(因为它运行在隔离的lhart-沙箱内,且有 exfil/policy 前置拦截),而非 shell 工具的askfail closed自动拒绝(返回headless_denied),因为非 shell 操作没有沙箱爆炸半径保护。

工具收窄强制:overlay/grant 无法授予空泛的 allow

权限规则(.lha/permissions.jsonl或审批卡授予)可以通过commandPrefixcommandRegexargsPattern收窄空泛的allow规则。关键约束(permission_rules.py 的_is_blanket_allow_stamp):

来源为overlaygrant、且目标是bash/process、却不携带任何收窄字段的规则,在加载时被直接拒绝——你不能从覆盖层或会话审批中授予「永远允许 bash」这种空泛权限,只有默认种子可以携带它。

这防止了意外的过度授权(对应 security-model.md 威胁模型中的 H10)。

与权限层的集成边界

需求正确做法
调用被策略拦截,想解释原因Agent 说明{"error": ..., "confirmation_required": True}对应的具体规则,并告知用户被拦的原因
一次性放行某条被拦的命令用户自己输入/grant <command>(会话级,policies_guard在求值前查询 grant)
强制弹出确认(而非硬拦截)某条命令.lha/permissions.jsonl中加ask_user规则
永久封锁某条命令.lha/permissions.jsonl中加deny规则

权限层细节见 permission-model.md。注意权限覆盖层与策略覆盖层共享同一.lha/写保护——它们都只能由用户直接编辑,Agent 无法代写。整个守护链在 security-model.md 中被描述为Layer A(exfil guard)→ Layer C(policies_guard + command_safety)→ Layer D(permission_guard)的顺序执行,第一个提出异议的层就是错误信息里出现的那一层;调用必须先通过硬拒绝地板,软询问层永远不会重新打开安全防护已拦截的调用。

实战工作流

列出当前生效的规则

read(".lha/policies.jsonl")

如果文件不存在,说明用户还没有任何覆盖层规则——当前只有种子生效。种子位于工作区之外,无法通过你的工具读取,此时应依据本文「默认种子」一节的内容描述其覆盖范围,而不是尝试去读它。

用户想新增一条规则

先读取当前覆盖层,确保建议的追加位置正确,然后把精确的 JSON 行交给用户自己添加(你不能写.lha/policies.jsonl):

existing = read(".lha/policies.jsonl") # 可能不存在 → 视为 "" new_rule = {"canonical_tool_name": "bash", "destructive_commands": {"command": ["rm -rf node_modules"]}}

然后告诉用户:"把这一行加到.lha/policies.jsonl(文件不存在就先创建,每行一个 JSON 对象):"后跟json.dumps(new_rule)。保存后下一次工具调用即生效(mtime 热加载)。

用户想删除一条规则

读取覆盖层,定位要删除的行(以覆盖层文件为准的 0 起始行号,忽略空行/注释行),告诉用户要删除的行号与内容。删除前务必与用户确认目标——覆盖层规则通常是为了堵住某个缺口而加的,删错了会重新暴露风险。

完整实操示例:阻止npm publish

用户需求:「从终端封锁npm publish。」

  1. 读取.lha/policies.jsonl(不存在则视为空);
  2. 构造规则:
{"canonical_tool_name": "bash", "destructive_commands": {"command": ["npm publish"]}}
  1. 告诉用户:"我无法自己编辑.lha/policies.jsonl——请把这一行添加进去(文件不存在就先创建):"后跟上面的 JSON;"保存后,下一次npm publish尝试就会被拦截。"

由于destructive_commands子串匹配(大小写不敏感),这条规则同时会拦截npm publish --tag betanpx npm publish等含npm publish子串的命令;如果你希望精确到整条命令,可以改用destructive_commands_regex^npm publish(\s|$)之类的锚定正则(注意正则需通过安全校验)。

注意事项与排障要点

  • 种子只读且够不着:默认种子不可编辑,且位于工作区根之外,无法通过工具读取;描述其覆盖范围请以上文种子内容为准;
  • JSONL 解析失败 fail closed:坏行被跳过并告警——让用户保存新规则后用read读回文件,确认能正常解析;
  • 覆盖层追加不覆盖:新规则只会叠加限制,不会削弱种子;若想放宽某条覆盖层规则,删除对应行即可(种子无法被覆盖层放宽);
  • 沙箱后端差异:部署在沙箱后端时,覆盖层文件存在于沙箱内而非宿主机,读取走 env 接口并按沙箱身份缓存(_overlay.py);本地开发则按 mtime 热加载;
  • 行为验证:仓库的单元测试(如 test_policies_guard.py、test_policies_default_seed.py、test_process_spawn_guardrails.py、test_regex_safety.py)覆盖了规则加载、种子内容、spawn 合成复查与正则安全校验等路径,是理解各边界行为的可靠参考。

总结

policy技能把「破坏性工具调用防护」做成了数据驱动、可审计、Agent 不可自改的体系:默认种子守住灾难底线,.lha/policies.jsonl覆盖层允许用户按工作区追加限制,argv 结构化分类器替代脆弱的子串匹配,/grant/yolo分别提供一次性豁免与会话级宽松,而权限层(Layer D)的收窄强制确保任何授权都无法变成空泛的「永远允许 bash」。理解了这四层(种子、覆盖层、argv 分类、权限层)的交互边界,你就能安全、精准地为 Long Horizon Agent 配置出符合自身风险的命令防线。

【免费下载链接】adk-samplesA collection of sample agents built with Agent Development Kit (ADK)项目地址: https://gitcode.com/GitHub_Trending/ad/adk-samples

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询