agent-guard:给完全访问的AI编程Agent上一道安全闸
灿若繁星先生

把 AI 编程工具切到完全访问模式是很上瘾的事:改文件、跑命令、装依赖一路畅通,不用隔几分钟点一次「允许」。但模式全开之后,中间没有任何东西拦着。agent 在重构时决定「顺手清一下再重装」,在排查问题时试出一条杀伤半径超出预期的命令,都可能直接落地。rm -rf、git push --force、把 .env 内容打出来——这些场景不需要 agent「作恶」,只需要它聪明得过头一次。

我给这件事做了个专门的工具 agent-guard:挂在宿主工具(ZCode / Claude Code / Cursor)的 PreToolUse hook 上,拦截所有 Bash、Write、Edit 和 MCP 工具调用,用一次快速的结构化判断给操作定级,只读的直接放,高危的强制人工确认,哪怕你开着完全访问模式它也拦得住。

项目已开源:GitHub - canyuda/agent-guard,npm install -g @canyuda/agent-guard 即装即用,零依赖,Node 20 以上就行。

起因:一块被清空的 D 盘

做这个工具的直接起因在公司。我们日常的 AI 编程工具是 Cursor,大家开着完全访问模式干活。后来听说有人的 D 盘被 Cursor 误清空了。事后推测:那位多半开着完全访问模式,模型在任务中途跑偏,最后执行了某条清理命令,而这条路上没有任何东西拦它。这件事给我的触动很直接——同样的模式我天天在开,谁也不能保证下一个跑偏的不是自己。

我想找一种方式把这类事故拦在执行前。权限弹窗的粒度是每个操作问一次,用一天点掉上百个确认框,人就开始无脑点「允许」,防呆变成防自己。我要的是一道按操作含义定风险的语义闸门:只读的别烦我,改项目文件的正常过,动到服务状态、git 历史、系统配置、凭证的,停下来等我确认。判断维度是「这次调用在干什么」,而不是「这调用出自哪个工具」——所以网络外发类内置工具(WebFetch/WebSearch)明确不拦,那是隐私合规问题,和本地破坏性操作硬凑在一起只会稀释判断质量。

契机很快出现。大学同学恰巧给我分享了 TypeSafe(typesafe.ai)的 Jev 模型——一个专门做判断、选择、分类的模型,正好能充当这道闸门的判断核心;他自己已经用 Python 做了一个同类工具 cmd-guard(源码在 NieXi/agent-guard),挂在 Claude Code 的 hook 上拦截高风险命令和 MCP 调用,fail-closed。思路既然验证是通的,我决定用 JS 重写一个,零依赖,发布到 npm 上给需要的人用——这就是 agent-guard。它借了 cmd-guard 的 rc 文件约定和 mock 思路,但有两处刻意不同:模型只输出风险等级和概率,阈值与处置全部收在配置里;以及完全访问模式下的输出映射——这里有个深坑,后面单独讲。

一条命令的完整旅程

agent-guard 的本体是一个 stdin/stdout 管道:宿主工具每次要执行 Bash、Write、Edit 或 MCP 工具,PreToolUse 事件会把 {tool_name, tool_input} 推给 hook 进程,hook 返回 allow / ask / deny 三种决策。整条链路:

1
2
3
4
5
6
7
8
9
10
PreToolUse (Bash|Write|Edit|mcp__.*)
→ stdin: { tool_name, tool_input }
→ ① 快路径: MCP 豁免 → 只读白名单 → 破坏黑名单 → 判定缓存
(命中即返回,一次网络都不出)
→ ② 灰色地带: 单次请求问 TypeSafe(Jev) 两个问题
destructive_risk: 破坏性风险 0~3 级
policy_violation: 是否触碰安全红线
→ ③ 三级判定: block / confirm / allow (阈值在 config.json)
→ ④ 输出映射: allow→放行 confirm→ask 或 deny block→deny
→ stdout 决策 + 理由(回流给 agent)

state 构造按工具给足上下文:Bash 给 command 和 cwd,Write/Edit 给 file_path 加内容前 2KB。模型判断的是「这次操作在干什么」,上下文不够,判断就虚。

四层快路径

日常命令大部分是 git status、ls、cat、rg 这类只读操作,为它们出一次网又慢又贵。config.json 里的快路径按序四层:MCP server 豁免名单(按审计数据填,信得过的只读 server 直接放)、只读白名单正则(命中放行)、破坏黑名单正则(rm -rf、format、fork 炸弹、git push --force,命中直接按 block 处理)、判定缓存(同一操作一小时内不重跑推理)。四层都没接住,才走网络。

缓存有个值得说的细节:存的是原始概率,不是判定结果。调阈值的时候——比如把 block 线从 2.5 降到 2.0——历史缓存会按新阈值现算,改动即时生效,不用清缓存。

