From de2dc561aa59cfc4bd863d828bbcde169a06fd85 Mon Sep 17 00:00:00 2001 From: m1ngshum <140998506+m1ngshum@users.noreply.github.com> Date: Fri, 3 Jul 2026 11:33:05 +0800 Subject: [PATCH] fix(dogfood): close the confine-tamper pins race at its root (exit-wait) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The macOS confine dogfood intermittently failed the tamper assertion with 'failed closed on pins, not confine — gate not isolated' (seen again on a docs-only PR #118, proving it's nondeterministic). #114 narrowed this by deleting the whole pins sidecar set before tampering, but couldn't beat the real cause: an async writer in another process. Root cause: driveHappy() resolved the instant the 3rd JSON-RPC response arrived and did child.kill() WITHOUT waiting for exit. The relay child's off-thread pins TOFU write (pins.json + .integrity, triggered by tools/list) was still in flight when control returned, so it landed AFTER the tamper step's rmSync — recreating a partial pins state, so the tampered run's readPins raised PINS-READ-ERROR and failed closed for an UNRELATED reason, masking the CONFINE gate under test. Fix: driveHappy() now resolves only on the child's 'exit' event (with a 2s SIGKILL escalation if SIGTERM stalls). Once the process is gone, no write from it can land after the delete, so the rmSync is final. Test-harness only — no product code; the confine enforcement path itself was always correct (secret read denied EPERM). Verified 3x locally. --- scripts/dogfood-confine.sh | 32 ++++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/scripts/dogfood-confine.sh b/scripts/dogfood-confine.sh index c34ac1e..6781953 100755 --- a/scripts/dogfood-confine.sh +++ b/scripts/dogfood-confine.sh @@ -134,7 +134,19 @@ function driveHappy(args) { function advance() { if (step === 1 && seen[1]) { step = 2; send({ jsonrpc: "2.0", method: "notifications/initialized" }); send({ jsonrpc: "2.0", id: 2, method: "tools/list" }); } else if (step === 2 && seen[2]) { step = 3; send({ jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "probe", arguments: {} } }); } - else if (step === 3 && seen[3]) { done = true; clearTimeout(to); child.kill(); resolve(seen); } + else if (step === 3 && seen[3]) { + // Resolve only once the child has FULLY EXITED — not the instant the 3rd + // response lands. The relay child does an off-thread TOFU pin write + // (pins.json + .integrity) triggered by the tools/list above; resolving + // before exit lets that write land AFTER the tamper step's rmSync, + // recreating a partial pins state → a spurious PINS-READ-ERROR that masks + // the CONFINE gate under test (the flake #114 chased and only narrowed). + // Once the process is gone, no further writes from it are possible. + done = true; clearTimeout(to); + const kto = setTimeout(() => child.kill("SIGKILL"), 2000); // escalate if SIGTERM stalls + child.once("exit", () => { clearTimeout(kto); resolve(seen); }); + child.kill(); + } } child.stdout.on("data", (d) => { buf += d.toString("utf8"); @@ -177,15 +189,15 @@ function runToExit(args) { } // 2. tamper — corrupt the embedded profile hash → must fail closed via the - // CONFINE gate (decide table row 3). Clear the WHOLE pins sidecar set first: - // the happy-path step above ran a real tools/list that TOFU-writes BOTH - // pins.json AND its pins.json.integrity sidecar (off-thread). Deleting only - // pins.json leaves the sidecar, so readPins sees a sidecar with no matching - // file -> PINS-READ-ERROR, which fails closed for an UNRELATED reason and masks - // the CONFINE gate under test. (Timing-dependent: locally the sidecar write may - // not have landed yet; on a slower CI runner it has — which is how the macOS CI - // leg first caught this.) Remove pins.json, its .integrity sidecar, and any - // stale .lock so readPins sees a clean first-run state. + // CONFINE gate (decide table row 3). driveHappy() above now waits for its child + // to fully EXIT, so the relay's off-thread pins TOFU write (pins.json + + // .integrity) has finished before we get here — no write can land after the + // rmSync below. Still clear the WHOLE pins sidecar set (file + .integrity + + // .lock) so readPins sees a clean first-run state: an orphaned sidecar (file + // gone, sidecar left) would raise PINS-READ-ERROR and fail closed for an + // UNRELATED reason, masking the CONFINE gate under test. (This is the flake the + // macOS CI leg first caught, then re-caught when only the file was removed and + // the async writer still raced — closed for good by the exit-wait.) const mcpmDir = path.join(process.env.HOME, ".mcpm"); fs.rmSync(path.join(mcpmDir, "pins.json"), { force: true }); fs.rmSync(path.join(mcpmDir, "pins.json.integrity"), { force: true });