第 14 周 · Tether 源码课

系统边界:把文件与进程当成外部世界

预计 100–120 分钟 先修:第 5、12–13 周:Service seam、Tools 与 Agent loop、会使用 cwd、环境变量与 exit code

学完你能做到

  • 沿 seam → Provider → Consumer 追踪一种操作系统能力
  • 区分 one-shot Shell、底层 Subprocess 与 persistent Terminal
  • 从 cwd、environment、cancellation、output bound 与 sandbox 判断风险
  • 用定向测试验证状态保留、输出截断和进程树回收

课程进度

  1. 第 1 周
  2. 第 2 周
  3. 第 3 周
  4. 第 4 周
  5. 第 5 周
  6. 第 6 周
  7. 第 7 周
  8. 第 8 周
  9. 第 9 周
  10. 第 10 周
  11. 第 11 周
  12. 第 12 周
  13. 第 13 周
  14. 第 14 周
  15. 第 15 周
  16. 第 16 周

一句话先懂

文件和进程不是“普通方法调用”:它们会触碰 Agent 进程外的状态,所以 Tether 用 seam 描述能力、Provider 承担平台细节、Consumer 决定怎样把能力交给模型。

真正的安全边界还要回答五件事:在哪里运行、继承什么、怎样停止、最多留下多少输出,以及限制是否真的被平台执行。

先看大图:前台、后厨与门禁

模型请求 path / command 前台 Consumer Fs.Tools Shell.Tools 服务窗口 seam IFileSystem IShellExecutor 文件后厨 LocalFileSystem resolve · read · atomic write 进程后厨 ISubprocessRuntime argv · env · stdio · tree 门禁 wrapper SandboxedSubprocessRuntime 持久工作台 ITerminalService · PTY Host OS:文件、进程与终端状态
同一个 tool 名字背后有多层责任。seam 让实现可替换;它本身不会自动带来沙箱。

一个类比:餐厅不是只靠点菜单

把 model tool 想成前台:它检查顾客填了哪些字段,把请求翻译成后厨能处理的格式。IFileSystem、IShellExecutor 这类 seam 像标准出餐口;Local Provider 才是使用本机刀具、灶台和仓库的后厨。Sandbox 像门禁,规定后厨人员能进入哪些区域。

Persistent Terminal 更像为某位厨师保留的工作台:刚才切换的目录、设置的 shell 变量会留到下一张单;one-shot Shell 则每张单都启用一张新工作台。

类比边界:现实餐厅不能表达 argv 注入、符号链接、process tree、PTY、GC 或平台 enforcement。尤其不能由“有门禁”推出“所有文件 API 都被限制”;当前 LocalFileSystem 明确不声称 sandbox containment。

先沿一个能力族读三层

以文件能力为例:

角色真实位置只负责什么
Service Definitionsrc/Tether.Fs/IFileSystem.cs定义 resolve、stat、list、UTF-8 read、atomic write 与 literal edit
Local Providersrc/Tether.Fs.Local/LocalFileSystem.cs把 FsTarget 映射到本机路径,实现版本 guard、每目标锁与稳定错误码
Model Consumersrc/Tether.Fs.Tools/FsToolsPlugin.cs注册 fs_read / fs_write / fs_edit,维护“先读后写”的 observed version

三者不应揉进一个程序集。Consumer 只依赖 seam,因此以后换容器文件系统或远端文件系统时,model tool 的调用约定不必跟着重写。

同样的方法可以追踪 Shell:

Shell.Tools Consumer
        ↓ Require<IShellExecutor>()
Tether.Shell seam
        ↓ provided by
Tether.Shell.Local Provider
        ↓ delegates exact argv to
ISubprocessRuntime

Shell、Subprocess、Terminal 不是同义词

