前置讲 P1 · 进程、Shell 与文件系统 · Processes, the Shell, and the File System

Engineering AI Agents · 预备知识 1。个人学习讲义与配套实验,按主题整理并持续修订。

课程源码 ZIP ↓

个人整理的学习笔记,非官方讲义;课程来源与整理说明见课程介绍。配套示例未逐一运行验证。

本篇目录

这是一篇前置讲,不对应 CS329Z 的某一次课。原课默认学生会写”可靠的软件”,有操作系统和命令行的常识;这篇为没有这些背景的读者补上,支撑第 4 讲(工具使用与沙箱)、第 14 讲(安全与护栏)、第 15 讲(编程智能体),以及原课作业 1 里”给智能体一个终端”的那几道题。 依据说明:本讲讲的是通用的操作系统概念(POSIX / Unix:macOS、Linux),用 Python 标准库演示。涉及智能体的地方,引用了原课作业 1 的公开说明(cs329z/assignment1-harness 的 handout)和 Anthropic 的 Building Effective Agents。代码在 macOS 上运行;Windows 的差别在 §9 单独说明。 配套代码 Code:code/prereq1/(只用 Python 标准库;5 个脚本 + 1 个模块;最慢的脚本约 4 秒) 预计阅读时间:2.5–3.5 小时(含运行代码)


0. 本讲导航 · How to read this lecture

0.1 为什么智能体工程师要懂这些

第 1 讲说过,智能体最有用的工具之一是终端:让模型写一行命令,在一个真实的计算机上执行,再把结果交回去。原课作业 1 的第 10 题就是”给智能体一个终端”,它的 handout 里列了一串要处理的情况——命令被拒绝、超时、输出有 60 万字符、后台进程、密钥泄露……每一条背后都是一个操作系统概念。

这篇前置讲把这些概念从零讲清楚,并在 code/prereq1/os_lab.py 里把它们实现成一个迷你终端 Terminal——它就是第 4 讲和原课 HW1 里 run_terminal 工具的雏形。学完这一讲,你再看那道题时,每一个”设计决策”背后的道理都会是清楚的。

0.2 学习目标 Learning goals

  1. 说清楚程序和进程的区别;知道一个进程怎样通过 stdout / stderr / 退出码把结果交回来,参数和环境变量怎样传进去。
  2. 理解 Shell 在”一行命令”和”一次进程调用”之间做了什么:拆词、引号、运算符(管道、重定向、&&、;、&)、展开(变量、通配符、命令替换、here-document)。
  3. 会给一个接收外部命令的工具做输入检查:程序白名单、禁用危险语法、把路径解析成真实路径再判断是否在工作区内。
  4. 会用超时 + 进程组收拾一条不结束或者留下后台进程的命令;知道 SIGTERM 和 SIGKILL 的区别;知道为什么”先等再读”会死锁。
  5. 理解命令输出可能很大,会在几种观察策略(原样 / 截断 / 存文件)之间权衡。
  6. 把这些组合成一个能给智能体用的终端工具,并说出它挡住了什么、没挡住什么(哪些要靠操作系统沙箱,前置讲 P4)。

0.3 本讲地图 Map

§1  程序与进程:PID、父子关系、fork/exec 的直觉                          ← 代码 01
§2  进程怎样交回结果:stdout / stderr / 退出码 / 信号                      ← 代码 01
§3  参数、环境变量、工作目录                                              ← 代码 01
§4  Shell:拆词、引号、运算符、展开                                       ← 代码 02
§5  从命令字符串到安全检查:白名单、拒绝危险语法                            ← 代码 02、os_lab
§6  文件系统:路径、工作区、权限、文本与编码                                ← 代码 03
§7  收拾残局:超时、进程组、信号、管道缓冲区                                ← 代码 04
§8  组合成一个给智能体的终端,以及输出策略                                  ← 代码 05
§9  Windows 的差别   §10 小结   §11 常见误区   §12 练习   §13 延伸阅读   §14 下一讲

1. 程序与进程 · Programs and processes

1.1 一个比喻

程序是磁盘上的一个文件——一份菜谱。进程是这份菜谱的一次”做菜”:同一份菜谱可以同时在三个灶台上做,每一次都有自己的锅、自己的半成品、自己的进度。操作系统给每个进程一个编号,叫 PID(process ID)。

代码 01 的演示 1 把同一个 Python 解释器运行三次:

PID = process ID,操作系统给每个进程的编号
第几次  子进程的 PID  子进程的父进程 PPID  子进程收到的参数 argv
1           ≠ 本进程             = 本进程    ['第1次', '--flag']
2           ≠ 本进程             = 本进程    ['第2次', '--flag']
3           ≠ 本进程             = 本进程    ['第3次', '--flag']
三个子进程的 PID 互不相同: True

三个进程用的是同一个程序文件,但 PID 各不相同,各有各的内存和状态。

1.2 父进程与子进程