灰色地带:一次请求问两个问题

快路径没接住的调用才是真正需要判断的。agent-guard 用单次请求并行问 Jev 两个问题。

destructive_risk 是四级阶梯打分:

级 含义 例子
0 纯只读 查看、搜索、查版本
1 受控可逆修改 编辑项目文件、装依赖、构建
2 中度风险 重启或杀服务、批量文件变动、改 git 历史
3 破坏性不可逆 删未提交数据、抹盘、强推覆盖远端

policy_violation 是单条红线判断:这个操作是否触碰强推覆盖远端、格式化抹盘、篡改系统文件(sudoers、注册表、服务配置)、泄露或导出凭证密钥、fork 炸弹、删除备份这一类红线。

两个问题的文案和 criteria 明文写在 lib/typesafe.mjs 里,后续可以随审计数据迭代。

三级判定是个纯函数

概率到决策的映射就十几行:

1
2
3
4
5
6
7
8
9
export function decide(judgments, cfg) {
const t = cfg.thresholds;
const { risk, violation } = judgments;
// risk is a 0~3 expected value; normalize to percentage for display
const reason = `破坏性风险=${Math.round((risk / 3) * 100)}% 红线违反=${Math.round(violation * 100)}%`;
if (risk >= t.block_risk || violation >= t.block_violation) return { level: "block", reason };
if (risk >= t.confirm_risk || violation >= t.confirm_violation) return { level: "confirm", reason };
return { level: "allow", reason };
}

默认阈值:risk ≥ 2.5 或红线违反 ≥ 0.85 直接 block;risk ≥ 1.5 或违反 ≥ 0.5 转 confirm;其余放行。「策略在代码、语义在模型」就落在这里——模型负责把操作映射到 0~3 的连续分值和红线概率,至于什么分数对应什么处置,是 config.json 里的四行数字,随时可调,不用动一行判断逻辑。

config.json:策略全部在明面上

上一节反复出现的 config.json 值得单独交代。安装后首次运行,npm 包内的配置模板会被复制到 ~/.agent-guard/config.json(该文件已存在合法配置时不会覆盖,升级不丢配置);也可以用 AGENT_GUARD_CONFIG 环境变量指向别的路径。加载时与内置默认值深合并——只想改一个阈值,配置文件里写那一个键就够;文件缺失或损坏则回退内置默认,hook 不会崩。每次判定都是新起的进程,配置现读,改完保存即生效,不需要重启宿主工具。

有一东西特意不在 config.json 里:API key。key 只从环境变量 TYPESAFE_API_KEY 或 ~/.agentguardrc 读取,配置文件就算误提交也带不出密钥。

全部字段过一遍:

字段 默认值 说明
model jev-latest 评估用模型,可锁定具体版本
thresholds block:risk 2.5 / violation 0.85;confirm:1.5 / 0.5 三级判定的四条线,risk 是 0~3 连续值
degrade_ask_to_deny true(试点配置已切 false) confirm 级用 ask 还是 deny 承载,见下一节
fastpath.mcp_allowlist [] MCP server 豁免名单,命中直接放行,按审计数据填
fastpath.allowlist 8 条正则 只读白名单,命中放行不出网
fastpath.denylist 4 条正则 破坏黑名单,命中直接按 block 处理
cache.ttl_minutes / max_entries 60 / 500 判定缓存的时效(分钟)与容量上限
state.content_prefix_bytes 2048 Write/Edit 评估携带的文件内容前缀长度,就是前文的「2KB」
log.enabled / log.path true / logs/audit.jsonl 审计日志开关与位置,相对路径落在 ~/.agent-guard/ 数据目录
proxy.enabled / host / port false / — / — TypeSafe API 走 HTTP CONNECT 代理,实现细节见后文

名单是正则,两个例子感受一下写法:黑名单里 \brm\s+[^|;&]*-[a-zA-Z]*r 拦所有带 -r 的 rm(rm -rf /、rm -r build 都算),:\(\)\{.*&.*\};: 匹配 fork 炸弹的经典形态 :(){ :|:& };:。匹配顺序是先白后黑,命中白名单直接放行——所以往白名单里加条目前要想清楚它的危险变体:一条以 cat 开头的命令即使后半截管道里藏着别的东西,也已经被放行了。

完全访问模式下,只有 deny 靠得住

这是整个项目里我最想强调的发现。设计阶段翻资料时看到 Claude Code 的一个 issue:官方文档说 hook 的 deny 在任何权限模式下都强制拦截,但有报告指出 bypassPermissions(完全访问)模式下,hook 返回的 ask 会被静默自动放行——确认框根本不弹。