问题one-shot IShellExecutorISubprocessRuntimepersistent ITerminalService
输入一段 bash / pwsh command text已拆好的 exact argv发给现存 bash / pwsh 的 command 或 raw input
生命周期每次 RunAsync 新建非交互进程每次 Spawn 返回受管 handleOpenAsync 建 PTY / ConPTY,跨多次 send 存活
cwd每次 spec 有确定 Workdir;shell 内 cd 不留到下一次SubprocessSpawnSpec.Cwd从初始 cwd 开始;shell 内 cd 会保留
environment清洗 ambient secrets,再合并显式 Env同一 EnvironmentScrub 规则shell 内 export / $global: 状态会保留到 reset
cancellation返回 aborted outcome,并等待 process tree 退出cancellation 触发 terminate → grace → kill treecommand cancellation 会 reset session 并传播取消
output boundstdout / stderr 各有内存 cap,可标记 truncated / spillCollect mode 保留有限 tail 与 lossiness每 session 有滑动字符缓冲,cursor 落后时 Lossy=true

ISubprocessRuntime 是最底层的“一次进程”能力:它不解释 shell 语法,也不把 argv 拼成字符串。LocalShellExecutor 才选择 bash -c 或 pwsh -Command,并把整段 command 放在一个 argv 元素里。

ITerminalService 又不同。它承载真实 PTY / ConPTY,适合需要 cd、激活虚拟环境、运行 REPL 或后台 job 的连续会话。持久状态也增加风险,所以同一 session 只允许一个 in-flight send;timeout、cancellation 与 shell exit 都会排空进程树并让旧 session id 失效。

五个风险问题怎样落到源码

1. cwd:相对路径相对于谁?

IFileSystem.ResolveAsync(path, cwd) 和 IShellExecutor.Resolve(request) 都把相对路径收敛成明确目标。不要依赖测试进程“碰巧在哪个目录启动”。对 Terminal,还要记住 shell 自己可以改变 cwd。

2. environment:继承不等于安全

EnvironmentScrub.cs 删除名称形似 KEY、PASSWORD、SECRET、TOKEN 的 ambient entries,也删除 TETHER_*;调用方显式写入的值最后合并。它是降低误泄漏的规则,不是凭证保险箱,也无法判断一个普通名称里是否装着秘密。

3. cancellation:取消 Task 还不够

caller 不再等待,不代表 child process 已消失。LocalSubprocessRuntime 把 cancellation 接到 handle,先请求终止,经过 GraceMs 后仍不退出就 kill entire process tree;Done 只有在输出 drain 和 teardown 完成后才结束。

4. output bound:无限输出也是资源攻击

Collect 模式只在内存保留有限 tail;越界会标出 Lossy / Truncated,配置 spill 时才可能保留完整流。Consumer 必须把“这只是尾部”告诉模型,不能把截断文本伪装成完整结果。

5. sandbox:Policy 与 Enforcement 必须分开看

SandboxPolicy 表达 ReadOnly、WorkspaceWrite、DangerFullAccess;SandboxEnforcement 才说明限制是 Complete、Partial 还是 None。当前 Local wrapper 在 macOS 用 sandbox-exec 包住 subprocess;其他平台请求受限 local policy 时 fail closed,并要求选择 container world,不能静默退化成 unrestricted。

第 12 周的 Approval 回答“这次是否获准做”,Sandbox 回答“获准后最多能触碰哪里”。两者不能互相替代。

动手实验:一次性进程与持久工作台

实验目标:先预测 shell state、输出上限和取消后的资源状态,再用现有测试验证。实验不会执行模型生成的任意命令。

1. 预测

不运行测试,先写下四个答案:

  1. one-shot bash 第一次执行 export COURSE=14,第二次执行 printf "$COURSE",会看到什么?
  2. persistent terminal 连续执行相同两条命令,会看到什么?
  3. terminal command timeout 后,旧 id 是否还可继续使用?
  4. subprocess 输出超过内存 cap 时,保留开头还是结尾?怎样知道内容不完整?

2. 运行 Shell 与 Terminal 生命周期测试