进程是有家谱的:一个进程启动另一个进程,前者是父进程(parent),后者是子进程(child)。上面三个子进程的”父进程 PPID”都是我们自己——是我们启动了它们。这正是智能体终端工具的样子:harness 是父进程,模型写的每一条命令都是一个子进程。父进程负责给子进程准备好输入、等它结束、收集它的输出,必要时把它杀掉。

在 Unix 上,启动子进程底层分两步(Python 的 subprocess 把它们包好了,知道有这回事即可):fork 先把当前进程复制一份(得到一个一模一样的子进程),exec 再让这个子进程把自己替换成要运行的新程序。“先复制、再替换”解释了一个重要事实:子进程继承了父进程的很多东西——打开的文件、环境变量、工作目录(§3)。这既方便(管道就是靠继承打开的文件实现的),也危险(密钥也会被继承,§3.2)。


2. 进程怎样交回结果 · How a process reports back

一个进程结束时,会通过三样东西把结果交回给父进程。代码 01 的演示 2:

情形                      stdout        stderr(最后一行)                   退出码
正常结束                  'result: 42'                                          0
写 stderr 并以 3 退出     'partial'     warning: x                              3
Python 抛出异常           ''            ZeroDivisionError: division by zero     1
程序不存在(Shell 报告)  ''            ... command not found                 127
文件没有执行权限          ''            ... Permission denied                 126
被信号 SIGTERM 杀死       ''                                                  -15

2.1 两条输出流:stdout 与 stderr

每个进程启动时自带三条”流”(stream):

  • stdin(标准输入,0 号):输入从这里来。
  • stdout(标准输出,1 号):结果写到这里。
  • stderr(标准错误,2 号):诊断信息(进度、警告、错误)写到这里。

分成两条流,是为了让”结果”和”关于运行过程的话”分开:grep urgent emails.txt > out.txt 把结果写进文件,而”文件不存在”这样的错误仍然显示在屏幕上。对智能体工具来说,这意味着:只读 stdout 的工具会漏掉错误原因。原课 HW1 明确要求终端工具的结果同时包含 stdout、stderr 和退出码。

2.2 退出码

进程结束时交回一个 0–255 的小整数,叫退出码(exit code / status):

  • 0 = 成功。这是唯一表示成功的值。
  • 非 0 = 失败,具体含义由程序自己定。常见约定:1 一般性错误,2 用法错误,126 找到了程序但不能执行(没有执行权限),127 找不到命令。
  • grep 有自己的约定:0 找到了,1 没找到,2 出错。所以”grep -c NOPE 返回退出码 1”不是错误,只是”没匹配”。模型(和 harness)要会读这些约定,不能一看到非零就当成失败。

2.3 信号

进程也可能不是自己结束的,而是被一个信号(signal)打断。信号是操作系统或别的进程发给一个进程的”通知”。和终端工具最相关的两个:

  • SIGTERM(15):“请你退出。“程序可以捕获它,先保存、清理,再退出。
  • SIGKILL(9):“立刻结束。“不能被捕获、不能被忽略,进程没有任何收尾的机会。

被信号杀死时退出码怎么表示,取决于谁在报告:Python 的 subprocess 用负数(被 SIGTERM 杀死 → -15);Shell 用 128 + 信号编号(→ 143)。同一件事,两种约定——读结果时要知道是谁在报告(代码 01 的演示 2 把两种都打印了出来)。§7 会用到这两个信号来”优雅地”或”强制地”结束超时的命令。


3. 参数、环境变量、工作目录 · What a process inherits

启动一个子进程时,父进程给它三样”起始条件”。

3.1 命令行参数(argv)

参数是一个字符串列表,不是一整行字。程序通过它(Python 里是 sys.argv)知道”要处理哪个文件、带哪些选项”。关键在于:

  • 如果用参数列表直接启动程序(run([py, "-c", code, "第1次", "--flag"])),列表里每个元素就是一个参数,没有人去解释里面的空格、引号、$。
  • 如果把一整行字交给 Shell(§4),是 Shell 把这行字拆成参数列表。

这个区别是安全的关键:我们自己的代码要运行一个程序时,用参数列表最安全(没有 Shell 在中间”翻译”);而模型写的一行命令通常得交给 Shell(因为它爱用管道),于是就有了 §4、§5 要处理的所有问题。

3.2 环境变量

环境变量是一张”名字 → 文字”的表,启动子进程时复制一份给它。程序常用它来找配置:PATH(去哪里找程序)、LANG(语言和编码),以及——API key(ANTHROPIC_API_KEY 就放在这里)。

子进程默认继承父进程的全部环境变量,这对智能体是个风险。代码 01 的演示 4:

子进程(继承全部环境变量)看到: sk-demo-not-a-real-key | postgres://admin:hunter2@db/prod
子进程(只给 PATH / LANG 等)看到: None | None
按名字找'像密钥'的变量(含 KEY / TOKEN / SECRET / PASSWORD…)只找到:['DEMO_API_KEY']——DATABASE_URL 里的密码被漏掉了。

