# 智能体进程授权：撤销分叉访问

自主智能体很少像任务日志显示的那样干净地停止。智能体可能完成一次编程请求，打印一条令人安心的完成消息，却留下格式化工具、测试运行器、隧道、Shell 或辅助进程继续运行。如果这个残留进程仍能调用生产 API 或建立 SSH 会话，那么任务实际上并没有在界面所显示的地方结束。

解决办法不是执着于杀死进程。清理进程确实必要，但它不是授权系统。请把授权的生命周期与 Unix 进程的生命周期分开定义，让边界可被观察，并在开始追踪后代进程前先撤销访问权限。我见过一些团队把父 PID 当作隔离证明。只要智能体能够调用 Shell，这个 PID 几乎就不能证明什么。

## 进程树不是授权边界

父子关系只能告诉你是谁创建了某个进程，不能告诉你父进程结束后它是否应该继续拥有权限。这是两个不同的问题。把它们混在一起，就会出现常见的故障：有人取消了智能体任务，看到父进程消失，五分钟后却发现某个子进程仍在发起请求。

Unix 为进程提供了多种脱离预期结构的方式。子进程可以继续 fork，可以用 `setsid` 创建新会话，也可以请求服务管理器接管。它还可能留在 Shell 管道中，而信号只发送给其中一个成员。甚至可能只是因为父进程根本没有发送信号，所以一直存活。

进程携带的信息也不止命令行。它可能继承环境变量、当前工作目录、打开的文件、管道、套接字和文件描述符。如果父进程把 bearer token 放在环境变量中，继承该环境的任何子进程都会拥有这个令牌。之后杀死子进程并不能让秘密回到保险库，也无法撤销已经发出的请求。

因此，授权边界应该回答一个明确而有限的问题：哪个正在运行的进程实例可以请求特权操作，授权到什么时候，以及我们如何立即拒绝它？答案不应是「从启动任务的终端派生出来的所有进程」。那只是进程布局带来的结果，不是安全决策。

对于需要 HTTP 或 SSH 访问的智能体，请把凭据放在智能体进程之外。让持有凭据的组件在确认调用方获得批准后执行操作。这样一来，清理就不再是拼命抹除泄露权限的行为，而会成为日常运维的一部分。

## 在启动任务前定义生命周期

一次智能体运行需要明确的开始条件、结束条件和撤销条件。在决定 UI 中的一次点击、一个进程组或一个 Shell 包装器是否算作控制手段之前，先把这些条件写下来。

对于交互式工作，合理的默认做法通常是，一次授权只对应一个根智能体进程。用户批准这次具体运行时，授权开始；根进程退出或你撤销授权时，授权结束。新的智能体调用需要重新做决定，即使它从同一目录运行同一个二进制文件。

对于无人值守的工作，不要因为重复提示令人厌烦，就悄悄把一次批准延长到全天。应为任务指定负责人、有限的有效时长、受限的目标范围，以及一个可以在任务运行期间使用的取消途径。如果任务需要在父任务结束后继续运行，就把它当作独立任务，并让它单独请求授权。

下面三个问题能很快暴露模糊的设计：

- 用户批准的是哪个可执行文件和哪个进程实例？
- 如果进程从未发出正常完成信号，什么事件会结束它的权限？
- 该事件发生后，后代进程还能获得新的特权操作吗？

如果最后一个问题的答案是肯定的，因为后代继承了令牌，那么你就在没有记录委托的情况下委托了权限。如果答案是肯定的，因为网关仍把所有后代都视为可信，那么你就把进程祖先关系变成了策略语言。在安全事件中，这两种选择都很难解释。

不要把便利误当成边界。终端标签页、项目目录、智能体账户和代码签名身份都能提供有用的上下文，但没有任何一项能单独标识一次具体运行。代码签名身份只能告诉你谁签署了可执行文件，不能告诉你它启动的是预期的辅助工具、旧版本辅助工具，还是取消后脱离的子进程。

## 终止前先检查实时进程树

