Potpie Daemon 信号终止权限收敛:ADR-0012 如何限制强制终止仅作用于直接子进程
【免费下载链接】potpieContext Graph for AI Native SDLC项目地址: https://gitcode.com/GitHub_Trending/po/potpie
导读
本文围绕 Potpie 的架构决策记录 ADR-0012(Restrict Forceful Daemon Termination To Owned Children)展开,解析该决策如何堵住一条因 PID 复用引发的“误杀无关进程”安全漏洞:此前 CLI 在守护进程无法通过认证时,允许依据daemon.pid中记录的数值 PID 发送SIGTERM/SIGKILL,而 PID 会被操作系统复用,导致信号可能落到无关进程上。读完本文你将掌握:Potpie 本地守护进程(daemon)的终止权限模型、直接子进程与“重挂载(attach)”进程的边界、RuntimeOwnershipLock与运行时记录的串行化清理机制,以及 DAEMON-052 至 DAEMON-056 五条新行为约束的源码级实现与测试验证。
背景:ADR-0009 留下的“有界操作系统终止”回退通道
Potpie 的守护进程架构建立在 ADR-0009 定义的“类型化本地运行时执行契约”之上。该契约规定:
DaemonController直接创建并观察一个前台子进程,不委托给操作系统级 supervisor;- 正常停止必须走“认证的类型化 daemon-control 操作”(即
daemon.shutdown类型的控制通道); - 但如果“无法认证任何存活端点”,控制器允许使用有界操作系统终止(先
SIGTERM,超时后SIGKILL),然后再清理过期运行时记录。
问题恰恰出在这个回退通道上。ADR-0009 允许回退的意图是处理“守护进程已死但记录残留”或“端点无法认证”的边界场景,但它隐含了一个危险假设:daemon.pid文件中的数值 PID 足以标识守护进程实例。
问题本质:PID 是诊断元数据,不是实例身份
后来的 CLI 调用会从daemon.pid中读出数字 PID,并据此重建一个进程句柄(_RecordedDaemonProcess)。如果守护进程已经崩溃退出,而操作系统恰好把该 PID 分配给了另一个进程,那么:
- 发现流程缺失、无效或无法认证(
missing, invalid, or unauthenticated discovery); - 控制器仍然依据残留 PID 调用
os.kill(pid, signal.SIGTERM),甚至SIGKILL; - 信号实际送达的是一个与本守护进程毫无关系的同用户进程。
这与已经接受的守护进程需求相冲突:
- PID 只是诊断元数据,而不是 daemon 实例身份(对应 conformance 中的
DAEMON-012:PID remains diagnostic); - 过期发现必须安全失败(
DAEMON-013:Stale discovery recovers safely); - 仅凭“owner-only 运行时文件”只能证明文件的控制权,不能证明持有被复用 PID 的进程就是原来的守护进程。
这是一个“本地同用户进程终止”漏洞(local same-user process-termination vulnerability),正是 SPEC-CHANGE-0012 中change_type: security的由来。
决策核心:强制终止仅限“直接拥有的子进程”
ADR-0012 给出的裁决非常明确(见 ADR-0012):
有界操作系统终止仅允许通过“由当前控制器直接创建的前台子进程句柄”执行。由后续 CLI 调用重建出来的控制器,只使用认证的类型化 shutdown;当类型化 shutdown 无法认证、失败或超时时,不得发送操作系统终止信号。
具体落到三条规则:
- attach 不授权信号:
DaemonController.attach()从运行时记录重建控制器时,_owns_process被置为False,该控制器对进程只有“观察”权,没有“终止”权; - 无法安全停止就返回类型化错误:返回
ResourceLifecycleError,不声称守护进程已停止(no stopped claim),并保留运行时记录; - 清理受身份约束:除非清理被“精确期望的 daemon 实例身份 + PID”双重守卫,否则不得删除记录;凭证发布与清理全部串行化在运行时所有权锁(
RuntimeOwnershipLock)之后。
同时,该决策只取代 ADR-0009 中“无法认证端点时允许有界 OS 终止”这一条,不改变:直接子进程的 readiness 清理、认证类型化 shutdown、daemon 传输、bearer 认证、发现 schema 与正常重启行为。
源码级实现:DaemonController的所有权与信号边界
核心实现在 potpie/runtime/controller.py 的DaemonController类中。类内部通过_owns_process字段区分“本控制器创建的子进程”与“从记录重挂载的进程”:
@property def owns_process(self) -> bool: """Whether this controller created the currently observed process.""" return self._owns_processstart()通过asyncio.create_subprocess_exec直接创建子进程后立即设置self._owns_process = True(controller.py中start()路径);attach()重建控制器时明确设置self._owns_process = False,其 docstring 也点明这是“观察早前 CLI 调用启动的守护进程,而非外部 supervisor 集成”(见 controller.py)。
stop()中的关键分叉逻辑(已简化为要点,完整实现在 controller.py):
if isinstance(requested, Success): try: await asyncio.wait_for(process.wait(), timeout=self._stop_timeout_s) # ... return StopResult(mode="typed_shutdown", ...) except TimeoutError: if not self._owns_process: return self._attached_stop_failure(...) # 不发送信号 result = await self._bounded_terminate(process) # 仅直接子进程可走此路 else: if not self._owns_process: return self._attached_stop_failure(...) # 认证不可用也不发送信号 result = await self._bounded_terminate(process)也就是说,_bounded_terminate(SIGTERM→ 超时 →SIGKILL)只可能被直接拥有的子进程路径触达;而 attach 路径在“shutdown 超时”(daemon_attached_shutdown_timeout)或“shutdown 不可用”(daemon_attached_shutdown_unavailable)时一律返回Failure[ResourceLifecycleError],并携带:
retry_posture="safe";recommended_next_action="inspect daemon status and runtime records before manual recovery";- 错误 details 中的
pid以及可选的cause_category/cause_code。
测试如何锁定该边界
test_daemon_controller.py 中有两个成对出现的测试,分别验证“直接子进程允许有界终止”和“attach 进程拒绝信号回退”:
test_controller_falls_back_to_bounded_signal_termination:直接启动的子进程,在 observer 报告ready=True但 shutdown 不可用时,stop()返回StopResult(mode="terminated", exit_code=-int(signal.SIGTERM));test_attached_controller_refuses_signal_fallback(参数化ready=False/True):先attach()再stop(),断言结果为Failure、错误码为daemon_attached_shutdown_unavailable、retry_posture == "safe",并且关键断言process.terminate_calls == 0、process.kill_calls == 0、process.wait_calls == 0——即 attach 控制器在认证不可用时对进程句柄零信号、零等待。
身份约束的清理:remove_daemon_runtime_records的双重匹配
运行时记录的清理位于 potpie/daemon/discovery.py 的remove_daemon_runtime_records()。清理只删除三类 canonical 记录(discovery.json、daemon.credential、daemon.pid)以及 UDS 端点文件,且必须先通过双重身份匹配:
if expected_instance_id is not None and ( discovery is None or discovery.instance_id != expected_instance_id ): return if expected_pid is not None and ( discovery is None or discovery.pid != expected_pid ): return只有当前discovery.json中的instance_id与pid同时等于调用方期望值时,才执行删除。这正好落实了 DAEMON-056:identity-bound cleanup 必须精确匹配期望 PID 与 per-boot 实例身份,杜绝“检查后删除”竞态(check-then-unlink race)误删替换启动(replacement boot)的记录。
所有权锁:发布与清理的串行化
凭证发布与记录清理全部经由 potpie/runtime/ownership.py 的RuntimeOwnershipLock串行化。该锁是跨平台的 OS 级排他锁:POSIX 上使用fcntl.flock(LOCK_EX | LOCK_NB),Windows 上回退到msvcrt.locking(LK_NBLCK);锁文件路径要求绝对路径,父目录chmod 0o700、锁文件chmod 0o600。
在 potpie/daemon/lifecycle.py 中可以看到它的两处典型用法:
Daemon.start()在启动前先_cleanup_runtime_records()抢锁,抢不到(daemon_ownership_conflict)则拒绝启动,防止两个 boot 并发写入记录;Daemon.stop()在类型化 shutdown 成功后才调用_cleanup_runtime_records_under_lock(expected_instance_id=..., expected_pid=pid),在持锁状态下执行身份约束的删除。
这样,一次并发的 stop 无法擦除正在进行的 replacement boot 的记录,凭证发布与所有清理都“串行化通过运行时所有权锁”,即 DAEMON-054 与 DAEMON-055 的实现基础。
行为约束落地:DAEMON-052 至 DAEMON-056
daemon 模块规格 中新增的五个行为 ID 与 ADR-0012 一一对应:
| 行为 ID | 约束内容(依据 spec/modules/daemon.md) |
|---|---|
| DAEMON-052 | 控制器只能通过自己直接创建的前台子进程句柄发送 OS 终止信号;禁止对从运行时记录 attach 的进程使用信号回退 |
| DAEMON-053 | attach 进程的类型化 shutdown 无法认证 / 失败 / 超时时,控制器必须返回ResourceLifecycleError,不得发送信号,也不得声称已停止 |
| DAEMON-054 | canonical PID 记录、discovery 文档与 per-boot 凭证的发布,必须仅在对应 boot 持有运行时所有权锁时进行 |
| DAEMON-055 | 任何删除 canonical PID / discovery / credential / owned UDS 端点的操作,必须持有或获取运行时所有权锁 |
| DAEMON-056 | 针对已知 daemon 身份的清理,仅当 discovery 文档同时匹配精确期望 PID 与精确期望 per-boot 实例身份时才允许删除 |
备选方案:为什么最终没有采用
ADR-0012 明确评审了四种替代方案,并给出了否决理由(见 ADR-0012 的 Alternatives Considered):
- 保留纯 PID 有界终止:限时等待只能缩短危险窗口,不能建立目标身份,PID 复用仍可能终止无关的同用户进程;
- 认证一次后对记录 PID 发信号:认证只证明了某个瞬间的端点,无法把后续信号绑定到同一个 OS 进程化身——认证与发信号之间守护进程可能退出、PID 可能被复用;
- 持久化跨平台进程化身令牌:Linux pidfd 或进程启动时间(start time)理论上支持更强的重挂载,但可移植的身份契约与恢复策略需要单独决策,P1 修复不依赖引入该机制;
- 匹配可执行名或命令行:名称与命令行既不稳定、也无法作为不可伪造的进程身份,同样无法关闭 PID 复用竞态。
因此该决策刻意选择了“fail closed”:宁可让无响应的守护进程需要人工恢复,也不冒险向无法证明身份的 PID 发信号。未来若引入独立的进程化身机制(process-incarnation mechanism),再另行决策。
后果与用户可见影响
ADR-0012 的后果清单(Consequences)可以归纳为对三类场景的影响:
- 过期或被复用的 PID 记录:不再能授权
SIGTERM/SIGKILL(漏洞关闭); - 健康守护进程:跨 CLI 调用仍通过认证的类型化
daemon.shutdown正常停止,无行为回归; - 直接拥有的子进程:readiness 失败或类型化 shutdown 未完成时,仍可接收有界终止(
terminated/killed模式); - 无响应的 attach 守护进程:现在失败关闭,
stop返回ResourceLifecycleError与非零 CLI 退出码,不会报告“已停止”,并建议人工恢复(manual recovery); - 清理竞态:未经身份验证的过期控制器无法删除 replacement boot 的记录。
这些语义变化通过 SPEC-CHANGE-0012 以规格变更记录的形式落地,将SPEC-DAEMON从 revision 1 推进到 revision 2。
验证与一致性:conformance 记录
daemon conformance 记录 在 PR-review 修复实现上验证了 DAEMON-001 至 DAEMON-056,其中:
DAEMON-052:OS signals remain limited to a directly owned child process handle—passed;DAEMON-053:Attached shutdown failures return typed errors without signalling or a stopped claim—passed;DAEMON-054:Canonical runtime-record publication occurs while the boot owns the runtime lock—passed;DAEMON-055:Runtime-record removal is serialized through the ownership lock—passed;DAEMON-056:Identity-bound cleanup matches the exact expected PID and per-boot instance—passed。
验证证据包括 pinned source review(D2-E1)、完整测试根道(D2-E2:uv run pytest tests -m "not premerge_journey" -q,1447 passed)、架构清单(D2-E3)以及聚焦协议/安全/生命周期车道(D2-E5,含 controller、runtime client/codec/transport、detached-daemon E2E、ownership-race 等测试)。DAEMON-039与DAEMON-049因“无兼容适配器、无备选运行时”而被标记为 verified-not-applicable。
小结
ADR-0012 是 Potpie 在“本地权威边界(local authority boundary)”上的一次重要安全收敛:它把操作系统信号的授权从“记录中的数字 PID”收窄到“当前控制器直接创建的进程句柄”,用_owns_process、RuntimeOwnershipLock与 PID + instance 双重身份匹配三重机制,从决策、规格、实现、测试到 conformance 记录形成完整闭环。对于理解 Potpie 守护进程生命周期(lifecycle.py)与 CLI 控制面(ADR-0009、daemon 模块规格)的读者而言,这条决策链展示了“类型化 shutdown 为主、有界信号为直接子进程保留的例外”这一清晰的权限模型。
【免费下载链接】potpieContext Graph for AI Native SDLC项目地址: https://gitcode.com/GitHub_Trending/po/potpie
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考