模型写的一条 env 或 echo $ANTHROPIC_API_KEY 就能把密钥读进上下文,甚至通过某个工具发出去。所以终端工具应该用一份干净的环境变量(原课 HW1 的沙箱就”不带你的环境变量运行命令”)。而且要用白名单(只给需要的几个),不要用黑名单(删掉名字像密钥的):密钥的名字五花八门,还可能藏在别的变量里(上面的 DATABASE_URL 里就有密码,黑名单漏掉了它)。os_lab.scrubbed_env() 就是一个白名单实现。

继承是单向的:子进程改自己的环境变量,父进程不受影响。

3.3 工作目录

每个进程有一个当前工作目录(current working directory,cwd)。相对路径(data/notes.txt)就是相对于它来解析的。代码 01 的演示 5 展示了一个智能体常犯的错:

第 1 次调用:cd data && pwd → <ws>/data
第 2 次调用:pwd            → <ws>   (又回到了工作区根目录)

cd 只改变当前这个 Shell 进程的工作目录,而每次工具调用都是一个新的 Shell 进程,所以上一步的 cd 到下一步就没了。智能体很容易以为”我上一步进了 data/,现在还在那里”。解决办法有两种:harness 自己记住工作目录,或者要求模型用绝对路径 / 每次都写 cd x && 命令。Anthropic 在 Building Effective Agents 里提到 SWE-bench 上的一个真实经验:智能体离开根目录后老是用错相对路径,团队把工具改成必须用绝对路径后,这类错误就消失了——这是”精心设计智能体-计算机接口(ACI)“的一个例子。


4. Shell:从一行字到一次进程调用 · The shell

模型给终端工具的是一行字,操作系统要的是一个参数列表。中间站着 Shell(/bin/sh、bash、zsh……)。它做四件事:拆词、处理引号、解释运算符、做展开。

4.1 拆词与引号

Shell 用空白把一行字拆成”词”,引号用来保护空白。代码 02 的演示 1 用一个”打印自己收到的参数”的小程序,让我们看见拆出来的结果:

命令行里写的          程序实际收到的 argv
a b c                 ["a", "b", "c"]
"a b" c               ["a b", "c"]          ← 双引号保护了空格
'a b' c               ["a b", "c"]          ← 单引号也保护空格
a\ b c                ["a b", "c"]          ← 反斜杠保护一个空格
"$HOME" '$HOME'       ["/home/demo", "$HOME"]

最后一行是重点:单引号里的一切都是普通字符('$HOME' 原样传过去),双引号里 $ 仍然有效("$HOME" 被换成了变量的值)。这个区别后面处处要用到。Python 的 shlex.split 按同样的规则拆词(但不做 $ 展开),写工具时可以用它来”预演”。

4.2 运算符:一行里的多条命令

一行命令里可以有好几条简单命令,用运算符连接。代码 02 的演示 2 用 os_lab.analyze_command 把它们分析出来:

运算符意思
|(管道)前一条的 stdout 接到后一条的 stdin
&& / ||前一条成功 / 失败才运行后一条
; 或换行不管成败,依次运行
> >> < 2>重定向:stdout 覆盖写 / 追加写文件、从文件读 stdin、stderr 写文件
&让命令在后台运行,Shell 不等它结束就返回(§7.2 的麻烦来源)
( ... )开一个子 Shell,里面的 cd 不影响外面

一个要记住的点:一行命令里可能运行好几个程序。cat x \| sort \| uniq 运行三个程序;grep a > b.txt && wc -l b.txt 运行两个。做安全检查时,要看每一个,不能只看第一个词(§5)。

4.3 展开:运行之前的改写

在真正启动进程之前,Shell 还会改写命令。代码 02 的演示 3:

命令                                        输出
echo hello $NAME                            hello agent        ← 变量展开
echo *.txt                                  a.txt b.txt        ← 通配符:按文件名匹配
echo '*.txt'                                *.txt              ← 引号里不展开
echo answer is $(python3 -c 'print(6*7)')   answer is 42       ← 命令替换:先运行里面的命令
python3 - <<END ⏎ print(...) ⏎ END          from a here-document ← here-document:后面几行当作 stdin
  • 变量展开 $VAR、通配符 * ? [...]:程序收到的参数往往不是你写的那几个字。
  • 命令替换 $(...) 或反引号:里面是一条完整的命令,先执行,用它的输出替换。所以 echo $(python3 ...) 其实运行了 python3——一个只检查第一个词的白名单会被它骗过(§5)。
  • here-document <<END:让一条命令跨好几行,END 之前的几行是它的 stdin。

命令替换和 here-document 让”这一行到底会运行哪些程序、读哪些数据”变得难以一眼看清。正因如此,原课 HW1 的终端沙箱直接拒绝它们,要求多行脚本写成 python3 -c '...'。


5. 从命令字符串到安全检查 · Checking a command before running it

现在我们能分析一行命令了,就可以决定要不要放行。os_lab.check_allowlist 用三条规则:

  1. 能被完整分析(引号都闭合,没有看不懂的语法)。看不懂的一律拒绝——安全检查的原则是”宁可错杀”,不是”尽量猜”。
  2. 没有用到被禁止的语法。默认禁止命令替换、here-document、子 Shell、后台运行——它们要么能”藏程序”,要么能留下不受控的进程。
  3. 每一个会运行的程序都在白名单里,而且是裸名字(不能写成 ./python3 或 /tmp/x/python3,否则等于可以运行任意文件)。