在 macOS 上，应从内核提供的进程事实开始，而不是根据任务标签猜测。`ps` 手册将 `pid`、`ppid`、`pgid` 和 `sid` 记录为不同字段。它们分别展示进程祖先关系、进程组成员关系和会话成员关系。当智能体被允许调用 Shell 和工具时，这些信息都需要查看。

调查一次运行时，执行下面的命令并保存输出：

```sh
ps -axo pid,ppid,pgid,sid,stat,etime,command
```

你可能会看到这样的结构：

```text
  PID  PPID  PGID   SID STAT ELAPSED COMMAND
48102 47790 48102 48102 S    00:18:04 agent-cli run build
48131 48102 48102 48102 S    00:17:59 /bin/sh -c make test
48144 48131 48102 48102 S    00:17:56 test-runner --watch
48209     1 48209 48209 S    00:16:02 helper --upload-results
```

前面三个进程共享同一个进程组和会话。最后一个进程的 PPID 是 1，并且使用了不同的进程组和会话。它可能已经脱离，也可能已经由某个启动器接管。无论是哪种情况，只向 PID 48102 发送信号都无法停止它。

如果要查找已知根 PID 的直接子进程，可以使用：

```sh
pgrep -P 48102 -alf
```

这个命令只查找一代子进程。如果要快速手动遍历，可以对每个子进程重复执行。对于安全事件记录，应在撤销前后分别保存 `ps` 输出，并记录准确的根 PID、启动时间、命令、进程组和会话。单独记录进程名称并不是可靠证据，因为名称会重复，命令行也会变化。

如果风险涉及外部调用，请检查打开的网络连接。在 macOS 上，`lsof` 可以显示进程的网络文件：

```sh
lsof -nP -p 48209 -i
```

监听套接字、已建立的出站连接或长期存在的 SSH 传输都会提高紧迫性，但它们不能证明存在恶意行为。它们只能证明，一个你原以为已经消失的进程仍保留着值得调查的通信渠道。

不要在生产环境中构建依赖解析人类可读 `ps` 输出的安全控制。`ps` 适合调查和测试。真正的启动器应在启动时记录标识符，并在授权网关处持有直接的撤销句柄。

## 进程组有帮助，但脱离的子进程会绕过它

专用进程组能为启动器提供一种实用方式，用来取消普通的任务树。请在智能体启动前创建进程组，让根进程成为组长，并向进程组发送信号，而不是只向根进程发送信号。这样可以覆盖 Shell、编译器、测试运行器和管道仍留在同一进程组中的常见情况。

在支持常见信号语法的系统上，负的组 ID 可以定位进程组：

```sh
kill -TERM -48102
sleep 3
kill -KILL -48102 2>/dev/null || true
```

`TERM` 信号会给普通工具机会，让它们关闭文件并报告取消。稍后发送的 `KILL` 信号则处理拒绝退出或无法退出的进程。在确认 48102 确实是目标进程组之前，不要把这段命令复制到自动化流程中。错误的组 ID 可能终止你自己的 Shell 或无关任务。

这种方法有局限。子进程可以调用 `setsid`，创建新的会话，通常也会创建新的进程组。任务还可以把工作提交给本地服务、远程构建系统或队列。Shell 也可能在进程组之外启动后台进程。一旦发生这些情况，终止进程组就只是清理，不再是隔离控制。

在 Linux 上，服务管理器可以把任务放进专用 cgroup，并将整个 cgroup 作为一个单位终止。这通常比进程组清理更强，因为内核会持续跟踪成员关系，而不只依赖普通的父子关系。不要暗示 macOS 菜单栏应用也具备这种控制能力。macOS 和 Linux 使用不同的进程监管模型，便携式智能体设计不应假装它们相同。

「直接杀掉整个进程树」之所以一直流行，是因为它在演示中有效。但在真正让智能体访问权限变得危险的条件下，它会失败，包括长时间运行的任务、后台辅助进程、包装器和部分取消。使用进程组可以减少残留，但不要把它当作唯一的撤销机制。

## 完全不要让进程树接触秘密

