LinOnward / Agent TutorialAgent Tutorial
章节10 / 18
教程整体目标

通过逐章可运行的增量,理解 Agent 的模型、上下文、任务状态、规划、工具、Skills 与 Agent Loop 等关键组件,并从零构建一个由 Harness 约束、不依赖 Agent 框架的可用 Agent。

09

权限与安全

用策略层限制副作用,并在必要时请求人工确认。

本页目录
本章任务验证允许的命令进入审批、未授权参数被拒绝,并且审批前不会启动进程。
章节目标为读取、写入、执行和外部副作用建立确定的权限决策与人工批准边界。
开始之前

Agent 已经能够调查、修改并验证一个小型代码任务。

本章涉及文件
  • src/tool.ts
  • src/policy.ts
  • src/execute-tool.ts
  • src/index.ts

Step 1给每个工具标记风险等级

前面的写入章已经加入 read | write | execute。现在把 external 加入 ToolEffect,并将参数解析与真正执行拆开;这样 Executor 才能在 Schema 校验之后、产生副作用之前调用策略层。

typescript
export type ToolEffect = "read" | "write" | "execute" | "external";export interface PreparedToolCall {  input: unknown;  execute(context: ToolContext): Promise<unknown>;}export interface RegisteredTool {  name: string;  description: string;  effect: ToolEffect;  parameters: Record<string, unknown>;  prepare(input: unknown): PreparedToolCall;}export type ToolExecutionResult =  | { type: "observation"; callId: string; output: string }  | { type: "waiting"; requestId: string; reason: "approval_required" };

相应更新 defineToolprepare 内先运行 definition.schema.parse(input),然后返回保存了解析结果的对象;其 execute(context) 闭包再调用原来的 definition.execute(parsed, context)。同时把 execute-tool.ts 中直接调用 tool.run(...) 的位置替换成 const prepared = tool.prepare(rawInput),但此时还不能执行它。

Step 2按参数与资源应用授权策略

创建 src/policy.ts。策略输入必须包含已经通过 schema 校验的参数与运行上下文;只看工具名或 effect 无法判断实际目标。决策包含允许、拒绝和等待人工批准三种状态。

typescript
export type PolicyDecision =  | { type: "allow"; scope: string }  | { type: "deny"; reason: string }  | { type: "ask"; request: ApprovalRequest };export interface ApprovalRequest {  id: string;  actionDigest: string;  summary: string;  risks: string[];  expiresAt: string;}export interface PolicyContext {  cwd: string;  realWorkspaceRoot: string;  allowedArgv: string[][];  network: "disabled" | "enabled";}function isRunCommandInput(input: unknown): input is { command: string; args: string[] } {  return (    typeof input === "object" &&    input !== null &&    "command" in input &&    typeof input.command === "string" &&    "args" in input &&    Array.isArray(input.args) &&    input.args.every((argument) => typeof argument === "string")  );}export function authorize(  tool: RegisteredTool,  input: unknown,  context: PolicyContext,): PolicyDecision {  if (tool.effect === "read" || tool.effect === "write") {    return authorizeWorkspacePath(tool, input, context.realWorkspaceRoot);  }  if (tool.name === "run_command") {    if (!isRunCommandInput(input)) return { type: "deny", reason: "invalid_command_input" };    const argv = [input.command, ...input.args];    const exactMatch = context.allowedArgv.some((allowedArgv) => arraysEqual(argv, allowedArgv));    if (!exactMatch) return { type: "deny", reason: "argv_not_allowed" };    return {      type: "ask",      request: createApprovalRequest({ tool, input, cwd: context.cwd, network: false }),    };  }  if (tool.effect === "external" || context.network !== "disabled") {    return { type: "deny", reason: "network_or_external_effect_not_allowed" };  }  return { type: "deny", reason: "no_matching_policy" };}

pnpmnode 与项目脚本都能执行任意代码并访问网络,所以“程序名在白名单”不是安全边界。示例默认关闭执行进程的 network,并只匹配 Harness 从验证计划生成的完整 allowedArgv;即使完全匹配,也进入 ask,向操作者显示 cwd、完整参数、影响和风险。network: "disabled" 只是策略输入,不会自动隔离进程;Executor 必须在真正禁网的容器或 OS sandbox 中启动命令。若部署环境无法提供该隔离,策略应拒绝运行不可信仓库代码。

authorize(tool, prepared.input, policyContext) 放在 prepare 之后、prepared.execute 之前。deny 转为 observation;ask 则持久化 ApprovalRequest、把运行置为 waiting 并停止执行。批准凭证必须绑定 actionDigest = hash(tool + canonical input + cwd + network policy)、一次性使用且有过期时间;恢复后若参数改变,必须重新请求,不能复用旧批准。

typescript
const prepared = tool.prepare(rawInput);const decision = authorize(tool, prepared.input, policyContext);if (decision.type === "deny") return observation("policy_denied", decision.reason);if (decision.type === "ask") {  const grant = await store.consumeApprovalGrant(state.runId, decision.request.actionDigest);  if (!grant) {    await store.saveApprovalRequest(state.runId, decision.request);    return {      type: "waiting" as const,      requestId: decision.request.id,      reason: "approval_required" as const,    };  }}return executeInSandbox(() => prepared.execute(toolContext), { network: "disabled" });

consumeApprovalGrant 必须在一个事务中检查 digest、过期时间和未使用状态,然后立即标记已消费。CLI 或 UI 收到 ApprovalRequest 后由人明确批准或拒绝;Harness 恢复 waiting 运行时再次走同一授权入口。

Step 3验收权限策略

先用固定输入直接验收策略层,不依赖模型临场选择。至少准备三组调用:允许但需要批准的测试命令、参数不匹配的命令,以及请求网络或外部副作用的工具。

typescript
expect(authorize(runCommandTool, allowedTest, context).type).toBe("ask");expect(authorize(runCommandTool, changedArgs, context)).toMatchObject({  type: "deny",  reason: "argv_not_allowed",});expect(authorize(externalTool, externalInput, context).type).toBe("deny");

然后验证一次完整的等待与恢复:第一次执行产生 ApprovalRequest 并进入 waiting,进程尚未启动;写入与 actionDigest 匹配的一次性批准后恢复,第二次授权才能进入 sandbox。综合工程任务会在完成规划和 Skills 后,由完整 Agent Loop 统一验收。