Repository navigation
feat(governed): 受管调用者以进程句柄与签名通道证明身份 - #114
Merged
Merged
Conversation
added 10 commits
October 23, 2026 16:05
新增专职监督进程与受管调用者会话:协调端持有子进程句柄与一条无文件名的 继承式匿名 IPC,身份由这两项事实推导,不采信任何自报字段。 关键约束:会话密钥只走独立的第二管道,不经可转交的 IPC 通道。每次响应 以该密钥做 HMAC 签名,因此仅继承 IPC 句柄的子孙进程无法伪造应答。 同时审计 socket 持有者集合以检测句柄转交,并以 pid + 进程启动时间防止 PID 复用。既有 detached + 0600 上下文方案不被复用为敏感批次的回退路径。 Refs: M-044 / MS-002
负例矩阵:同账号旁路无法经文件系统发现通道;孙进程继承 IPC 句柄后伪造的 应答因缺少会话密钥而被签名校验拒绝;句柄转交由 socket 持有者审计检出; 子进程退出与 pid 复用均不可继续派发。 夹具刻意把 IPC fd 显式交给孙进程,孙进程在继承的描述符上真实读出请求并 注入伪造响应,确保用例不是靠超时侥幸通过。 Refs: M-044 / MS-002
CI 在 Linux 上暴露了三处平台差异,均会削弱反例的有效性: - 持有者审计按 lsof 固定列索引取地址,但两平台列布局不同(macOS 地址 8 位、 Linux 16 位且尾部带 type=STREAM)。改为按地址形状匹配,与平台无关。 - 测试拿全零地址当「未知地址」样本,而它在 Linux 上是合法的未命名 socket 地址,繁忙机器上必然命中。改用不可能存在的地址。 - 夹具孙进程用 fs 读写继承的 fd:Linux 上 libuv 拒绝轮询 socket 而中止, fs.writeSync 对 socket 返回 EINVAL。改用 net.Socket 接管描述符,并按 Node 的 IPC 信封格式写帧,否则协调端会静默丢弃裸 JSON。 Linux 上继承的描述符只有写方向,孙进程读不到请求,只能盲注入伪造响应; macOS 上可读,可精确冒充。测试断言相应加强:伪造必须确实到达协调端,且 伪造结果绝不能被当作子进程的应答返回。 macOS 与 Linux 双平台各 41 项测试全部通过。
独立验证对 MS-002 的结论是 FAIL,决定性缺陷是协调端的中继路径: `_dispatchInbound` 从不校验入站 REQUEST 的 MAC,协调端又用自己的密钥 给入站请求签名。孙进程在继承的句柄上读到协调端发出的 REQUEST,原样 重放为入站请求,协调端就为攻击者选定的载荷执行 handler 并签出合法应答, 攻击者再把该应答原样回灌——协调端接受了一个它监督的子进程从未发出的响应。 方向绑定 MAC 是封堵的根本。签名 kind 现在区分出站与入站: `request-out` / `request-out-response` / `request-in` / `request-in-response` 互不通用,协调端只接受按出站方向签名的应答,入站请求只有先通过入站方向 验签才会被执行。响应 MAC 另外绑定请求载荷摘要(`digestPayload`),使签名 应答无法被挪用到另一个载荷上。 同时修复验证者列出的其余各项: - 身份审计接入请求路径。`auditChannelHolders` 与 `auditProcessContinuity` 在应答返回后运行,`ok:false` 即终止会话并拒绝本次派发;两者都 fail-closed, 拿不到 socket 地址或 start token 时返回 `ok:false` 而非 `ok:true`。 - macOS 启动令牌改为微秒级。`ps -o lstart=` 只到秒,同秒启动的进程会拿到 同一个令牌,pid 复用因此不可检测;改为从 `proc_bsdinfo` 读 `pbi_start_tvsec`/`pbi_start_tvusec`。实测同秒启动的 20 个进程此前只得到 1 个不同令牌,现在得到 20 个。Linux 的启动令牌加上 boot id,重启后计数 不会与上一次混淆。 - `childIdentity` 改为实例上的不可配置只读访问器。`Object.freeze` 只冻结对象 本身,属性仍可被整体替换或用 getter 遮蔽;现在赋值会抛错,`defineProperty` 也无法覆盖。批次范围原本挂在会话上(可被外部 `.add()` 改写的死代码), 已移出,真正生效的是监督器闭包里的 `permitted`。 - Node 的 IPC 信封在直写 socket 时不会被解开,协调端会把整帧当消息丢弃。 现在路由前先解包,否则继承句柄的后代写来的帧根本不会被处理,反例也就 证明不了任何事。 - 补 relay 与载荷注入负例:断言拒绝必须点名认证失败,排除 `timed out` 蒙混过关;并断言攻击者载荷从未被执行。 - 修掉两处恒真断言:原 `assert.notEqual(reply.answeredOnParentChannel, true)` 检查的是一个全仓无人产生的字段;转交句柄一例此前也接受 `timed out` 作 为拒绝理由。 顺带修正夹具:transfer 与 relay 两种模式此前都不回答协调端的请求, `transfer` 模式因此永远走不到应答路径。 验证:macOS 与 Linux(node:22-bookworm + lsof)各 51/51 通过; `tests/governed/` 全量 219/219 通过。原 `codex-shadow-persist` 的失败与本次 改动无关——M044 工作树的 `.agent` gitlink 未初始化,缺 `.agent/skills/context-budget`,补上后该文件 3/3 通过。
第一轮独立验证对 590efbc 给出 FAIL,决定性缺陷是协调端的中继路径:_dispatchInbound 不校验入站 REQUEST 的 MAC,协调端用自己的密钥给入站请求签名,攻击者可让协调端为攻击者载荷执行 handler 并把合法签名应答回灌。 修复为方向绑定的 MAC(request-out / request-out-response / request-in / request-in-response 互不通用)+ 响应 MAC 绑定请求载荷摘要;入站请求强制验签后才执行。另修 Node IPC 信封未解包导致反例根本没打到协调端、审计从未在请求路径调用且降级未 fail-closed 两处。 同时更正早前对 codex-shadow-persist 失败归因的表述:该失败的真实原因是本工作树 .agent gitlink 未初始化(缺 .agent/skills/context-budget),补上后该文件 3/3 通过、tests/governed 219/219 通过,与本次改动无关。
上一轮的中继封堵已让独立验证通过四项判据,但 CI 的 full-test 在 Linux 上 仍然失败,同时独立验证提出了一项 P1 与数项 P2 观察项。本轮一并处理。 CI 失败的根因不是测试本身,而是身份审计在 Linux 上认错了 socket。 `lsof -U` 的列在两个平台上含义不同:macOS 打印地址对(local ->peer), Linux 打印的 `0x...` 只是 inode 的哈希,会与无关 socket 碰撞。此前只按 本地地址匹配,于是在繁忙的 CI 机器上把一个干净通道误判成 `channel_handle_transferred`。 改为按 socket 对象本身识别:Linux 读 `/proc/<pid>/fd/3` 的 inode, macOS 仍用地址对。实测确认同一 socket 在两端共享同一 inode,而继承 fd 的孙进程也共享该 inode,因此转交仍可被检出,误判消失。 独立验证的观察项: - P1:fail-closed 审计发生在派发之后,敏感载荷已经上线路。审计前移到 写帧之前,被拒绝的请求不再写入通道;往返结束后仍保留一次审计,以 覆盖请求在途期间发生的转交。 - P2:`_childIdentity` 是普通可写属性,写入它即可让 pid 复用检查失效。 身份槽改为闭包变量,事实来源的读取函数在构造时绑定,外部无法替换。 - P2:`spawnOptions` 展开在最后,可覆盖 `detached` 与 `stdio`。这两个键 现在最后应用,不再可被逃生口改写。 - P2:macOS 启动令牌在辅助程序编译失败时静默降级为秒级粗粒度,pid 复用因而不可检测。粗粒度令牌带标记,连续性审计对粗粒度令牌 fail-closed。 负例随之调整:中继夹具改为在首个请求到达时才启动同谋,使转交发生在 请求在途期间。这样入站 MAC 校验这一层被真正覆盖,而不是被派发前的 审计提前挡掉。 验证:macOS 与 Linux 双平台各 57/57 通过;governed 套件 15/15 通过; 全量 479/479 通过;check-l3-boundary 与 check-template-parity 均无违规。
记录第二轮独立验证的四项判据 PASS 结论、本轮修复的 CI 失败根因 (Linux 上 lsof 的 0x 列只是 inode 哈希会碰撞)、以及 P1 与各 P2 观察项 的具体修法。同时说明负例夹具改为在首个请求到达时才启动同谋的原因, 避免负例因派发前审计提前挡掉而失去意义。
第三轮独立验证对 `4614b735` 裁定 VC-044-004 FAIL,指出两个真实缺陷。 **P1:身份写入器其实仍可从外部调用。** 上一轮把身份槽改成闭包变量, 但把写入器挂在实例上作为 `session._setChildIdentity`——它虽然不可配置、不可 写入,却仍然**可被调用**,于是任何持有 session 引用的代码都能改写已确立的 身份,把 pid 复用检查比对成「替换进程与它自己」,审计随即报成干净通道。 关键教训是:让一个句柄**难以被替换**不等于**没有句柄**。写入器必须根本 不出现在实例表面上。身份与三个事实读取函数改用**私有类字段**(`#identity`、 `#collectFacts`、`#readHolders`、`#tokenIsPrecise`):它们不是自有属性、不在原型 上、`getOwnPropertyNames` 与 `getOwnPropertyDescriptors` 都看不到,外部没有任何 可写句柄。 新增测试按攻击者的真实建模复现该绕过:只改 pid 复用真正会变的那一个事实 (start token),其余全走真实事实源,断言审计仍然拒绝;并断言实例上不存在 任何写入器(**调用即无效**,而不只是「无法 redefine」——上一轮的断言目标与 攻击面错位,正是它让这个洞漏了过去)。两条新测试均已对未修复代码确认失败。 **P2:Linux `readSocketHolders` 漏读带路径的 socket。** 原先按 `/\s(\d+)\s+type=/` 匹配 NODE 列,但**命名** socket 的 NAME 列(路径)夹在 inode 与 `type=` 之间, 正则失配整行被跳过,于是持有者集合变成**空集**——一个 fail-open 方向。IPC 通道 本身是匿名 socketpair,不受影响,但该缺口必须闭合。改为按 inode 相对 `0t0` 列的位置匹配,新增回归测试断言带路径 socket 的持有者能被找到(已对旧正则 确认会漏)。 **文档事实更正。** 上一轮称「Linux 的 `0x` 列是 inode 的哈希、会碰撞」, 以及「连接两端共享同一 inode」。实测两者都不成立:连接两端是两个不同的 socket 对象、inode 不同(子 1866278 / 父 1866277),`0x` 列也不是 inode 的函数 (socketpair 两端 inode 不同而 `0x` 相同)。正确表述是:inode 标识**socket 的 一端**,而继承 fd 的一方与被继承方共享该 inode——这正是审计需要的性质。 PR body 与代码注释已按实测更正,并写明 CI 失败的确切触发条件尚未完全解释。 验证:macOS 与 Linux 双平台各 59/59 通过;`--scope governed` 15/15 通过; 全量 479/479 通过;CI `Full Test Suite` 与 `M-040 Final Product Validation` 成功。
Kucell
force-pushed
the
codex/m044-ms002-managed-caller
branch
from
October 23, 2026 08:24
772f400 to
d04778f
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Implements M-044 / MS-002 — managed caller proof: a dedicated, non-detached supervisor
process that holds the supervised child's OS process handle plus a filename-less inherited
bidirectional IPC channel, and derives the caller's identity from those two facts alone.
Origin Story
The upstream M-053 / MS-008 R3 coordination contract lists "handle mis-inheritance and
explicit handle transfer" as mandatory negative cases (VC-044-005) but does not give a
mechanism by which they are rejected.
This implementation measured that gap on the target platform and found it real:
fork+stdio: [..., 'ipc']channel lives on fd 3 in the child, and that fdcan be deliberately handed to a descendant via
spawn(..., { stdio: [..., 3] }).the parent's channel. Reproduced locally — the coordinator received a forged
result: { forged: true }before the genuine answer.transferable, so the descendant sees it too, and it can replay it.
The fix separates the two channels: the session secret travels only on a second,
independent pipe (fd 4 in the child), and every response carries an HMAC-SHA256 over the
request identity. A descendant that inherited only the IPC handle has no secret, so it cannot
produce a valid MAC. Two further layers detect rather than merely blunt the attack:
the socket's holder set is audited for handle transfer, and a pid + process-start-time token
detects PID reuse.
The relay attack — found by the first independent review
The first independent review of this PR (against
590efbc4) returned FAIL, and it found ahole the secret-on-a-second-channel fix does not close.
The coordinator's
_dispatchInboundnever verified the MAC of an inbound request, and thecoordinator signed its own answer to that inbound request with its own key. A descendant
holding the transferred handle could therefore:
The coordinator then accepted a response its supervised child never sent, computed from a
payload the attacker chose. The reviewer reproduced it 3/3 on the pristine commit:
The fix is direction binding. The signed kind now separates the two directions —
request-out/request-out-response/request-in/request-in-response— and the four arenot interchangeable. The coordinator accepts only an answer signed as
request-out-response,and it executes an inbound request only after that request verifies under
request-in. Thecoordinator therefore never signs an answer to an unauthenticated inbound request, so there is
no signed material left for an attacker to relay. The response MAC additionally binds the
request payload digest, so a signed answer cannot be moved to a different payload.
Two further findings from the same review were fixed in the same round:
socket arrives with Node's IPC envelope intact, and the coordinator was discarding the whole
envelope instead of routing the payload — so the "forged response" fixtures had never
actually delivered a forgery. The envelope is now unwrapped before routing.
auditChannelHolders/auditProcessContinuityexisted butran on no request path, and degraded environments reported
ok: true. Both now run on therequest path and fail closed: an unknown socket address or a missing start token refuses
the dispatch rather than permitting it.
Commits in this PR (oldest first)
06aec2fc590efbc4b80d5ae8d630b0ddbd4c006cc49e7c9ed44ec2847dd23d95772f4005What is in the diff
lib/governed/managed-caller-signing.jslib/governed/managed-caller-identity.jslib/governed/managed-caller-session.jslib/governed/managed-caller-supervisor.jsstdio/detachedkeys are not overridabletests/governed/managed-caller-*.test.jstests/governed/fixtures/m044-ms002-*.jsWhat the second independent review found, and this round's fixes
The second independent review (against
bd4c006c, by a validator who did not implementthe change) returned PASS on all four criteria — VC-044-003, VC-044-004, VC-044-005 and
VC-044-018 — after reproducing the relay attack with three independent attack chains on both
macOS and Linux. It also recorded one P1 and several P2 observations. Fixes landed across
d44ec284,7dd23d95, and772f4005.The CI failure was a real defect, not a flaky test
Full Test Suitefailed on the Linux runner withchannel_handle_transferredon a cleanchannel. The cause was in the identity audit, not in the test:
lsof -Umeans differentthings on the two platforms. macOS prints an address pair (
local ->peer), while on Linux the0x...columnis not a reliable identifier for the socket endpoint. Matching it produced a transferred-handle
report for a clean channel. The earlier inode-hash collision explanation was incorrect; the exact
trigger of the CI failure remains unproven.
The socket is now identified by the socket object itself: on Linux the kernel inode from
/proc/<pid>/fd/3, on macOS the address pair.Measured on Linux: the two ends of a connection are two distinct socket objects with two
distinct inodes (e.g. child
1866278/ parent1866277), so the inode does not identify aconnection — it identifies one end of it. That is exactly what the audit needs: a descendant
that inherited the descriptor holds the same socket object, and therefore the same inode, and
it shows up as an extra holder. The transfer is still detected, and the false positive is gone.
One correction to the earlier explanation in this PR: the
0x...column on Linux is not ahash of the inode. The two ends of a connection carry different
0xvalues despite havingdifferent inodes, and the same
0xvalue on a socketpair's two ends despite their inodesdiffering — so the column is not a function of the inode at all. The inode is used because it is
precise and agrees with a direct
/procread of the holder set, not because the0xcolumn wasshown to collide. The exact trigger of the CI failure is therefore not fully explained; the
defect it exposed — matching a socket by a value that does not identify it — is fixed either way.
P1 — the audit ran after the payload had gone out
request()wrote the request frame onto the channel and then ran the identity audit. Arefusal therefore reported "refused to dispatch" while the sensitive payload had already been
written to the channel, where a holder of the transferred handle could read it. The audit now
runs before the frame is written, and a second audit runs after the round trip to cover a
transfer that happens while the request is in flight. A test asserts that a refused request
produces no request frame on the channel.
P2 — the identity slot and the fact source were replaceable
childIdentitywas exposed through a read-only accessor, but the slot behind it was a plainwritable property. Writing it made the recorded start token match whatever the caller put
there, which defeated the pid-reuse check the continuity audit compares against. The slot and fact readers now use private class fields. The instance and class prototype are
hardened in
772f4005, so their audit methods cannot be replaced through the session surface. The degraded-environment tests were rewritten to simulatethe degradation at its source (the fact reader returns the degraded fact) instead of
overwriting the identity derived from it.
P2 —
spawnOptionscould detach the supervised childThe caller's spawn options were spread last, so they could replace
detached: trueand thestdiolayout — the two properties the trust primitive depends on. They are now applied lastand are not overridable. A test asserts the child is not in its own process group even when
the caller asks for
detached: true; the test was confirmed to fail against the unfixed code.P2 — a coarse start token silently degraded pid-reuse detection
When the macOS helper could not be compiled, the start token silently fell back to
ps -o lstart=, which resolves to whole seconds — so every process started in that secondshared a token and a pid reuse between them was undetectable. The coarse token is now marked
(
ps-coarse:) and the continuity audit fails closed on it rather than reporting a pass fora comparison it cannot make.
The negative case was adjusted so it still proves something
With the audit moved before dispatch, the relay fixture was blocked earlier and the inbound-MAC
layer was never reached — the case would have passed for the wrong reason. The relay fixture now
starts its accomplice when the first request arrives, so the handle is transferred while the
request is in flight. The relayed inbound request really reaches the coordinator, where it has
to be refused on authentication grounds.
Validation
Two new regressions failed on the previous implementation. A concurrent first run hit a Linux 30-second file timeout and slowed an unrelated event-bus performance benchmark; serial Linux (63/63), the benchmark (5/5), and the full strict suite (479/479) then passed.
Correction to an earlier attribution
An earlier revision of this PR described the
governed/codex-shadow-persist.test.jsfailure as"pre-existing — it fails identically with this branch's changes stashed". That attribution was
wrong. The real cause is that this worktree's
.agentgitlink is not initialised (the repo hasno
.gitmodules, sogit submodule statusreportsfatal: no submodule mapping found), andlib/governed/codex-shadow-persist.jsrequires.agent/skills/context-budget/scripts/minimal-context.js. Supplying that skill makes the filepass 3/3 and the whole
tests/governed/directory pass 219/219. CI fetches.agentat the pinnedgitlink commit, so CI is unaffected.
Scope boundaries (deliberately not touched)
lib/governed/launcher.jsis unchanged; its existingdetached: true+0600contextfile behaviour still serves the existing non-sensitive flows and is never used as a
fallback for sensitive batches (a test asserts the sensitive path contains no
CORTEX_LAUNCH_CONTEXT,context.jsonordetached: true)lib/coordination/lease*.jsuntouched (MS-003 scope)Current revision and remaining gate
Head:
772f4005d9142e35d41de504e92b9ce5f7fcc75a(fifth repair).The fourth frozen source still allowed a holder of the session object to replace the prototype audit methods and dispatch despite a modelled PID reuse. The session also exposed writable
secret,child,pending, and handler state. This revision moves trust-critical state to private fields, makes the instance non-extensible, freezes the class prototype and production fact exports, and prevents a second inbound handler from bypassing the fixed-batch policy.MS-002 remains pending independent sign-off on this exact SHA for VC-044-003/004/005/018. Earlier reviews and their PASS/FAIL findings apply only to the SHAs they inspected. MS-001's threat-model Decision remains open; MS-003 must not start before the Owner decides how to handle same-account state tampering. SamHMI product security stays BLOCK.
Refs: M-044 / MS-002