最安全的子进程，仍然是无法读取凭据的子进程。通过环境变量传递令牌，会让继承该环境的所有后代都能访问令牌，也可能让诊断信息、崩溃报告或不谨慎的日志记录暴露令牌。传递临时文件只好一点点，如果子进程能在你删除文件前复制它，风险依然存在。

避免把下面这些模式用于智能体启动的命令：

```sh
export DEPLOY_TOKEN='token-value'
agent-cli run deploy
```

```sh
agent-cli run deploy --token "$(cat ~/.config/deploy-token)"
```

这两种方式都会把原始权限放进智能体的执行环境。第二种方式还可能把它放进进程参数、Shell 历史记录或日志中。出现问题后轮换令牌可能是必要的，但轮换是恢复措施，不应成为正常的取消路径。

改用本地操作网关。智能体应请求某项操作，例如向获准的端点发起 HTTP 请求，或执行 SSH 命令，但不应接触凭据材料。网关注入相关凭据、执行操作并返回结果。这样，即使某个残留子进程仍在运行，你也能阻止它发起下一次调用。

Sallyport 对 HTTP API 和 SSH 采用了这种方式：加密保险库留在应用中，智能体通过 `sp mcp` shim 连接，并接收操作结果，而不是接收秘密。这个设计比任何巧妙的进程终止脚本都更重要，因为子进程不可能继承它从未拥有的令牌。

不要夸大这项优势。拥有已批准网关会话的进程，仍然可以在会话结束或被撤销前请求操作。让秘密远离进程树可以限制凭据被窃取的风险，但不会让一个已获批准的智能体变得无害。

## 审批应绑定一次运行，而不是一个名称

只根据可执行文件名称授权是不可靠的。任何人都可以把二进制文件复制到其他路径，用 Shell 脚本包装它，或稍后再次运行另一个实例。只根据签名者授权有利于追溯来源，但范围仍然过宽，尤其是在它悄悄批准同一方签署的所有未来运行时。

一张实用的审批卡，应使用人能够核对的信息标识请求进程，包括代码签名机构、可执行文件路径、根 PID 和启动时间。决定应只适用于这一次进程运行。子进程不应仅仅因为历史上某处存在一个已获批准的祖先，就获得无限期行动权。

对于后代进程，有两种合理模式。更严格的模式要求每个不同的请求方都获得审批。更实用的模式允许一次已批准的根进程运行期间产生的调用，并在该运行结束后拒绝所有新调用。对于确实需要启动短期工具的智能体，第二种模式通常很好用，前提是网关能判断根运行已经结束，而且授权无法被重新绑定到一个名称相同的后续进程。

Sallyport 默认使用按会话授权：新智能体进程首次调用时，会显示一张审批卡，首先展示该进程的代码签名机构；授权会持续到这次运行退出。保险库锁定时，保险库网关会拒绝所有操作；按调用设置还可以要求每次使用某项凭据时都进行审批。这些控制有意保持简单。堆叠一大批策略规则，只会掩盖究竟是谁批准了什么。

按调用审批适合每次操作都值得认真审核的凭据，例如生产部署账户或具有破坏性的管理 API。但它不适合所有只读请求。如果每个无害操作都要求用户确认，人们最终会对所有提示一律点击批准。应把阻力放在后果真正严重的地方，在其他场景缩短并明确会话边界。

## 先撤销授权，再追踪进程

当父任务意外结束时，首先撤销它发起新的特权调用的能力。然后终止根进程组，检查幸存进程，再清理逃脱的部分。如果顺序反过来，可能会留下一个漏洞：在你查看进程表时，尚未找到的子进程仍然可以继续向外发起调用。

合理的响应顺序如下：

1. 在操作网关处撤销会话或锁定保险库。
2. 保存根 PID、进程信息、最近的操作记录和取消时间。
3. 向已知进程组发送 `TERM`，并检查剩余进程。
4. 只对仍属于该任务且拒绝退出的进程升级发送 `KILL`。
5. 检查脱离的本地进程或远程任务是否需要单独取消。

即使根 PID 已经消失，第一步也必须有效。如果智能体故意试图让辅助进程存活，这一步也必须有效。要求调用方仍然存在才能撤销授权的网关，把方向弄反了。

