用 OPA 策略为 AI Agent 的 bash 工具做传递式管控:以“只读 git”为例的完整实战
【免费下载链接】aiThe AI Toolkit for TypeScript. From the creators of Next.js, the AI SDK is a free open-source library for building AI-powered applications and agents项目地址: https://gitcode.com/GitHub_Trending/ai/ai
导读
当 AI Agent 手里握着一个粗粒度的bash工具(输入只有一个{ command })时,模型理论上可以在这个 shell 里执行任何 git 操作——包括git clone、git push这类有副作用、有网络写行为的命令。本文以 AI SDK(The AI Toolkit for TypeScript)官方仓库 packages/policy-opa/examples/git-in-bash 为例,完整讲解如何用 Open Policy Agent(OPA)策略实现“传递式强制管控”:无论模型是通过bash工具间接执行 git,还是直接调用一个细粒度的git工具,同一份 Rego 策略都能把它限制为只读 git,其余一律默认拒绝。读完本文,你将掌握toInput归约逻辑、fail-closed 命令解析、subcommand 级白名单的陷阱,以及如何在generateText中通过toolApproval接入 OPA 决策。
场景与核心思想:为什么“在 bash 里管 git”很难
bash工具(例如 vercel-labs/bash-tool 这类实现)的接口极度粗糙——模型传入{ command },工具就把它丢给 shell 执行。任何 git 操作都可以伪装成一段 shell 命令:
- 直接写:
git clone https://example.com/x.git - 加前缀:
cd /tmp && git clone https://example.com/x.git - 管道混淆:
git status | sh - 命令替换:
git $(echo clone) https://x
这个例子的关键思路(见 README.md)在于:调度器(dispatcher)的toInput把 bash 命令归约成一个逻辑动作,凡是无法归约成一次干净 git 调用的命令,默认一律拒绝。bash 天生就是对抗性解析的对象,所以这里采取的策略是“无法证明安全 = 拒绝”(can't prove it's safe means deny),而不是“看起来没危险 = 放行”。
更妙的是,这套策略同时约束两个入口:粗粒度bash工具和细粒度git工具。两者的 OPA 输入形状被统一成{ kind, subcommand, args },因此同一份 Rego 规则同时管辖两条面(见 policy.rego 的注释)。
示例文件全景
本示例位于仓库packages/policy-opa/examples/git-in-bash/目录,包含 6 个文件,分工明确:
| 文件 | 作用 |
|---|---|
policy.rego | 策略本体:只读 git 白名单 + 默认拒绝 |
policy_test.rego | OPA 单元测试(allow / deny / 不可解析路径) |
parse-git-invocation.ts | toInput使用的 fail-closed 命令解析器 |
parse-git-invocation.test.ts | 解析器的 Vitest 单元测试 |
git-in-bash.ts | 可运行 demo,把策略接进generateText |
README.md | 本文所依据的说明文档 |
三层验证:先跑通测试,再跑端到端
1. 运行策略测试(无需 Node 依赖)
OPA 自带测试运行器,直接指向示例目录即可:
opa test packages/policy-opa/examples/git-in-bash预期输出:
PASS: 11/11这 11 条测试覆盖了 policy_test.rego 中的全部用例:allow 类(status、log --oneline、remote -v、裸remote、裸branch)与 deny 类(branch -D、remote update、clone、push、remote add、以及cd /tmp && git clone这类不可归约的 bash 命令)。
2. 运行解析器测试
解析器是纯 TypeScript,走@ai-sdk/policy-opa包的 Node 测试:
pnpm --filter @ai-sdk/policy-opa test:node parse-git-invocation测试用例见 parse-git-invocation.test.ts,覆盖四条路径:
- 干净的单次 git 调用:
git status→{ subcommand: 'status', args: [] };git log --oneline -n 5→{ subcommand: 'log', args: ['--oneline', '-n', '5'] } - 裸 git 无子命令:
git→null - 非 git 程序:
ls -la、/usr/bin/git status→null(注意:绝对路径调用 git 也被拒绝,避免绕过白名单) - 复合与混淆命令一律 fail closed:
cd /tmp && git clone https://x、git status; git clone https://x、git status | sh、git $(echo clone) https://x、git status \whoami`、git status > /tmp/out、git status \\n clone→ 全部null`
3. 运行端到端 demo
OPA HTTP 后端是可选 peer 依赖,demo 需要先装依赖、再起 OPA 服务器、最后运行:
pnpm add @open-policy-agent/opa opa run --server --addr :8181 packages/policy-opa/examples/git-in-bash pnpm tsx packages/policy-opa/examples/git-in-bash/git-in-bash.ts预期输出:
bash: git status allowed → ran: git status bash: git log --oneline allowed → ran: git log --oneline bash: git remote -v allowed → ran: git remote -v bash: git clone https://example.com/x.git DENIED → git clone is not permitted (read-only git only) bash: cd /tmp && git clone ... DENIED → command not permitted by policy git status allowed → git status: ok git clone https://example.com/x.git DENIED → git clone is not permitted (read-only git only)注意@ai-sdk/policy-opa的package.json(package.json)中把@open-policy-agent/opa与@open-policy-agent/opa-wasm都声明为可选 peer 依赖,因此用 HTTP 后端时必须显式pnpm add @open-policy-agent/opa,否则 http-policy-client.ts 会在运行时动态import失败并抛出明确错误。
决策链路:从{ command }到 OPA 的decision
bash工具的toInput(即 parse-git-invocation.ts 中的bashCommandToInput)把{ command }变成策略真正裁决的动作形状。README 给出了完整的对照表:
command | 派生出的 OPA input | 决策 |
|---|---|---|
git status | { kind: "git", subcommand: "status", args: [] } | allow |
git remote -v | { kind: "git", subcommand: "remote", args: ["-v"] } | allow |
git remote update | { kind: "git", subcommand: "remote", args: ["update"] } | deny |
git branch -D feature | { kind: "git", subcommand: "branch", args: ["-D", ...] } | deny |
git clone https://x | { kind: "git", subcommand: "clone", args: [...] } | deny |
cd /tmp && git clone https://x | { kind: "bash", command: "cd /tmp && ..." } | deny |
git status \| sh | { kind: "bash", command: "git status \| sh" } | deny |
这里有一个值得注意的细节:subcommand 级白名单太粗。branch和remote只有在“列表形态”下才是只读的——git remote -v允许,但git remote update(会 fetch 网络)和git branch -D(删除分支)必须拒绝。所以策略在 subcommand 白名单之外,还额外加了一层对参数的“列表形态”检查(见下文 Rego 分析)。
对照表最后两行永远不会变成git动作:解析器一旦看到 shell 元字符(&&、|、;、重定向、子 shell、命令替换等)就返回null,命令于是以kind: "bash"交给 OPA,被策略的默认拒绝兜住。
Rego 策略逐行拆解:白名单 + 列表形态 + 默认拒绝
policy.rego 全文只有 56 行,是“fail-closed 三层防线”的极简范例:
第一层:纯只读子命令白名单
package agent.action import rego.v1 git_read_only := {"status", "log", "diff", "show"}status、log、diff、show在任何形式下都是只读的,直接进白名单。规则包名agent.action与 demo 中的 OPA 入口路径agent/action/decision一一对应。
第二层:列表形态检查(关键陷阱)
git_listing := {"branch", "remote"} listing_flags := {"-v", "--verbose", "-l", "--list", "-a", "--all"} decision := {"decision": "allow"} if { input.kind == "git" git_listing[input.subcommand] is_listing } is_listing if count(input.args) == 0 is_listing if { count(input.args) == 1 listing_flags[input.args[0]] }branch/remote只有两种形态被放行:无参数(git branch、git remote),或带且仅带一个列表旗标(-v、--verbose、-l、--list、-a、--all)。git remote update、git remote show(走网络)、git branch -D feature全部在is_listing处失败,落入默认拒绝。这正是 README 强调的“allowlist 时的 gotcha”——一旦子命令带上了会变异的旗标,仅靠子命令名是不够的,必须检查参数形态。
第三层:默认拒绝 + 具体原因
default decision := {"decision": "deny", "reason": "command not permitted by policy"} decision := {"decision": "deny", "reason": msg} if { input.kind == "git" not git_read_only[input.subcommand] not git_listing[input.subcommand] msg := sprintf("git %s is not permitted (read-only git only)", [input.subcommand]) }默认拒绝覆盖了clone、push、pull、fetch、reset、变异的branch/remote形态,以及所有解析器不愿担保的kind: "bash"输入。同时,对于明确识别出的 git 子命令,会给出人类可读的拒绝原因(如git clone is not permitted (read-only git only))——这正是 demo 输出里 DENIED 后那串文案的来源,也让 Agent 模型能基于结构化原因自行调整行为。
解析器与 fail-closed 语义:宁可误杀,不可漏放
parse-git-invocation.ts 是整个示例的灵魂,全部逻辑不过 27 行:
const SHELL_METACHARACTERS = /[;&|<>`$(){}\\\n]/; export function parseGitInvocation(command: string): GitInvocation | null { if (SHELL_METACHARACTERS.test(command)) { return null; } const tokens = command.trim().split(/\s+/); if (tokens[0] !== 'git' || tokens.length < 2) { return null; } const [, subcommand, ...args] = tokens; return { subcommand, args }; }SHELL_METACHARACTERS正则把;、&、|、<、>、反引号、$、(、)、{、}、反斜杠和换行全部视为危险信号——任何一个出现,就意味着命令不止“一次程序调用”(可能是链式、管道、重定向、子 shell、命令替换或续行),解析器拒绝担保并返回null。null就是 fail-closed 信号。
值得注意的取舍:cd /tmp && git clone、git status | sh、甚至/usr/bin/git status都会返回null。源码注释说得很清楚:“deliberately strict … Tighten or widen to taste, but err towardnull”——解析器刻意保守,它不是完整的 shell 语法解析器,宁可在边缘情况误杀,也绝不为对抗性命令冒险。
紧接着的bashCommandToInput把null映射为{ kind: 'bash', command },交给策略默认拒绝:
export function bashCommandToInput(command: string) { const git = parseGitInvocation(command); return git ? { kind: 'git', subcommand: git.subcommand, args: git.args } : { kind: 'bash', command }; }端到端接线:opaPolicy、httpPolicyClient与generateText
git-in-bash.ts 展示了把策略接入 AI SDK 的完整流程,核心是toolApproval机制。
1. 构造 OPA HTTP 客户端
import { httpPolicyClient } from '../../src/opa/http-policy-client'; const client = httpPolicyClient({ url: 'http://localhost:8181' });httpPolicyClient 底层使用@open-policy-agent/opa的OPAClient,url通常指向本地 OPA 服务(默认端口 8181),headers可用于 Styra DAS / EOPA 之类的鉴权场景。它采用惰性加载:真正第一次evaluate时才动态import依赖,未安装依赖时给出可读错误。
2. 定义两个工具(一条策略管两条面)
const bash = tool({ description: 'Run a shell command', inputSchema: jsonSchema<{ command: string }>({ type: 'object', properties: { command: { type: 'string' } }, required: ['command'], }), execute: async ({ command }) => `ran: ${command}`, }); const git = tool({ description: 'Run a git subcommand', inputSchema: jsonSchema<{ args: string[] }>({ ... }), execute: async ({ args }) => `git ${args.join(' ')}: ok`, });demo 中这两个execute是模拟实现(只返回字符串,不真执行命令),方便无副作用地演示决策流。
3. 构造两个opaPolicy审批器,共享同一 OPA 入口
const bashApproval = opaPolicy({ client, path: 'agent/action/decision', toInput: ({ toolCall }) => bashCommandToInput((toolCall.input as { command: string }).command), }); const gitApproval = opaPolicy({ client, path: 'agent/action/decision', toInput: ({ toolCall }) => { const args = (toolCall.input as { args: string[] }).args; return { kind: 'git', subcommand: args[0], args: args.slice(1) }; }, });两个审批器指向同一个 Rego 入口agent/action/decision,唯一区别是toInput如何从toolCall.input归约出逻辑动作:bash 走bashCommandToInput,git 直接把args[0]当作 subcommand。这正是“同一份策略、两个表面”的实现方式。
从 opa-policy.ts 的实现可以看到,opaPolicy返回一个ToolApprovalConfiguration(可直接传给generateText/streamText/ToolLoopAgent的toolApproval),其内部做了两件关键事:
- 默认输入形状:不传
toInput时,OPA 收到的是{ tool: { name }, args, messages, runtimeContext }(DefaultOpaInput),Rego 规则可以直接读input.tool.name、input.args等字段; - fail-closed 兜底:通过 evaluate-policy.ts 把后端错误(OPA 不可达、WASM 故障、路径错误)捕获为值而不是抛出,一旦
evaluatePolicy返回ok: false,审批结果就是denied,理由为policy evaluation failed: ...。注释明确说明这样做的原因:后端错误绝不能解读成“没有意见”,必须拒绝,让模型看到结构化结果而不是让异常打断整个运行。
4. 跑通generateText
async function runBash(label: string, command: string) { const result = await generateText({ model: mockModelCalling('bash', JSON.stringify({ command })), prompt: label, stopWhen: isStepCount(3), tools: { bash }, toolApproval: bashApproval, }); report(`bash: ${command}`, result); }demo 使用ai/test的MockLanguageModelV3模拟模型(第一步发出工具调用、第二步停止),并配合isStepCount(3)限制步数,因此无需真实 API key 即可复现完整决策链路。README 注明“Swap the mock model for a real provider in one line”——换成真实模型只需替换model参数。
5. 结果报告
report函数从responseMessages中找到tool-result,若输出是字符串则判定 allowed,若output.type === 'execution-denied'则取reason输出 DENIED——这就是上文预期输出中allowed → ran: git status与DENIED → git clone is not permitted (read-only git only)两行文案的生成方式。
决策归一化与扩展用法
opaPolicy最后把 OPA 原始结果交给normalizeOpaDecision统一成 SDK 审批状态。包内的 policy-decision.ts 定义了归一化后的PolicyDecision类型,只有四种:
{ type: 'approved'; reason?: string }{ type: 'denied'; reason?: string }{ type: 'user-approval'; reason?: string }{ type: 'not-applicable' }
此外,opa-policy.ts 还导出了optionalOpaPolicy:当client为undefined时返回undefined,SDK 会回退到默认的放行行为。它特别适合“策略文件按环境配置”的场景——生产环境加载 WASM 策略、本地开发不加载,例如:
const wasm = process.env.POLICY_WASM_PATH ? await readFile(process.env.POLICY_WASM_PATH) : undefined; const client = wasm ? await wasmPolicyClient({ wasm }) : undefined; const toolApproval = optionalOpaPolicy({ client, path: 'agent/call/decision' });@ai-sdk/policy-opa的源码目录(packages/policy-opa/src)中还包含wasm-policy-client(在进程内用 WASM 评估策略,免去外部服务)、shadow(影子模式,先观察策略判定而不拦截)、wrap-mcp-tools(包装 MCP 工具)等机制,读者可以继续深入。
诚实的局限:策略门控的边界在哪里
README 的最后一部分非常坦率地指出了这个方案的边界,值得每一位做 Agent 安全的读者牢记:
本方案门控的是模型“请求”运行的那条命令。
它无法阻止一个已被放行的工具执行超出其输入描述之外的真实副作用。同时,parseGitInvocation是刻意保守的简化解析器,不是完整的 shell 语法解析器——总存在解析器无法预见的边界情形。
因此,对于不可信执行环境,正确的姿势是:把本策略与带外(out-of-band)沙箱配合使用,把沙箱边界视为真正的信任前沿(trust frontier)。策略层负责“模型不被允许请求危险操作”,沙箱层负责“即便策略被绕过,进程也无法产生真实危害”,两层缺一不可。这正是生产级 Agent 安全的正确分层思维。
【免费下载链接】aiThe AI Toolkit for TypeScript. From the creators of Next.js, the AI SDK is a free open-source library for building AI-powered applications and agents项目地址: https://gitcode.com/GitHub_Trending/ai/ai
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考