'grep -c urgent emails.jsonl'   → (True, 'ok')
'echo $(python3 -c ...)'        → (False, 'shell features not allowed: command_substitution')
'python3 - <<EOF...'            → (False, "... heredoc (write multi-line scripts as python3 -c '...')")
'ls; sleep 1'                   → (False, "program 'sleep' is not on the allowlist (...)")
'echo "unclosed'                → (False, 'cannot parse command: unclosed double quote')

两个工程要点:

  • 只检查第一个词是最常见的漏洞。ls; sleep 1 的第一个词是 ls,但它还会运行 sleep;echo $(curl ...) 的第一个词是 echo,但命令替换里藏着 curl。所以要分析出全部程序再逐个检查。
  • 拒绝要说清楚原因。拒绝的说明会作为观察发回给模型,所以要写明为什么被拒、怎样改(“多行脚本写成 python3 -c”)。原课 HW1 的说明特别指出:模型默认会写 here-document,除非工具描述里告诉它别这样——规则要写进工具描述,而不只是写进代码。

os_lab.analyze_command 不是完整的 POSIX 解析器,只覆盖最常见的语法;这对”安全检查”够用,因为任何它看不懂的东西都会落进”拒绝”。真正的隔离要靠下一层——操作系统沙箱(前置讲 P4)。


6. 文件系统 · The file system

智能体的文件工具收到的是模型写的一个路径字符串。要安全地处理它,得懂路径、工作区、权限、编码。

6.1 路径

路径有两种:绝对路径以 / 开头,从文件系统的根出发,在哪里运行都指向同一个地方;相对路径接在进程的工作目录后面解析。路径里 . 是当前目录,.. 是上一级。代码 03 的演示 1 展示了同一个文件的五种写法最后指向同一处——同一个文件可以有无数种写法。所以”这个路径在不在工作区里”不能看字符串本身。

6.2 工作区检查:字符串前缀 vs 真实路径

智能体工具通常把模型能碰的文件限制在一个工作区目录里。怎样判断一个路径在不在工作区内?代码 03 的演示 2 对比了两种做法:

模型给的路径              字符串前缀检查  resolve_in_workspace(真实路径)
notes/todo.txt            通过            在工作区内
../workspace2/report.txt  通过            拒绝:解析后在工作区外
<tmp>/workspace2/...      通过            拒绝:解析后在工作区外
shortcut/report.txt       通过            拒绝:解析后在工作区外(shortcut 是指向外面的符号链接)

字符串前缀检查会被三种情况骗过:(1) 带 .. 的路径(/ws/../secret)字面上以 /ws 开头,实际在外面;(2) 名字以工作区开头的兄弟目录(workspace2 以 workspace 开头);(3) 工作区里指向外面的符号链接(symlink,一种”快捷方式”文件)。

正确做法(os_lab.resolve_in_workspace):先用 os.path.realpath 把路径解析成真实路径(消掉 ..、展开所有符号链接),再用 os.path.commonpath 按路径的组成部分比较它和工作区的公共前缀是不是工作区本身。按组成部分比较,/tmp/ws2 就不会被当成 /tmp/ws 的一部分。注意 realpath 和纯字符串的 normpath 会给出不同答案(练习 3),而 realpath 才是对的——因为只有它展开了符号链接。

6.3 权限

每个文件对三类人(所有者 / 同组 / 其他人)各有三种权限(读 r / 写 w / 执行 x)。代码 03 的演示 3:

文件          权限        操作               结果
readonly.txt  -r--r--r--  追加写入           PermissionError
hello.sh      -rw-r--r--  直接执行           退出码 126
hello.sh      -rwxr-xr-x  chmod +x 之后执行  hello from a script

权限串第 1 位是类型(- 普通文件,d 目录),后面每 3 位一组。八进制写法里每一位是 r=4 + w=2 + x=1,所以 0o444 = r--r--r--(只读),0o755 = rwxr-xr-x。权限是操作系统层面的保护,对所有程序都有效,包括模型写的命令——把数据文件设成只读,是一种便宜又可靠的防护。

6.4 文本与编码

磁盘上存的是字节,“字符”是按某种编码解码出来的。代码 03 的演示 4:

'智能体 agent ✓':11 个字符,UTF-8 编码后 19 个字节
把 Latin-1 编码的字节当 UTF-8 读:UnicodeDecodeError: invalid continuation byte

一个汉字在 UTF-8 里占 3 个字节。所以”文件多大”(字节)、“有多少字”(字符)、“有多少 token”(分词器,第 2 讲)是三件不同的事。而且不是所有字节都是合法的 UTF-8——真实数据里常有混编码的乱码,读文件时要先决定遇到坏字节怎么办(报错、替换成 �、还是跳过)。JSONL(一行一个 JSON 对象)是智能体世界里常见的格式:可以一行一行读(文件再大也只占一行内存)、用 wc -l 数记录、用 grep 粗筛,很适合日志、数据集和轨迹(原课 HW1 的邮件档案就是 emails.jsonl)。