set -e
dotnet test tests/Tether.Fs.Local.Tests/Tether.Fs.Local.Tests.csproj \
  --filter FullyQualifiedName~LocalFileSystemTests
dotnet test tests/Tether.Shell.Local.Tests/Tether.Shell.Local.Tests.csproj \
  --filter FullyQualifiedName~LocalBashExecutorTests
dotnet test tests/Tether.Terminal.Local.Tests/Tether.Terminal.Local.Tests.csproj \
  --filter "FullyQualifiedName~State_persists_across_sends_and_read_pages_by_cursor|FullyQualifiedName~Command_timeout_resets_persistent_shell"

Windows 上第一条把 filter 改为 LocalPwshExecutorTests;Terminal provider 会改走 ConPTY 对应实现。

3. 运行底层边界测试

set -e
dotnet test tests/Tether.Subprocess.Local.Tests/Tether.Subprocess.Local.Tests.csproj \
  --filter "FullyQualifiedName~Explicit_env_survives_scrub_and_ambient_secrets_do_not|FullyQualifiedName~Collected_output_keeps_the_tail_and_marks_truncation|FullyQualifiedName~Cancel_and_dispose_join_the_process_tree"
dotnet test tests/Tether.Sandbox.Local.Tests/Tether.Sandbox.Local.Tests.csproj \
  --filter FullyQualifiedName~SandboxTests

在非 macOS 上,Sandbox 测试验证 profile / wrapper 的构造与 fail-closed 语义,不表示本机已有完整 native confinement。

4. 观察

  • Shell tests 每次都产生 fresh, noninteractive process,并把 nonzero / timeout / abort 当作结构化 outcome。
  • Terminal 的变量与 cwd 跨 command 保留;timeout result 的 ShellReset=true,旧 id 随后是 TERMINAL_NOT_FOUND。
  • Subprocess collect 保留 tail,Lossy=true;显式 env 可进入 child,ambient credential-shaped names 不会进入。
  • cancel 与 dispose 都等待 process tree join,而不是只丢弃一个 Process 对象。

5. 解释

one-shot 的优点是状态少、请求边界清楚;persistent terminal 的优点是能承载连续交互。选择哪一个不是语法偏好,而是生命周期选择。无论选哪种,cwd、env、cancellation 与 output budget 都必须成为显式 contract。

检查理解

1. IFileSystem 的实现叫 LocalFileSystem,是否说明模型只能读 workspace?

查看答案

不能。它把路径解析到 host filesystem,并明确不声称 sandbox containment。workspace 参数提供相对路径基准,不等同于强制访问边界;真正的 confinement 必须由对应 execution world 的 provider 执行。

2. 为什么 ISubprocessRuntime 接受 argv list,而不是一整段 command string?

查看答案

因为它是 shell 之下的 seam。每个 argv 元素应原样交给操作系统,不再经历 shell 解析;需要 bash / pwsh 语法时,由上层 Shell Provider 明确选择解释器。

3. SandboxPolicy.WorkspaceWrite 已记录在 Session,是否足以证明进程受限?

查看答案

不够。还要看 `SandboxEnforcement` 和实际 Provider。Policy 是期望,Enforcement 是执行程度;受限 policy 在没有 provider 时必须 fail closed,不能把 `None` 当作安全。

本周带走

  • seam 隔离 contract,Provider 承担平台行为,Consumer 决定怎样向模型暴露;三者都不自动等于安全边界。
  • Shell 是 fresh command,Subprocess 是 exact argv 与受管 process tree,Terminal 是跨命令存在的 PTY state。
  • 系统调用前总要检查 cwd、environment、cancellation、output bound,以及 policy 是否真正 enforced。

下一周研究另一种“外部状态”:运行中的程序集怎样替换,又怎样确认旧代码真的不再被引用。边界细节可查文件系统、Shell、Subprocess和沙箱。

在 GitHub 上编辑此页