Day 5:Bash 工具 + 权限引擎
Day 4 让 Agent 能安全改文件。今天给它装上『跑命令』的能力——但每条命令都要先过一道权限闸门:危险命令直接拒、普通命令弹预览要你按 y、文件编辑可以切到 acceptEdits / plan 模式。模型只看到一个 bash 工具,所有策略都藏在 PermissionRequest → decide_permission 这条链路里。
Day 4 让 Agent 能安全改文件。每次写盘前 harness 出 diff、让你按 y/N、自动备份旧内容——一套完整的读写保护链。但 Agent 还不能跑命令。模型想跑个 pytest 看测试、跑个 git status 看仓库状态,现在没工具。Day 3 的 grep 是 Python 实现,不是让模型调 shell。
今天给 Agent 装两条新能力。一是 bash 工具:模型能调任意 shell 命令,但执行前要过一道权限引擎——危险命令拦截、命令预览确认、--permission-mode 三模式(default / acceptEdits / plan)。二是 ask_user_question 一等工具:模型不止能"做",还能"问你",弹一个 numbered menu 等你选。
代码约 300 行,新增 4 个文件(bash_runner.py / permissions.py / bg_manager.py / prompt_ui.py),改 3 个老文件(tools.py / agent.py / cli.py),删 1 个老文件(diff_ui.py,实现搬到 prompt_ui.py 后删除——v1 先留 re-export 垫片让老 import 还能跑,v2 切完再删)。无新依赖——subprocess、threading、re 全是标准库。
Day 5 主视觉:Bash + Permission Engine
先看这张 Agent Logic Map。它不做逐帧执行回放,只抓 Day 5 最容易混在一起的四条线:tool_use 如何变成 PermissionRequest,bash 如何先过危险命令拦截和确认,--permission-mode 如何改变 allow / ask / deny,以及 ask_user_question 和后台 bash 为什么仍然属于 harness 分支。
今天从 Day 4 的 agent-code 项目继续改。仓库里的 packages/day-04-safe-edit/ 是 Day 4 的参考答案快照(Day 5 终态参考 demo/agent_code/)。
起手:今天的起点
Day 4 给了 Agent 两个写工具——file_write 整文件覆盖、file_edit 字符串替换。harness 在落盘前会渲染 diff、等用户按 y、自动备份旧内容。但拦截块是 agent.py 里一段 if call.name in ("file_write", "file_edit"):——只对编辑生效,且每加一种新工具就要加一个分支。
今天围绕这个拦截块加四件事:
- 新文件
bash_runner.py:用subprocess.run跑 shell 命令,cwd 锁项目目录、env 最小集合、超时 + 输出截断。 - 新文件
permissions.py:PermissionRequest描述一次工具调用,PermissionDecision(allow | ask | deny)是 harness 的决策;decide_permission()是统一入口。 - 新文件
prompt_ui.py:从 Day 4 的diff_ui.py迁出render_diff+confirm_edit,再加confirm_command+confirm_tool_use+prompt_single_choice。diff_ui.py分两步处理:v1 改成从prompt_uire-export 的垫片让老 import 不动也能跑,v2 切完调用点后把diff_ui.py整个删掉。 - 新文件
bg_manager.py:subprocess.Popen+ daemon 线程,立刻返回background_id,输出落到.bg/<id>.out/.err。
主线分四步:v1 先把 bash 跑通,v2 把确认逻辑抽成权限引擎,v3 加 ask_user_question,v4 接后台执行。
v1:bash 同步执行 + 命令预览 + y/N 确认
先把最直接的场景跑通:模型想跑一条命令,harness 把命令打印出来让你看过,你按 y 才执行。和 Day 4 v1 的 file_write 思路一样——拦截在 tools.run 之前,先预览再确认。但 bash 有个关键区别:命令越界检查靠的不是 resolve_in_cwd(命令是 shell 字符串,不是文件路径),而是 subprocess.run(cwd=ctx.cwd) 把进程锁在项目目录里。
1.1 新建 agent_code/bash_runner.py
bash 执行抽象成独立模块。工具函数只做薄包装,实际执行逻辑在 runner 里——这样权限引擎和工具函数都能复用它。完整 41 行看 v1 DiffCard,核心两块:
# bash 执行的环境变量最小集合——不让宿主机的敏感变量漏进子进程
_MINIMAL_ENV = {
"PATH": os.environ.get("PATH", "/usr/bin:/bin"),
"HOME": os.environ.get("HOME", ""),
"USER": os.environ.get("USER", ""),
"SHELL": os.environ.get("SHELL", "/bin/bash"),
}
def run_sync(command: str, cwd: Path, timeout: int = 30) -> str:
"""同步执行 shell 命令。cwd 锁定在项目目录,超时后杀进程。"""
try:
proc = subprocess.run(
command,
shell=True,
cwd=str(cwd),
env=_MINIMAL_ENV,
capture_output=True,
timeout=timeout,
)
except subprocess.TimeoutExpired:
return f"error: command timed out after {timeout}s"
...
truncated = truncate_output(output.strip(), max_chars=12000)
if proc.returncode != 0:
return f"exit code {proc.returncode}\n{truncated}"
return truncated if truncated else "(no output)"shell=True 是为了让模型写的命令和读者在终端里敲的一致。这个选择有安全代价——任何能绕过权限引擎的 shell 注入都可能直接执行。教学版的安全策略在 v2 的权限引擎里补:危险命令 regex 拦截 + 用户确认。
1.2 新建 agent_code/prompt_ui.py
Day 4 的 diff_ui.py 里只有 render_diff 和 confirm_edit。今天还要再加 confirm_command(v1 用)、confirm_tool_use(v2 用)、prompt_single_choice(v3 用)——里面 80% 都不是 diff 了,文件名得跟着改。把它搬成 prompt_ui.py。
模块搬家分两步:v1 这一版把实现挪到 prompt_ui.py,diff_ui.py 改成 4 行 re-export 垫片,让 v1 的 agent.py(还在写 from .diff_ui import ...)原封不动也能跑。v2 改 agent.py 切到 from .prompt_ui import ...,切完整个包里没人再 import diff_ui,把它整个删掉。本版不一次到位是因为 v1 的目标是 bash 拦截块跑通,再顺手改 agent.py 那行 import 会让本版的 diff 多一处和 bash 无关的改动。
diff_ui.py 整个文件替换成下面这 4 行(原来的 import difflib / import typer / def render_diff / def confirm_edit 全删):
from __future__ import annotations
# Day 5 v1:render_diff 和 confirm_edit 的实现迁到了 prompt_ui.py。
# 这里只是个 re-export 垫片,让 v1 的 agent.py 不用改 import 也能跑。v2 会把它删掉。
from .prompt_ui import confirm_edit, render_diff
__all__ = ["confirm_edit", "render_diff"]confirm_command 的函数体很短:
def confirm_command(command: str) -> bool:
"""让用户确认是否执行这条 bash 命令,默认不执行。"""
return typer.confirm(f"Run this command?", default=False)1.3 改 agent_code/tools.py:新增 bash、git_status、git_diff
三个新工具。bash 是核心——调 bash_runner.run_sync。git_status 和 git_diff 是只读便利工具,内部也是 subprocess,但 v2 的权限引擎会把它们放进 _READONLY_TOOLS 白名单——降低 ask 弹窗噪音。
顶部 import 区追加:
from .bash_runner import run_sync as _bash_run_syncfile_edit 之后、class ToolRegistry 之前插入三个工具函数。bash 工具函数本身很薄:
def bash(args: dict[str, Any], ctx: ToolContext) -> str:
"""执行 shell 命令。前置校验和用户确认在 agent.py 拦截块完成。"""
command = args.get("command", "")
if not command:
return "error: missing required argument 'command'"
timeout = int(args.get("timeout", 30))
background = bool(args.get("background", False))
# v1 只做同步;v4 接 background=True 分支
if background:
return "error: background mode not implemented yet (coming in v4)"
return _bash_run_sync(command, ctx.cwd, timeout=timeout)完整三个工具函数 + 三处 registry.register(...) 看 v1 DiffCard。注意 bash 的 input_schema 只暴露 command / timeout / background 三个字段——危险命令拦截、用户确认、模式切换全都不暴露给模型。
1.4 改 agent_code/agent.py:在拦截块加 bash 分支
Day 4 的拦截块只认 file_write 和 file_edit。v1 在这个 if 后面加一个 elif 分支:识别 bash 工具,打印命令预览,让用户确认。顶部 import 追加 from .prompt_ui import confirm_command,然后在 result = tools.run(call, ctx) 之前插入:
# bash 拦截:打印命令预览,让用户确认后再执行
elif call.name == "bash":
command = call.arguments.get("command", "")
timeout = call.arguments.get("timeout", 30)
console.print(f"\n[bold yellow]Command:[/bold yellow] {command}")
console.print(f"[dim]timeout: {timeout}s cwd: {ctx.cwd}[/dim]")
if not confirm_command(command):
result = ToolResult(call.id, "error: command rejected by user", is_error=True)
emit(f"observation: {result.content}")
tool_result_blocks.append(...)
continue注意这里只拦截了 bash。git_status 和 git_diff 默认走 tools.run,不弹确认。
1.5 跑三个验证
(a) 只读 git 工具——不弹确认直接出结果:
$ uv run agent-code "用 git_status 看一下当前仓库状态,然后用 git_diff 看改了什么"
Agent Code
cwd: /your/project
provider: anthropic model: deepseek-v4-flash
tool_call: git_status {}
observation: On branch main
Changes not staged for commit:
modified: ...
tool_call: git_diff {}
observation: diff --git ...
final: 当前仓库有一些未提交改动,我已经看到了 git status 和 git diff。这里不要求你的输出和示例一模一样:如果你正在这个仓库里跟着教程改代码,git status 大概率不是 clean。关键看两件事:出现 tool_call: git_status / tool_call: git_diff,并且中间没有 Run this command? 提示。
(b) bash 执行——命令预览 + 确认:
$ uv run agent-code "用 bash 跑一下 pytest --version"
...
tool_call: bash {'command': 'pytest --version', 'timeout': 30, 'background': False}
Command: pytest --version
timeout: 30s cwd: /your/project
Run this command? [y/N]: y
observation: pytest 8.x.x
final: 当前环境中安装了 pytest 8.x.x。看到 Command: 黄字预览和 Run this command? 提示,按 y 才执行。按 n 会看到 observation: error: command rejected by user。
(c) bash 执行报错——模型看到 exit code 后自我修正:
$ uv run agent-code "用 bash 跑一下 python -c 'print(1/0)'"
...
observation: exit code 1
Traceback (most recent call last):
File "<string>", line 1, in <module>
ZeroDivisionError: division by zero
final: 命令执行失败了——代码尝试用 0 做除数,Python 抛出了 ZeroDivisionError。模型拿到 exit code 1 + stderr 内容后能自己分析失败原因。
v1 的 bash 能跑了,但确认逻辑还嵌在 agent.py 的 if/elif 链里。每加一种新工具就要加一个分支,而且危险 bash 也只是弹 y/N,没有在确认前被拦住。下一版把确认逻辑抽成独立的权限引擎。
v2:权限引擎——三模式 + 危险命令拦截
v1 有三个问题:
- 确认逻辑散落:
file_write/file_edit的 diff+confirm 在 if 块里,bash的命令预览+confirm 在 elif 块里。加ask_user_question又要加一个分支。 - 危险 bash 只靠确认不够:
echo hello和rm -rf /都走同一个 y/N 流程。危险命令应该在确认前直接拒绝。 - 没有模式切换:有时候你想让 Agent 自己改文件不弹确认(
acceptEdits),有时候你想让它只看不改(plan)。
权限引擎解决这三个问题:所有工具调用在执行前都经过 decide_permission(),返回 allow | ask | deny。Agent Loop 根据决策分发:allow → 直接执行,ask → 渲染预览 + 用户确认,deny → 返回 error observation。
2.1 新建 agent_code/permissions.py
核心是一个函数 decide_permission(request)。PermissionRequest 描述"这次工具调用想做什么",PermissionDecision 描述 harness 要不要放行。完整 93 行看 v2 DiffCard,关键 dataclass + 决策函数骨架:
@dataclass
class PermissionRequest:
"""一次工具调用的权限请求。工具只描述意图,是否执行交给 harness 决定。"""
tool_name: str
args: dict
mode: str
cwd: Path
@dataclass
class PermissionDecision:
"""权限引擎的决策结果。behavior 是 allow / ask / deny 之一。"""
behavior: str # "allow" | "ask" | "deny"
message: str | None = None
def decide_permission(request: PermissionRequest) -> PermissionDecision:
"""权限引擎入口:根据工具名、参数和当前模式决定 allow / ask / deny。"""
tool_name = request.tool_name
args = request.args
mode = request.mode
if tool_name in _ASK_TOOLS:
return PermissionDecision("ask")
# plan 模式:只允许只读工具。写类工具一律 deny。
if mode == "plan":
if tool_name in _READONLY_TOOLS:
return PermissionDecision("allow")
return PermissionDecision("deny", f"plan mode: {tool_name} is not allowed. ...")
# 只读工具在所有模式下默认允许
if tool_name in _READONLY_TOOLS:
return PermissionDecision("allow")
# bash 工具的危险命令检测——不管什么模式,危险命令先拦截
if tool_name == "bash":
command = args.get("command", "")
danger_reason = _is_dangerous(command)
if danger_reason:
return PermissionDecision("deny", f"Dangerous command blocked: {danger_reason}")
# acceptEdits 模式:文件编辑跳过确认 UI,但安全校验仍在 agent.py 做
if mode == "acceptEdits":
if tool_name in ("file_write", "file_edit"):
return PermissionDecision("allow")
# 默认模式:写文件和 bash 需要确认
return PermissionDecision("ask")危险命令 regex 是教学版的最小安全网,覆盖 rm -rf / sudo / chmod -R / curl ... | sh / git push --force / git push -f / git push / git reset --hard 等。_READONLY_TOOLS 把 read_file / list_files / glob / grep / project_tree / git_status / git_diff / system_date / echo 一并放进白名单,默认放行。_ASK_TOOLS 装 ask_user_question / web_fetch / web_search——这些工具即使是只读,也希望让用户知道 Agent 在停下来问人或访问外部资源。
为什么只读工具白名单放在权限引擎而不是工具注册表?因为"是否只读"是权限决策的属性,不是工具自身的属性。同一个 bash 工具可能跑只读命令也可能跑写命令,权限引擎看的是"这个工具在当前调用里做什么"。
2.2 改 agent_code/agent.py:用 decide_permission 统一拦截
把 v1 的 if/elif 拦截链替换成统一的 permission gate。顶部 import 把 confirm_command/confirm_edit/confirm_tool_use/render_diff 都从 prompt_ui 直接导入,并追加 from .permissions import PermissionRequest, decide_permission。切完之后整个 agent_code/ 没人再 import diff_ui——rm agent_code/diff_ui.py 把 v1 留的过渡垫片删掉。可以 rg 'from \.diff_ui|import diff_ui' agent_code/ 验证没遗留引用。垫片用完就拆,留着是死代码。
run_agent 签名加 permission_mode: str = "default" 参数。然后把整个工具调用循环里的 if/elif 拦截块替换成三分支结构。核心变化:所有工具调用先包装成 PermissionRequest 进 decide_permission,根据返回的 behavior 分发到 deny / ask / allow 三条路径:
# 权限引擎统一入口:所有工具调用先包装成 PermissionRequest
request = PermissionRequest(
tool_name=call.name,
args=call.arguments,
mode=permission_mode,
cwd=ctx.cwd,
)
decision = decide_permission(request)
edit_preview: tuple[str, str, str] | None = None
if call.name in ("file_write", "file_edit") and decision.behavior != "deny":
# acceptEdits 只跳过确认 UI,不能跳过 Day 4 的安全校验
# 路径解析 + read-before-edit + mtime 检查 + apply_single_replace 全在这里跑
...
edit_preview = (path_str, old_content, new_content)
if decision.behavior == "deny":
# deny 路径:直接返回 error observation,不弹 UI
result = ToolResult(call.id, f"error: {decision.message}", is_error=True)
...
continue
elif decision.behavior == "ask":
if call.name in ("file_write", "file_edit"):
# ask 模式只负责 diff + confirm(校验已经做过)
...
elif call.name == "bash":
# 命令预览 + confirm
...
elif call.name in ("web_fetch", "web_search"):
# confirm_tool_use 让用户确认访问外部资源
...
elif call.name == "ask_user_question":
pass # v3 接上
# allow 路径 + ask 通过:执行工具
result = tools.run(call, ctx)三种决策路径:deny 直接返回 error observation(不弹 UI、不执行工具);ask 按工具类型分发不同的预览 UI,用户确认后才落到 tools.run;allow 跳过 UI 直接 tools.run。完整 ~150 行替换看 v2 DiffCard。
注意 acceptEdits 只是跳过确认,不是跳过安全校验。file_write / file_edit 在进入 ask 或 allow 分支前,都会先走 Day 4 的 ensure_read_before_edit + check_mtime_conflict + apply_single_replace 预计算。
2.3 改 agent_code/cli.py:加 --permission-mode 选项
main_command 签名加:
permission_mode: str = typer.Option("default", "--permission-mode", help="Permission mode: default, acceptEdits, plan"),run_once 签名加 permission_mode: str 参数,并在两处 run_once(...) 调用(直接 run 和 REPL 循环)传过去;最后传给 run_agent(..., permission_mode=permission_mode)。
2.4 跑五个验证
(a) default 模式——文件编辑仍需确认:
Day 4 跑完后 hello.txt 通常已经被改成了 hola from agent。先让模型读一遍再把 hola 改回 hello:
$ uv run agent-code "先读 hello.txt,再把里面的 hola 改成 hello"
...
tool_call: read_file {'path': 'hello.txt'}
observation: hola from agent
tool_call: file_edit {...}
Diff for hello.txt:
...
Apply this edit to hello.txt? [y/N]:Day 4 的 diff + confirm 行为不变。
(b) acceptEdits 模式——文件编辑跳过确认:
$ uv run agent-code --permission-mode acceptEdits "先读 hello.txt,再把里面的 hello 改成 hola"
...
tool_call: file_edit {...}
observation: Edited hello.txt: replaced 5 chars with 4 chars没有 Diff for 和 Apply this edit?——acceptEdits 模式下 decide_permission 对 file_write / file_edit 返回 allow,但执行前仍会跑 read-before-edit、mtime 冲突和字符串替换校验。bash 仍然需要确认:
$ uv run agent-code --permission-mode acceptEdits "用 bash 跑 echo hello"
...
Command: echo hello
Run this command? [y/N]:(c) plan 模式——写工具被拒绝:
$ uv run agent-code --permission-mode plan "创建 hello.txt 文件"
...
tool_call: file_write {'file_path': 'hello.txt', 'content': 'hello'}
observation: error: plan mode: file_write is not allowed. Only read-only tools can run in plan mode.
final: 当前处于 plan 模式,不允许执行写入操作。只读工具不受影响——read_file 在 _READONLY_TOOLS 白名单里:
$ uv run agent-code --permission-mode plan "读一下 hello.txt"
...
tool_call: read_file {'path': 'hello.txt'}
observation: hola from agent(d) 危险 git 命令被拦截:
$ uv run agent-code "用 bash 跑 git push --force origin main"
...
tool_call: bash {'command': 'git push --force origin main', 'timeout': 30, 'background': False}
observation: error: Dangerous command blocked: git push --force overwrites remote history没有弹 Command 预览、没有弹确认——decide_permission 在 _is_dangerous 那一步就返回了 PermissionDecision("deny")。
(e) sudo rm -rf / 可能被模型先拒绝:
真实模型有时会在调用工具前就拒绝这类极端危险命令,给个 final 说"我不能运行"。这不代表权限引擎没生效,只是模型层先拦了一次。要稳定验证权限引擎,优先用上面的 git push --force。
到这里,权限引擎的三模式 + 危险命令拦截全部接好。agent.py 的拦截逻辑不再是一堆 if/elif,而是一个统一的 decision.behavior 三分支。
v3:ask_user_question —— 让模型主动问你
v2 的权限引擎只处理了两种交互:allow(直接执行)和 ask(预览 + y/N)。但有些场景模型需要的不是"同不同意执行",而是让你做选择题——"先修测试还是先修代码?"
ask_user_question 就是干这个的。它是独立的一等工具,不是 slash 命令。模型调它,harness 阻塞 Agent Loop,终端弹一个 numbered menu 让你选,结果作为 tool_result 回灌模型。
3.1 改 agent_code/prompt_ui.py:加选项菜单
文件末尾追加:
def prompt_single_choice(question: str, labels: list[str]) -> str | None:
"""展示一个 numbered menu 让用户单选,返回被选中的 label。"""
import rich
console = rich.console.Console()
console.print(f"\n[bold yellow]? {question}[/bold yellow]")
for i, label in enumerate(labels, 1):
console.print(f" {i}. {label}")
console.print(f" 0. [dim]Skip / Other[/dim]")
try:
choice = typer.prompt("Choice", default="0")
idx = int(choice)
if 1 <= idx <= len(labels):
return labels[idx - 1]
return None
except (ValueError, TypeError):
return None3.2 改 agent_code/tools.py:新增 ask_user_question
工具函数本身不读 stdin——实际交互在 agent.py 拦截块里完成。这里只做参数校验,正常路径不会被调用:
def _ask_user_question(args: dict[str, Any], ctx: ToolContext) -> str:
"""由 agent.py 拦截块处理——工具函数本身不读 stdin。"""
prompt = args.get("prompt", "")
options = args.get("options", [])
if not prompt:
return "error: missing required argument 'prompt'"
if not options or not isinstance(options, list):
return "error: options must be a non-empty list"
return "error: ask_user_question must be handled by the harness, not executed directly"default_tools() 在 bash 注册之后追加 ask_user_question 注册(input_schema 含 prompt 字符串 + options 数组)。完整注册看 v3 DiffCard。
3.3 改 agent_code/agent.py:拦截 ask_user_question
v2 拦截块里有 elif call.name == "ask_user_question": pass 占位。顶部 import 追加 prompt_single_choice,把 pass 替换成实际逻辑:
elif call.name == "ask_user_question":
question = call.arguments.get("prompt", "")
options = call.arguments.get("options", [])
if not isinstance(options, list):
options = []
labels = [str(o) for o in options]
selected = prompt_single_choice(question, labels)
if selected is None:
result = ToolResult(call.id, "User skipped the question.", is_error=False)
else:
result = ToolResult(call.id, f'User selected: "{selected}"', is_error=False)
emit(f"observation: {result.content}")
tool_result_blocks.append({
"type": "tool_result",
"tool_use_id": result.tool_call_id,
"content": result.content,
"is_error": result.is_error,
})
continue注意 ask_user_question 走的是 continue——它自己构造 ToolResult 并加入 tool_result_blocks,不落到后面的 tools.run。这和 file_write / file_edit / bash 不同:那些工具在确认通过后仍然需要 tools.run 执行实际动作;ask_user_question 的全部作用就是弹菜单拿答案。
3.4 跑验证
$ uv run agent-code "我应该先修测试还是先修代码?用 ask_user_question 问我,选项三个:先修测试、先修代码、不确定"
...
tool_call: ask_user_question {'prompt': '修复这个 bug 应该从哪个方向入手?', 'options': ['先修测试', '先修代码', '不确定']}
? 修复这个 bug 应该从哪个方向入手?
1. 先修测试
2. 先修代码
3. 不确定
0. Skip / Other
Choice: 2
observation: User selected: "先修代码"
final: 你选择了"先修代码"。那我们从修改实现代码开始……模型拿到选择后继续推理。选 0 则返回 User skipped the question.——is_error=False,模型知道"用户看了问题但没给明确方向"。
v3 的 ask_user_question 到这里就收住。现在这个 numbered menu 是教学版的最小实现:能证明"模型发起结构化问题 → harness 暂停 loop 等用户 → 用户选择变成 tool_result → 模型继续推理"这条链路已经接通。
你在 Claude Code 里看到的上下键选择,本质上不是模型能力更强,而是 prompt_ui 这一层做得更完整:终端进入 raw input,监听上/下箭头移动当前选项,按 Enter 返回选中的 label,再由 Agent Loop 包成 tool_result。源码里这块也是一等工具,名字是 AskUserQuestion,它的权限检查固定走 ask,工具调用会要求用户交互;UI 侧支持一组问题、选项描述、预览和多选。我们今天不做这些,是为了先把 harness 边界讲清楚。后面真要升级,主要改 prompt_single_choice():把 typer.prompt("Choice") 换成上下键菜单,agent.py 的拦截流程基本不用变。
收尾:bash(background=True) —— 后台执行
v1 的 bash 是同步的:模型调了 bash("sleep 30"),Agent Loop 就得干等 30 秒。对编译、长时间测试这类场景,等不起。
后台执行解决这个问题:模型设置 background=True,harness 启动子进程后立即返回结构化 observation(含 background_id、输出文件路径、pid),Agent Loop 不阻塞。模型后续可以 bash("cat .bg/<id>.out") 查输出、bash("kill <pid>") 杀进程。
4.1 新建 agent_code/bg_manager.py
一个子线程跑 subprocess.Popen,stdout 和 stderr 分别写到 .bg/<id>.out 和 .bg/<id>.err。启动函数立即返回 dict,不等待进程结束:
def start_background(command: str, cwd: Path) -> dict:
"""在后台启动 shell 命令,stdout/stderr 流式写入 .bg/<id>.out/.err。
立即返回结构化信息,不等待命令结束。"""
bg_id = f"bg-{uuid.uuid4().hex[:8]}"
bg_dir = cwd / ".bg"
bg_dir.mkdir(parents=True, exist_ok=True)
out_path = bg_dir / f"{bg_id}.out"
err_path = bg_dir / f"{bg_id}.err"
out_f = open(str(out_path), "w")
err_f = open(str(err_path), "w")
proc = subprocess.Popen(
command, shell=True, cwd=str(cwd), env=_MINIMAL_ENV,
stdout=out_f, stderr=err_f,
)
def _wait_and_close() -> None:
"""等子进程结束,关闭文件描述符。在 daemon 线程里跑。"""
proc.wait()
out_f.close()
err_f.close()
t = threading.Thread(target=_wait_and_close, daemon=True)
t.start()
return {"background_id": bg_id, "output_file": ..., "stderr_file": ..., "pid": proc.pid, "message": ...}daemon=True 的线程在主进程退出时自动回收,不会阻止 CLI 退出。这里先补一个完整心智模型:真正的后台任务生命周期不是"起一个线程"就结束了,而是 创建任务 → 记录 task id → 持续写输出 → 允许后续读取 → 支持取消 → 结束时通知模型 这一整条链。
Claude Code 的做法也是这个形状:后台 bash 仍然是同一个 Bash 工具,只是参数里带 run_in_background: true。harness 启动子进程后,会注册一个本地 shell task,把输出持续写到任务 output 文件,立刻把 backgroundTaskId 和输出路径作为 tool_result 还给模型。任务结束时,再通过 notification 把"这个后台任务完成了"这类消息送回模型上下文;用户要停掉任务时,走内部的 task kill 能力,而不是让模型自己猜 pid。
Day 5 不做这套完整管理。我们只保留最小链路:background=True 启动子进程,stdout/stderr 落到 .bg/<id>.out/.err,tool_result 返回 background_id、输出路径和 pid。后续模型想看结果,就自己 bash("cat .bg/<id>.out");想停掉,就自己 bash("kill <pid>")。按当前 14 天主线,bg_status / bg_read / bg_cancel 和完成 notification 不进主线,适合放到 extras,或者以后让 AI 按这张生命周期图补出来。
4.2 改 agent_code/tools.py:bash 接 background 分支
bash 工具函数里的:
if background:
return "error: background mode not implemented yet (coming in v4)"替换成:
if background:
# 后台执行:启动子进程后立即返回结构化信息,不阻塞 Agent Loop
from .bg_manager import start_background
result = start_background(command, ctx.cwd)
return (
f"Command running in background with ID: {result['background_id']}.\n"
f"Output is being written to: {result['output_file']}\n"
f"Stderr is being written to: {result['stderr_file']}\n"
f"PID: {result['pid']}\n\n"
f"{result['message']}"
)后台模式不做 timeout(由子进程自己控制),也不做输出截断(输出落到文件,不在 tool_result 里)。
4.3 agent.py 不动
后台 bash 和同步 bash 走同一个 decide_permission 流程——对权限引擎来说,bash 就是 bash,不区分同步和后台。危险命令检测对两者都适用,用户确认对后台 bash 同样弹。background=True 只是执行方式不同,不是权限豁免。
4.4 跑两个验证
(a) 后台执行 + 稍后查输出:
$ uv run agent-code "用 bash(background=True) 运行 sleep 5 && echo 'done from background'。拿到 background_id 后,用同步 bash 跑 sleep 6 && cat 对应的 .bg 输出文件"
...
tool_call: bash {'command': 'sleep 5 && echo done from background', 'timeout': 30, 'background': True}
Command: sleep 5 && echo done from background
Run this command? [y/N]: y
observation: Command running in background with ID: bg-a1b2c3d4.
Output is being written to: .bg/bg-a1b2c3d4.out
...
tool_call: bash {'command': 'sleep 6 && cat .bg/bg-a1b2c3d4.out', 'timeout': 10, 'background': False}
Command: sleep 6 && cat .bg/bg-a1b2c3d4.out
Run this command? [y/N]: y
observation: done from background
final: 后台任务执行完成,输出为 "done from background"。这里故意等 6 秒,因为刚启动后台任务就 cat 可能读到空文件。如果你用 printf 模拟交互,至少准备两次 y:一次给后台命令,一次给后面的 sleep 6 && cat ...。
(b) 杀后台进程:
$ uv run agent-code "用 bash(background=True) 运行 sleep 300,然后用 kill 杀掉它"
...
observation: Command running in background with ID: bg-e5f6g7h8.
PID: 12345
...
tool_call: bash {'command': 'kill 12345', 'timeout': 10, 'background': False}模型从第一次 tool_result 拿到 pid,构造成 kill <pid> 命令。
终端 Replay 演示
下面是 agent-code "用 bash 跑一下 pytest --version" 的终端动画,对应 Day 5 主视觉里的 7 帧故事——tool_call: bash → Command: 黄字预览 + timeout/cwd dim 行 → Run this command? [y/N]: y → observation: pytest 8.4.2 → final:
今天有了什么
bash工具:模型能跑任意 shell 命令。cwd 锁定在项目目录,env 用_MINIMAL_ENV最小集合,timeout 默认 30s,输出截断到 12000 字符。git_status/git_diff是它的只读薄包装,默认 allow,降低 ask 弹窗噪音。- 权限引擎
permissions.py:PermissionRequest描述一次工具调用,PermissionDecision(allow | ask | deny)统一决策。三模式——default(写类默认 ask)、acceptEdits(文件编辑跳过确认但保留 Day 4 安全校验)、plan(写类一律 deny)。 - 危险命令拦截:regex 覆盖
rm -rf/sudo/chmod -R/curl | sh/git push/git push --force/git reset --hard。命中直接 deny,不弹 UI。 ask_user_question工具:模型主动停下来问用户结构化单选问题。终端弹 numbered menu,结果作为 tool_result 回灌驱动下一轮推理。- 后台 bash:
bash(background=True)启动子进程后立即返回background_id+ 输出文件路径。Agent Loop 不阻塞。模型后续用bash("cat .bg/<id>.out")查输出、bash("kill <pid>")杀进程。 agent.py拦截块统一化:v1 的 if/elif 链 → v2 的decide_permission三分支。每加一种新工具,不再要改拦截块结构——只要在权限引擎里给它一条决策规则。
常见问题
bash 工具报 command timed out after 30s
模型给的命令跑了超过 30 秒。默认 timeout 是 30s,可以让模型在调用时传更大的 timeout 值,或者改用 background=True 在后台跑。给 timeout 设上限可以放课后挑战。
shell=True 安全吗
教学版用 shell=True 是为了命令写法自然(和终端敲的一致)。安全靠两层:权限引擎的危险命令 regex 拦截(v2)+ 用户确认(v1)。生产环境还需要容器 sandbox、只读文件系统、网络隔离。这不是"shell=True 安全",而是"shell=True + harness 拦截"。
plan 模式下模型反复调 file_edit 被拒绝
模型可能没注意到自己处于 plan 模式。plan 模式在 system prompt 里应该有明确提示(Day 8 会加上)。目前如果模型陷入"调写工具 → 被 deny → 再调写工具"循环,可以 /exit 退出后用 --permission-mode default 重跑。
ask_user_question 选了 0 之后模型怎么处理
返回 User skipped the question.,is_error=False。模型拿到这个结果一般会自己拍板或换种方式问你——它知道"用户看了问题但没给明确方向",不是 error。
后台 bash 的输出文件什么时候出现
start_background 调用后 .bg/<id>.out 文件立刻创建(Popen 时 open("w"))。但内容要等到子进程真正产生输出时才写入。模型刚拿到 background_id 立刻 cat 可能看到空文件——让它等几秒再读。教程验证里用的是 bash("sleep 6 && cat .bg/<id>.out")。
课后挑战
- 挑战 1:可选 range 的时间限制。给
permissions.py加一个配置项_MAX_TIMEOUT = 120,在 bash 分支里检查timeout > _MAX_TIMEOUT,超过则返回 ask 并附加说明;给命令行加--max-timeout选项。 - 挑战 2:记住用户选择。在
permissions.py里加一个_remembered: dict[str, str],用户对某个工具选了 "always allow" 后后续直接 allow;给confirm_command和confirm_edit加 "yes to all" 选项。 - 挑战 3:
ask_user_question支持多选。给prompt_single_choice加一个multi_select参数,多选模式下输入1,3返回两个 label;同步改_ask_user_question的 schema 加multi_select字段。 - 挑战 4:后台任务列表。给
bg_manager.py加一个list_background() -> list[dict],扫描.bg/目录返回所有后台任务的 status(running / done);给tools.py加一个bg_list工具让模型列出自己的后台任务。 - 挑战 5:
--permission-mode在 REPL 里切换。给 CLI 加一个/permissionsslash 命令,在 REPL 里输入/permissions acceptEdits切换当前会话的权限模式,不需要退出重进。
思考题
几个开放性问题,先自己憋一句话答案,再继续往下看。面试官真问起 harness 边界这块,能不能讲清楚就看这关。
-
为什么
decide_permission放在agent.py的拦截块里调,而不是放在tools.py的每个工具函数内部? (提示:和 Day 4 思考题 2 共享同一个设计原则。如果一个工具函数自己决定"要不要弹确认",把 CLI 换成 web 端时会发生什么?) -
git_status和git_diff被做成独立工具,而不是让模型调bash("git status")。这个设计选择的好处和代价分别是什么? (提示:想一下_READONLY_TOOLS白名单是怎么工作的,以及模型在猜测"什么命令是只读的"时可能犯什么错。) -
plan模式只做了"写工具一律 deny"的硬约束,但没做"强制先用 todo_write 起草计划"的软约束。少了软约束,plan 模式还能叫 Plan Mode 吗? (提示:Day 8 会补上这一半。先想想如果只有硬约束没有软约束,模型在 plan 模式下的典型行为会是什么。) -
后台 bash 的输出写到
.bg/<id>.out文件,模型通过bash("cat .bg/<id>.out")查询。如果不用文件、改用 harness 在进程结束时自动把输出作为一条 notification 注入下一轮 messages,Agent 的行为会有什么不同? (提示:notification 注入意味着模型不需要主动查询,但什么时候注入、注入给哪个 step 的模型?)
下一天
今天 Agent 具备了命令执行能力:能跑命令、有权限控制、能后台执行、能主动问你问题。加上 Day 3 的文件 + Web 工具和 Day 4 的安全编辑,Agent 已经具备了做小型代码任务的基础能力:读代码 → 改代码 → 跑测试 → 看结果 → 再改。
但每次 agent-code 退出后,对话历史就丢了。下一天我们要让会话可以保存和恢复——session JSONL 落盘、--resume 恢复上次对话、AGENT.md 项目记忆注入 system prompt、以及一个跨 session 的长期记忆系统(memdir)。这样 Agent 就能记住你和你的项目,而不是每次都是"初次见面"。