7. 收拾残局 · Cleaning up

模型写的命令可能永远不结束,也可能留下后台进程。终端工具必须能收场。

7.1 超时

给每条命令一个时间上限,到点杀掉。代码 04 的演示 1 让一个死循环在 0.5 秒后被 SIGKILL 杀死(退出码 -9)。没有超时,一个死循环会让智能体永远等下去、一直占着 CPU。原课 HW1 的沙箱默认每条命令 10 秒。超时也是一种结果,要告诉模型(“命令在 N 秒后超时”),让它换个办法。另外,很多程序没有输入时会傻等着读 stdin——给子进程一个立刻关闭的 stdin,它就不会卡住。

7.2 进程组:为什么只杀子进程不够

代码 04 的演示 2 揭示了一个微妙的 bug。命令 sleep 30 & echo started:Shell 启动一个后台的 sleep,然后几乎立刻结束。但是——

超时后怎么杀         timed_out  拿到的 stdout  孙进程 sleep 30
只杀子进程(Shell)  True       ''             还活着
杀整个进程组         True       'started'      已结束

即使 Shell 早就结束了,我们还是等到了超时,而且拿不到输出。为什么?后台的 sleep 继承了 Shell 的 stdout 管道;只要这个管道还开着,我们就一直等不到”输出结束”。只杀 Shell 没用——它早就没了,而孙进程还活着。

解决办法:启动子进程时让它自成一个进程组(Python 的 start_new_session=True),超时后用 os.killpg 把整组一起杀掉。这样子孙一起清理,已经写出的输出(started)也拿得到。os_lab.run 默认就是这么做的。

7.3 SIGTERM vs SIGKILL

代码 04 的演示 3 给一个会捕获 SIGTERM 的子进程分别发两种信号:

发送的信号  退出码  子进程最后说了什么
SIGTERM          0  收到 SIGTERM:保存进度、关闭文件……然后退出
SIGKILL         -9  (什么也没打印)

所以关闭一个子进程的标准顺序是:先礼貌地请它退出(关它的 stdin,或发 SIGTERM),等一会儿,还不走再 SIGKILL。MCP 规范对 stdio 服务器的关闭顺序就是这么规定的(前置讲 P2)。我们的 run 超时后直接用 SIGKILL:模型写的命令通常没有需要保存的东西,快速彻底地清理更重要。

7.4 管道缓冲区:为什么不能”先等再读”

代码 04 的演示 4 展示了另一个藏得很深的 bug:

这台机器上一个管道最多能缓存约 64 KB
子进程输出的字节数  先 wait() 再读        改用 communicate()
10,000              正常结束              读到 10,000 字节
1,000,000           卡住了(1 秒后放弃)  读到 1,000,000 字节

管道的缓冲区是有限的(这台机器约 64 KB)。如果我们只等子进程结束、不读它的输出,子进程写满缓冲区后就停在 write 上等我们读,而我们在等它结束——互相等,谁也动不了(死锁)。输出小于缓冲区时这个 bug 不出现,所以它常常在测试里藏得很好,直到某条命令输出了几百 KB。正确做法是一边读一边等(subprocess 的 communicate() 会同时读 stdout、stderr 并等结束),os_lab.run 用的就是它。


8. 组合成一个给智能体的终端 · A terminal tool for an agent

把前面各层叠起来,就是 os_lab.Terminal:白名单检查 → 在工作区里、用干净的环境变量执行 → 超时杀整组 → 按策略处理输出。代码 05 的演示 1 跑了一组典型命令:

模型写的命令                                   status      退出码/情况  模型看到的第一行
grep -c URGENT emails.jsonl                    ok          0            171
cut -d, -f2 senders.csv | sort | uniq -c ...   ok          0             441 [email protected]
cd docs && ls                                  ok          0            expenses.md
grep -c NOTHING emails.jsonl                   tool_error  1            0
python3 -c 'while True: pass'                  tool_error  超时         [timed out after 1 s]
python3 - <<EOF...                             tool_error  拒绝         [rejected] ... heredoc ...
sleep 3                                        tool_error  拒绝         [rejected] ... not on the allowlist
wc -l no_such_file.txt                         tool_error  1            [stderr] ...

和原课 HW1 的约定一样:只有”运行了且退出码为 0”才算 ok;非零退出、超时、被拒绝都是 tool_error,但都不会让工具崩溃,而是变成一条带说明的结果交给模型。

8.1 输出太长:观察策略

命令可能打印几十万个字符。模型应该看到什么?代码 05 的演示 2 用一条打印约 60 万字符的命令,比较三种策略(之后再问”第 12345 行是什么”):

