
用 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 SDKThe AI Toolkit for TypeScript官方仓库 packages/policy-opa/examples/git-in-bash 为例完整讲解如何用 Open Policy AgentOPA策略实现“传递式强制管控”无论模型是通过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 天生就是对抗性解析的对象所以这里采取的策略是“无法证明安全 拒绝”cant prove its 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.regoOPA 单元测试allow / deny / 不可解析路径parse-git-invocation.tstoInput使用的 fail-closed 命令解析器parse-git-invocation.test.ts解析器的 Vitest 单元测试git-in-bash.ts可运行 demo把策略接进generateTextREADME.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 closedcd /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→ 全部null3. 运行端到端 demoOPA 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.jsonpackage.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 的decisionbash工具的toInput即 parse-git-invocation.ts 中的bashCommandToInput把{ command }变成策略真正裁决的动作形状。README 给出了完整的对照表command派生出的 OPA input决策git status{ kind: git, subcommand: status, args: [] }allowgit remote -v{ kind: git, subcommand: remote, args: [-v] }allowgit remote update{ kind: git, subcommand: remote, args: [update] }denygit branch -D feature{ kind: git, subcommand: branch, args: [-D, ...] }denygit clone https://x{ kind: git, subcommand: clone, args: [...] }denycd /tmp git clone https://x{ kind: bash, command: cd /tmp ... }denygit 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与generateTextgit-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的OPAClienturl通常指向本地 OPA 服务默认端口 8181headers可用于 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 走bashCommandToInputgit 直接把args[0]当作 subcommand。这正是“同一份策略、两个表面”的实现方式。从 opa-policy.ts 的实现可以看到opaPolicy返回一个ToolApprovalConfiguration可直接传给generateText/streamText/ToolLoopAgent的toolApproval其内部做了两件关键事默认输入形状不传toInput时OPA 收到的是{ tool: { name }, args, messages, runtimeContext }DefaultOpaInputRego 规则可以直接读input.tool.name、input.args等字段fail-closed 兜底通过 evaluate-policy.ts 把后端错误OPA 不可达、WASM 故障、路径错误捕获为值而不是抛出一旦evaluatePolicy返回ok: false审批结果就是denied理由为policy evaluation failed: ...。注释明确说明这样做的原因后端错误绝不能解读成“没有意见”必须拒绝让模型看到结构化结果而不是让异常打断整个运行。4. 跑通generateTextasync 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时返回undefinedSDK 会回退到默认的放行行为。它特别适合“策略文件按环境配置”的场景——生产环境加载 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),仅供参考