这意味着在完全访问模式下,confirm 这一级如果用 ask 承载,等于没有。所以 agent-guard 做了输出映射层,并提供 degrade_ask_to_deny 开关:试点期默认 confirm 也用 deny 承载,deny 是任何模式都硬拦的决策,多重拦一道总好过裸奔。后来在本机实测确认了 ZCode 完全访问模式下 ask 能正常弹原生确认框,才把它切回 false。开关保留着,异常场景随时切回。

deny 的理由会回流给 agent,文案写得很明确:

此操作需人工确认后才可执行:请停下向用户说明,等待用户自行执行或明确同意;不要重试、不要改写命令规避本拦截。

最后半句是防 agent 的「求生欲」。拦下来之后它换个写法再试一次,闸门就成了筛子。

fail-closed:评估不了就转人工

安全工具的错误处理只有一个方向:往保守倒。评估服务不可用、超时(8 秒)、没配 key、代理连不上,全部落到同一个去处——转人工:

1
2
3
4
5
6
7
8
} catch (e) { // fail-closed: evaluation unavailable -> hand to human
return {
level: "confirm",
reason: `评估服务不可用(${e?.type ?? "unknown"}),按 fail-closed 转人工`,
source: "error", judgments: null,
duration_ms: Date.now() - start, state,
};
}

hook 路径上零重试。重试在交互式场景是美德,在安全闸门上是漏洞:每次重试都可能撞上服务故障窗口,fail-closed 保证撞上的每一次都变成「等你确认」,而不是「放过去了」。代理链路同理——代理连不上转人工,开了代理开关但 host/port 配错直接报 proxy_config,绝不静默直连绕过代理。

零 npm 依赖,连代理隧道都是手写的

agent-guard 的 npm 依赖是零。所有能力都来自 Node 内置模块:fetch 直连 TypeSafe API,正则名单,sha1 缓存键,JSONL 审计追加写。

代价最明显的地方在代理。Node 内置 fetch 不读 HTTP(S)_PROXY 环境变量,官方的 undici ProxyAgent 又要装包,违反零依赖约束。最后的方案是手写 HTTP CONNECT 隧道:net 连代理,TLS 套上去,一条 deadline 覆盖连代理、CONNECT、TLS 握手、请求响应全程,超时统一按 AbortError 分类。

换来的是安装体验:全局装一个包,没有依赖树,没有 supply chain 面扩大。一个安全工具自己拖一堆第三方依赖,本身就值得警惕。

装一个试试

1
2
npm install -g @canyuda/agent-guard
agent-guard setup # 四步向导:key → 选工具 → 作用域 → 写入

setup 会自动探测已配置的 key(环境变量 / Windows 注册表 / 已有 rc 文件)和已安装的宿主工具,写 hook 配置前自动备份 .bak,只增改 agent-guard 自己的条目,重复执行幂等,--dry-run 可以只看计划不落盘。

也可以绕过 hook 手动评估一条命令:

1
2
3
4
5
6
agent-guard check --cmd "docker compose down"
# 输出: level=<allow|confirm|block> rendered=<决策> source=<来源> 破坏性风险=<%> 红线违反=<%>

# 仓库内离线 mock(固定判定,无需 key)
node check.mjs --mock --cmd "git reset --hard HEAD~1"
# → level=confirm rendered=deny source=api 破坏性风险=77% 红线违反=40% (6ms)

每次判定的完整依据都落在 logs/audit.jsonl:时间、工具、命令摘要(截断到 200 字符)、判定来源(fastpath_*/cache/api/error)、两级概率、最终决策、耗时。调阈值、查误判、填 MCP 豁免名单,都拿这份日志说话,不靠印象。

测试目前 74 个(node:test,全部离线,550ms 跑完),覆盖 config、state、fastpath、decide、emit、typesafe、judge 和 hook 集成。项目现在处于试点期:工作区级挂载正式 hook,观察期的退出标准是零崩溃、零高危误放、误伤率低于 10%,并依据审计数据填出 MCP 豁免名单,达标后迁移到用户级配置全局启用。

小结

  • 语义闸门:按操作含义定风险,四层快路径让大部分调用不出网,日常开销毫秒级
  • 决策权分离:模型只回答「风险多高」,阈值与处置在 config.json;缓存存原始概率,调阈值即时生效
  • 完全访问模式的现实:deny 是唯一硬闸门,ask 可能被静默放行,输出映射层为此而生
  • fail-closed:评估不了就转人工,宁可烦人,不可漏放

设计细节在仓库 docs/specs/ 里,两轮 grilling 拷问出来的 v2 spec 连「不做什么」都写得明白。欢迎来 GitHub - canyuda/agent-guard 提 issue、提 PR。