策略模型看到的字符≈token能直接看到那一行追问那一行
原样(pass)600,013约 60,000能不需要
只留头尾(head_tail)4,050约 416不能只能重新跑命令
存文件(spill)4,178约 444不能一条 grep(约 8 token)找回
  • 原样:约 6 万 token(我们的近似计数;真实分词器通常更多)一次塞进上下文,远超合理的预算,而且之后每一步都要重新发送它(第 1 讲 §3.5、第 7 讲)。
  • 只留头尾:上下文安全了,但中间丢了——要问第 12345 行只能重跑。
  • 存文件:完整输出写进工作区的一个文件,给模型看开头 + 文件路径,需要时用很便宜的 grep 找回任何一行。原课 HW1 第 10 题的 live test 正是这样设计的:一条命令打印约 21.8 万字符,接着问其中某一行。

这三种在已发布的 harness 里都有人用(HW1 的说明列举了 mini-swe-agent 原样传、Codex CLI 截断头尾、OpenHands / Gemini CLI / Claude Code 存文件),没有标准答案——这是原课让你在设计备忘录里辩护的决策之一。

8.2 这一层挡住了什么、没挡住什么

代码 05 的演示 3 确认:终端里的命令读不到我们的 API key(scrubbed_env 只给了几个必需的变量)。但要诚实地说清楚这只是一层:

  • 挡住了:不在白名单里的程序、能”藏程序”的语法、死循环(超时)、后台进程(进程组)、读取父进程的密钥(干净环境)、撑爆上下文的大输出(输出策略)。
  • 没挡住:白名单里的 python3 本身什么都能做——读写工作区外的文件、联网、fork 一堆进程。要把这些也关住,必须交给操作系统沙箱:限制一个进程能访问哪些文件、能不能联网。原课 HW1 的沙箱在 macOS(用 sandbox-exec)和 Linux(用 bubblewrap)上就多了这一层,并提醒学生想清楚”每一层挡住了什么、没挡住什么”(它还引用了第 1 讲 §6.5 那个多智能体串通的事件作为警示)。前置讲 P4 讲沙箱的层次,第 14 讲讲完整的智能体安全。

9. Windows 的差别 · A note on Windows

本讲的概念(进程、stdout/stderr、退出码、环境变量、工作目录、管道)在 Windows 上都有,但细节不同:没有 /bin/sh(命令行是 cmd 或 PowerShell)、没有 Unix 式的进程组和信号(SIGKILL/SIGTERM 不一样)、路径用 \ 分隔且有盘符(C:\)、权限模型完全不同。原课 HW1 的做法是:在 Windows 上让学生装 Git for Windows,用它自带的 bash.exe 跑终端命令,并明说Windows 上没有操作系统级的隔离(沙箱那一层只在 macOS / Linux 上生效),WSL(Windows 里的 Linux 子系统)则能用上 Linux 的沙箱。本讲的代码在 macOS / Linux 上运行;在 Windows 上,os_lab 的进程组和 /bin/sh 相关的部分不适用。


10. 小结 · Summary

  1. 程序是文件,进程是它的一次运行,有自己的 PID、内存、三条流。智能体的终端工具是父进程,每条命令是一个子进程。
  2. 进程通过 stdout(结果)、stderr(诊断)、退出码(0 成功 / 非 0 失败)交回结果;也可能被信号打断(SIGTERM 可捕获、SIGKILL 不可)。工具要把三样都给模型看。
  3. 子进程继承参数、环境变量、工作目录。环境变量会带上密钥(用白名单清理);cd 不跨命令持久(用绝对路径或自己记录)。
  4. Shell 把一行字翻译成进程调用:拆词、引号(单引号里 $ 失效、双引号里有效)、运算符(管道 / 重定向 / && / ; / & / 子 Shell)、展开(变量 / 通配符 / 命令替换 / here-document)。
  5. 安全检查三条规则:能解析、没有危险语法、每个程序都在白名单里(不只是第一个词)。拒绝时说清原因,规则也要写进工具描述。
  6. 文件安全:同一个文件有无数种写法,用 realpath + commonpath 判断是否在工作区内(别用字符串前缀);权限是跨程序的保护;文本是按编码解码的字节。
  7. 收场:超时 + 进程组杀整组;关闭顺序先礼后兵(SIGTERM → SIGKILL);读输出要边读边等(否则管道缓冲区满了会死锁)。
  8. 一个终端工具是很多层的叠加,每一层挡住一类问题;白名单之上还需要操作系统沙箱(P4、第 14 讲)。

11. 常见误区 · Common pitfalls

  1. “把模型的命令字符串拼进 os.system 就行了。” 那等于让模型写任意代码:管道、命令替换、后台进程、读密钥全都放行。至少要做白名单检查,更要加操作系统沙箱。
  2. “白名单检查第一个词就够了。” ls; sleep 1、echo $(curl ...) 的第一个词都无害,真正运行的程序藏在后面。要分析出全部程序。
  3. “只把 stdout 返回给模型。” 错误原因通常只写在 stderr;只读 stdout,模型就不知道为什么失败了。
  4. “退出码非零就是出错了。” grep 没匹配返回 1,diff 有差异返回 1——很多程序用非零表示”正常的另一种结果”。要按程序的约定理解。
  5. “用字符串前缀判断路径在不在工作区里。” 会被 ..、同前缀的兄弟目录、符号链接骗过。用 realpath + commonpath。
  6. “cd 进子目录之后,下一条命令还在那里。” 每条命令是新进程,cd 不持久。这是智能体的高频错误。
  7. “超时杀掉子进程就干净了。” 后台的孙进程会活下来,还占着输出管道让你等到超时。要杀整个进程组。
  8. “先等命令结束,再读它的输出。” 输出超过管道缓冲区(约 64 KB)就死锁。要边读边等。
  9. “把命令的全部输出塞给模型最省事。” 几十万 token 撑爆上下文,而且之后每一步都要重发。要截断或存文件。
  10. “做了白名单和干净环境,终端就安全了。” python3 本身还能读写任意文件、联网。隔离要靠操作系统沙箱,不是靠命令过滤。

