教程整体目标
通过逐章可运行的增量,理解 Agent 的模型、上下文、任务状态、规划、工具、Skills 与 Agent Loop 等关键组件,并从零构建一个由 Harness 约束、不依赖 Agent 框架的可用 Agent。
权限与安全
用策略层限制副作用,并在必要时请求人工确认。
预计 35 分钟本页目录
Agent 已经能够调查、修改并验证一个小型代码任务。
src/tool.tssrc/policy.tssrc/execute-tool.tssrc/index.ts
Step 1给每个工具标记风险等级
前面的写入章已经加入 read | write | execute。现在把 external 加入 ToolEffect,并将参数解析与真正执行拆开;这样 Executor 才能在 Schema 校验之后、产生副作用之前调用策略层。
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" };相应更新 defineTool:prepare 内先运行 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 无法判断实际目标。决策包含允许、拒绝和等待人工批准三种状态。
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" };}pnpm、node 与项目脚本都能执行任意代码并访问网络,所以“程序名在白名单”不是安全边界。示例默认关闭执行进程的 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)、一次性使用且有过期时间;恢复后若参数改变,必须重新请求,不能复用旧批准。
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验收权限策略
先用固定输入直接验收策略层,不依赖模型临场选择。至少准备三组调用:允许但需要批准的测试命令、参数不匹配的命令,以及请求网络或外部副作用的工具。
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 统一验收。