即时撤销应该拒绝未来的请求，而不是改写历史。保留能显示既有授权和调用的记录。如果网关只记录成功的操作，那么调查期间的重要证据就会被隐藏。撤销后的拒绝调用能告诉你，某个对象仍在继续尝试行动。

保险库锁定是针对所有活动会话的紧急制动器。当你无法快速识别受影响的运行时，使用它很合适。能够识别具体会话时，应采用更精确的会话撤销。把这两种操作区分开，操作人员就不必在袖手旁观和中断所有工程师工作之间二选一。

## 审计轨迹必须与进程证据关联

没有进程上下文的活动记录只能回答一半问题。它可能说明发生了一次 HTTP 请求，却无法说明哪个获准运行发起了请求。只有会话记录而没有具体调用，也存在相反的问题。你需要同时保留两种视图，并且要有办法确认事件发生后记录没有被人悄悄修改。

至少记录授权事件、请求进程身份、会话开始和结束时间、每次特权操作、撤销事件，以及撤销后的任何拒绝。加入时间戳和稳定的关联标识符。不要记录原始秘密。也不要想当然地认为命令行可以安全保存，因为命令行经常包含本不应该在那里出现的值。

Sallyport 从一份加密且采用哈希链的审计日志中生成 Sessions 和 Activity 两个日志视图。它的 `sp audit verify` 命令可以在离线状态下对密文检查哈希链，无需保险库密钥。如果你需要把导出的记录交给某人，让对方确认完整性，却又不能让对方读取存储的凭据，这种验证方式很有用。

哈希链不能让不完整的日志变得完整。如果启动器从未记录根 PID，审计记录之后也无法重建它。如果远程任务接收 API 请求后在另一台机器上继续运行，本地进程证据不会显示远程进程。应审计发起远程工作的请求，并要求远程系统提供自己的取消机制和事件记录。

## 一个常见故障需要两项独立修复

设想一个编程智能体被要求运行集成测试并发布报告。它启动一个 Shell，Shell 启动测试运行器，测试运行器又启动一个上传结果的辅助进程。用户看到测试失败后取消智能体。父智能体退出，Shell 消失，但辅助进程已经脱离，仍保留出站连接，并在取消后发送报告。

如果上传凭据放在环境变量中，辅助进程可能在你撤销本地批准后仍然拥有它。此时你需要轮换凭据、检查日志，甚至可能启动安全事件响应。进程清理来得太晚，因为权限已经进入了子进程。

如果辅助进程通过网关请求上传，撤销父会话就能阻止新的上传请求。如果上传已经开始，网关审计也会记录这一事实。你仍然要终止辅助进程，但不再依赖终止进程作为唯一控制手段。

更棘手的是，有些辅助进程本来就需要比智能体存活更久，例如本地预览服务器或排队中的发布任务。不要把它称为子进程，然后让它永久继承权限。为它指定明确的负责人、独立的授权记录、明确的过期时间和可见的停止控制。一旦工作超出发起它的任务生命周期，它就已经成为独立的运维对象。

## 用顽固的后代进程测试取消功能

从未测试过的控制，往往会在最不方便的时候失效。构建一个无害的测试智能体，让它启动一个会休眠的子进程，打开一个无害的本地连接，并在父进程退出后尝试通过网关执行操作。然后在多个时点取消父进程：子进程启动前、子进程运行期间、子进程脱离后，以及操作正在执行期间。

预期结果必须具体。普通子进程应随进程组一起退出。脱离的子进程可能仍然可见，这正好说明为什么还要继续检查。撤销后，每个新的特权请求都应得到拒绝。会话记录应显示批准和撤销，活动记录则应显示边界前后的调用。

不要把测试通过理解成 UI 状态发生了变化。要验证操作系统中的进程状态、网关的决策和审计记录。这是三种不同的观察结果。如果取消按钮只是隐藏任务卡片，那只是表演。

最实际的第一步很简单：每当你授权智能体时，记录根进程身份，并让授权在这次运行结束时独立过期。然后，在你把能改变生产环境的凭据交给取消功能之前，先用一个故意顽固的子进程进行测试。