12. 练习 · Exercises

带 💻 的题可以用本讲的模块验证。参考答案在每题后面(先自己做再看)。

练习 1(退出码)💻。不运行,先预测下面三条命令的退出码,再用 os_lab.run_shell 验证(工作区里有一个含 “URGENT” 的文件 f.txt):(a) grep -c URGENT f.txt;(b) grep -c NOPE f.txt;(c) grep -c URGENT missing.txt。

参考答案

(a) 0(找到了,-c 还会打印匹配的行数);(b) 1(grep 的约定:没匹配到任何行就返回 1,哪怕 -c 打印的是 “0”);(c) 2(文件不存在,是真正的错误,stderr 里有 “No such file or directory”)。这说明为什么不能”非零就当出错”:(b) 的”失败”只是”没找到”。

练习 2(拆词)💻。echo "a b" 'c d' e\ f 被 Shell 拆成几个参数?分别是什么?用 shlex.split 验证。再想:如果把这一行交给一个”只检查第一个词”的白名单,它看到的程序是什么?真正会运行的是什么?

参考答案

4 个:['echo', 'a b', 'c d', 'e f'](双引号保留了两个空格,单引号和反斜杠各保护了一个空格;引号本身不保留)。只检查第一个词的白名单看到的程序是 echo,这一行也确实只运行 echo——但把 echo 换成 echo hi; curl evil.com,第一个词还是 echo,却多运行了一个 curl。这就是为什么要用 analyze_command 找出全部程序。

练习 3(真实路径 vs 字符串)💻。工作区是 <root>/workspace,里面有一个符号链接 shortcut 指向 <root>/workspace2。对下面两个路径,分别用 Python 的 os.path.normpath(纯字符串消 ..)和 os_lab.resolve_in_workspace(真实路径)求结果,看它们是否一致:(a) shortcut/../workspace/notes/todo.txt;(b) shortcut/../notes/todo.txt。哪个才是对的?

参考答案

编写时的运行结果:

  • (a) normpath → <root>/workspace/workspace/notes/todo.txt(它只是把 shortcut/.. 当字符串消成了空,又接上 workspace/...,得到一个不存在的路径);resolve_in_workspace → <root>/workspace/notes/todo.txt,判定在工作区内。
  • (b) normpath → <root>/workspace/notes/todo.txt;resolve_in_workspace → 拒绝(shortcut 先展开成 workspace2,.. 到 <root>,再接 notes/todo.txt = <root>/notes/todo.txt,在工作区外)。

两者都不一致,而 realpath(resolve_in_workspace 用的)才是对的:只有它展开了符号链接,知道 shortcut 真正指向哪里。纯字符串的 normpath 在有符号链接时会给出错误答案——安全检查必须用真实路径。

练习 4(进程组)💻。把 os_lab.run_shell("sleep 30 & echo started", ...) 分别用 kill_group=False 和 True 运行(超时设 1 秒)。(a) 两种情况下 timed_out 和拿到的 stdout 各是什么?(b) 为什么 Shell 明明立刻就结束了,kill_group=False 时我们还是等到了超时?(c) 这对智能体终端工具意味着什么风险?

参考答案

(a) 都 timed_out=True;False 时 stdout 是空的、sleep 还活着,True 时 stdout 是 “started”、sleep 已结束(代码 04 演示 2 的结果)。(b) 后台的 sleep 继承了 Shell 的 stdout 管道;只杀 Shell(早就结束了)不会关掉这个管道,我们就一直读不到”结束”,直到超时。(c) 模型可以用 & 在服务器上留下不受控的后台进程——占资源、甚至继续联网。所以要么禁止 &(我们的 check_allowlist 默认就把 background 列为禁止特性),要么超时时杀整个进程组,两者最好都做。

练习 5(观察策略)💻。用 Terminal(ws, max_chars=1000, policy="spill") 运行一条打印约 60 万字符的命令。(a) 模型看到的文字有多长?完整输出有多少字符?(b) 如果模型接着要找第 12345 行,它要付出多少额外代价?(c) 如果改成 policy="head_tail",同样的追问会怎样?

参考答案

