diff --git a/test/openclaw-config-guard-absent-hash.test.ts b/test/openclaw-config-guard-absent-hash.test.ts index 5142ca7b6ed..d3c79351f1f 100644 --- a/test/openclaw-config-guard-absent-hash.test.ts +++ b/test/openclaw-config-guard-absent-hash.test.ts @@ -94,7 +94,7 @@ function fixture() { return { root, configDir, configPath, hashPath }; } -function runGuard(action: "lock" | "unlock", configDir: string, failure = "none") { +function runGuard(action: "lock" | "preflight" | "unlock", configDir: string, failure = "none") { const result = spawnSync( "python3", ["-c", RUN_AS_CURRENT_USER, GUARD_PATH, action, configDir, failure], @@ -172,6 +172,28 @@ describe("openclaw-config-guard lock with an absent .config-hash", () => { expect(rejectedNames(configDir)).toEqual([]); }); + it("replaces openclaw.json so a retained writable descriptor cannot mutate the sealed config", () => { + const { configDir, configPath, hashPath } = fixture(); + const retainedDescriptor = fs.openSync(configPath, "r+"); + try { + fs.rmSync(hashPath); + + const result = runGuard("lock", configDir); + + expect(result.status, JSON.stringify(result.lines)).toBe(0); + const attackerBytes = Buffer.from('{"gateway":{"port":19999}}\n'); + expect(fs.writeSync(retainedDescriptor, attackerBytes, 0, attackerBytes.length, 0)).toBe( + attackerBytes.length, + ); + fs.fsyncSync(retainedDescriptor); + const verification = runGuard("preflight", configDir); + expect(verification.status, JSON.stringify(verification.lines)).toBe(0); + expect(fs.readFileSync(hashPath, "utf-8")).toBe(CONFIG_HASH_RECORD); + } finally { + fs.closeSync(retainedDescriptor); + } + }); + it("relocks idempotently after a synthesized-hash lock without rewriting inodes", () => { const { configDir, configPath, hashPath } = fixture(); fs.rmSync(hashPath);