(a) 模型看到约 1,188 字符(开头 1000 + 一句”完整输出已存到 …”的说明),完整输出 600,000 字符(代码 05 演示 2 在 max_chars=4000 下的数字是看到约 4,178、完整 600,000;比例随 max_chars 变)。(b) 一条 grep 'line 12345' <文件>,几 token 就找回那一行——因为完整输出存在工作区的文件里。(c) head_tail 没有存文件,中间的内容已经永久丢失,模型只能重新运行整条命令(又是 60 万字符),或者放弃。这就是”存文件”策略的价值:一次性的大输出变成可以反复查询的资源。

练习 6(设计一个终端工具)。仿照原课 HW1 第 10 题,为你自己的智能体设计 run_terminal 工具。写出你的三个判断性决策和理由:(a) 白名单放哪些程序?(b) 输出的观察策略?(c) 工具描述里要对模型说哪几句话(而不只是写在代码里)?

参考答案

没有标准答案,关键是理由。一个可能的回答:(a) 放只读的分析工具(ls cat head tail wc grep sort uniq cut sed awk find jq)和 python3——它们覆盖”查看和统计数据”的绝大多数需求;不放 curl/wget(联网)、rm/mv(破坏数据,或者要求必须经过审批)。(b) 存文件:既保护上下文,又能让模型事后查询任何一行,代价只是工作区里多几个临时文件。(c) 至少三句:①”多行脚本写成 python3 -c '...',不要用 here-document”(否则模型默认会写 <<EOF,被拒绝后浪费一轮);②”命令在 N 秒后会超时,长任务请分步做”;③”大输出会被截断并存到文件,用 grep 查你要的部分”。第①③条正是因为”模型默认会那样做”——原课 HW1 的教训是:想让模型避免某个行为,要在工具描述里明说,光在代码里拦截只会让它反复撞墙。

练习 7(环境变量泄露)💻。os_lab.looks_secret 用黑名单(名字里含 KEY/TOKEN/SECRET/PASSWORD…)判断一个变量像不像密钥。给它 DEMO_API_KEY 和 DATABASE_URL=postgres://admin:hunter2@db/prod 两个变量,它会标记哪个、漏掉哪个?为什么说白名单(scrubbed_env)比黑名单更安全?

参考答案

它只标记 DEMO_API_KEY,漏掉 DATABASE_URL——后者的名字不含那些关键词,但它的值里有数据库密码。黑名单的根本问题是”你得想到所有危险的名字”,而密钥可能藏在任何名字里(连接串、GITHUB_*、AWS_*、自定义的名字……)。白名单反过来:只放行少数你确定安全的变量(PATH、LANG 等),其余一律不给——你没想到的危险名字自动被挡住。安全设计里这叫”默认拒绝”(default deny)。

练习 8(管道死锁)。不看代码 04 的输出,解释:为什么一个子进程输出 1,000,000 字节时,“先 wait() 再 read()”会卡住,而输出 10,000 字节时不会?communicate() 做了什么不同的事?

参考答案

管道的缓冲区有限(这台机器约 64 KB)。输出 10,000 字节时,全部塞得进缓冲区,子进程写完就正常结束,我们再读没问题。输出 1,000,000 字节时,子进程写满 64 KB 后,write 会阻塞,等着有人从另一头读走一些;但我们在 wait() 等它结束——它等我们读、我们等它结束,死锁。communicate() 不同:它同时从 stdout、stderr 读,并等待结束(内部用 select / 多线程),所以子进程一直有人”接着”,不会卡在 write 上。经验法则:只要可能有输出,就用 communicate()(或边读边处理),不要”先等后读”。


13. 延伸阅读 · Further reading

  • 原课作业 1 的起始代码与 handout(终端工具、沙箱、观察策略的设计决策):https://github.com/cs329z/assignment1-harness
  • E. Schluntz, B. Zhang, Building Effective Agents, Anthropic, 2024(“精心设计智能体-计算机接口 ACI”、SWE-bench 绝对路径的例子)。
  • Python 官方文档:subprocess(Popen、communicate、start_new_session)、os(killpg、environ、path.realpath、path.commonpath)、shlex(split、quote)、signal。
  • The Linux Programming Interface(M. Kerrisk)第 24–27 章(进程创建、exec、信号)、第 44 章(管道)——想深入到系统调用层面时的权威参考。
  • 前置讲 P4(安全基础:威胁模型、最小权限、沙箱的层次)会接着讲”白名单之上”的那一层。

14. 下一讲预告 · Next lecture

前置讲 P2 网络、HTTP 与 API:这一讲的终端让智能体作用于本机;下一讲让它够到网络。智能体调用一个 LLM、调用一个外部 API、或者连接一个 MCP 服务器,底下都是 HTTP 或 JSON-RPC 的请求与响应。P2 会从客户端 / 服务器讲起,讲 HTTP 的请求行、头部、状态码、认证,JSON 与 JSON Schema,REST 与 JSON-RPC 2.0(MCP 的底层),stdio 和 HTTP 两种传输,以及流式输出(SSE)——然后在本机上起一个真正的 HTTP 服务和一个迷你 MCP 服务器,手写客户端去调用它们。这正好接上第 4 讲的工具使用与 MCP。

← 返回课程目录