From 5d04423d504b16e450af263e486ddc5341ead2f4 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 12 May 2026 07:34:04 -0700 Subject: [PATCH 1/5] fix(blueprint): harden openclaw baseline recovery --- scripts/nemoclaw-start.sh | 66 ++++++++++++++++++++++++++-------- test/nemoclaw-start.test.ts | 72 +++++++++++++++++++++++++++++++++---- 2 files changed, 117 insertions(+), 21 deletions(-) diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index b38c093ba98..462ea0754c9 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -216,6 +216,23 @@ _SANDBOX_HOME="/sandbox" # Home dir for the sandbox user (useradd -d /s # inherit group=sandbox regardless of which UID created them, so the # agent keeps read access and shields-up locking still works the same. # +# Keep the recovery baseline outside the mutable group-write contract. It is +# readable by the sandbox group for restore, but only root should rewrite it. +lock_openclaw_config_baseline_if_present() { + local config_dir="${1:-/sandbox/.openclaw}" + local baseline_file="$config_dir/openclaw.json.nemoclaw-baseline" + + [ -f "$baseline_file" ] || return 0 + [ "$(id -u)" -eq 0 ] || return 0 + + if [ -L "$config_dir" ] || [ -L "$baseline_file" ]; then + return 0 + fi + + chown root:sandbox "$baseline_file" 2>/dev/null || true + chmod 0440 "$baseline_file" 2>/dev/null || true +} + # Idempotent. Skips when shields are UP (config dir owned by root) so # the lock is not weakened. normalize_mutable_config_perms() { @@ -234,6 +251,7 @@ normalize_mutable_config_perms() { find "$config_dir" -type d -exec chmod g+s {} + 2>/dev/null || true chmod 2770 "$config_dir" 2>/dev/null || true chmod 660 "$config_dir/openclaw.json" "$config_dir/.config-hash" 2>/dev/null || true + lock_openclaw_config_baseline_if_present "$config_dir" } openclaw_config_dir_owner() { @@ -365,6 +383,13 @@ write_openclaw_config_baseline() { return 0 fi + # Idempotent — only capture once per sandbox. Still re-lock an existing + # baseline because mutable permission normalization is intentionally broad. + if [ -f "$baseline_file" ]; then + lock_openclaw_config_baseline_if_present "$config_dir" + return 0 + fi + # Skip in shields-up mode — config is supposed to be locked, baseline # capture is unnecessary and the prepare/restore permission dance is # already owned by the override paths. @@ -372,9 +397,6 @@ write_openclaw_config_baseline() { return 0 fi - # Idempotent — only capture once per sandbox. - [ -f "$baseline_file" ] && return 0 - # Refuse to capture broken state. grep -q '[^[:space:]]' is false for both # 0-byte and whitespace-only files. if ! grep -q '[^[:space:]]' "$config_file" 2>/dev/null; then @@ -385,16 +407,31 @@ write_openclaw_config_baseline() { # baseline a known-good restore target. openclaw.json is JSON5 (comments, # trailing commas) everywhere else in the stack — OpenClaw uses # JSON5.parse / parseJsonWithJson5Fallback, and migration-state.ts uses - # JSON5.parse — so the validator here matches that contract instead of - # rejecting JSON5 features as the strict json.load would. - if ! python3 - "$config_file" 2>/dev/null <<'PY_VALIDATE'; then -import json, re, sys -src = open(sys.argv[1]).read() -src = re.sub(r'//[^\n]*', '', src) # line comments -src = re.sub(r'/\*[\s\S]*?\*/', '', src) # block comments -src = re.sub(r',(\s*[}\]])', r'\1', src) # trailing commas -json.loads(src) -PY_VALIDATE + # JSON5.parse — so use the real JSON5 parser instead of approximating the + # grammar with regexes. + if ! node - "$config_file" 2>/dev/null <<'NODE_VALIDATE'; then + const fs = require("fs"); + + let JSON5; + for (const candidate of [ + "/opt/nemoclaw/node_modules/json5", + "./nemoclaw/node_modules/json5", + "json5", + ]) { + try { + JSON5 = require(candidate); + break; + } catch { + // Try the next runtime location. + } + } + + if (!JSON5) { + process.exit(1); + } + + JSON5.parse(fs.readFileSync(process.argv[2], "utf8")); +NODE_VALIDATE return 0 fi @@ -404,8 +441,7 @@ PY_VALIDATE # 0440 root:sandbox so the gateway/sandbox user can READ for recovery but # cannot truncate or rewrite the baseline through the same path that # corrupts the active config. - chown root:sandbox "$baseline_file" 2>/dev/null || true - chmod 0440 "$baseline_file" 2>/dev/null || true + lock_openclaw_config_baseline_if_present "$config_dir" printf '[config] Baseline snapshot created: %s\n' "$baseline_file" >&2 } diff --git a/test/nemoclaw-start.test.ts b/test/nemoclaw-start.test.ts index f92eea2dc5c..f7a3a2cfd8a 100644 --- a/test/nemoclaw-start.test.ts +++ b/test/nemoclaw-start.test.ts @@ -2304,6 +2304,49 @@ describe("openclaw.json baseline + recovery (#3118)", () => { }); // ── write_openclaw_config_baseline ──────────────────────────────────────── + function runNormalizeMutableConfigPermsWithBaseline() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-baseline-lock-")); + const openclawDir = path.join(root, ".openclaw"); + fs.mkdirSync(openclawDir, { recursive: true }); + const configPath = path.join(openclawDir, "openclaw.json"); + const hashPath = path.join(openclawDir, ".config-hash"); + const baselinePath = path.join(openclawDir, "openclaw.json.nemoclaw-baseline"); + + fs.writeFileSync(configPath, "{}"); + fs.writeFileSync(hashPath, "oldhash\n"); + fs.writeFileSync(baselinePath, JSON.stringify({ source: "baseline" })); + fs.chmodSync(openclawDir, 0o2770); + fs.chmodSync(configPath, 0o660); + fs.chmodSync(hashPath, 0o660); + fs.chmodSync(baselinePath, 0o460); + + const wrapper = [ + "#!/usr/bin/env bash", + "set -euo pipefail", + "id() { echo 0; }", + "chown() { return 0; }", + `stat() { if [ "$1" = "-c" ] && [ "$2" = "%U" ] && [ "$3" = ${JSON.stringify(openclawDir)} ]; then echo sandbox; return 0; fi; command stat "$@"; }`, + extractShellFunction("lock_openclaw_config_baseline_if_present").replaceAll( + "/sandbox", + root, + ), + extractShellFunction("normalize_mutable_config_perms").replaceAll("/sandbox", root), + "normalize_mutable_config_perms", + ].join("\n"); + const script = path.join(root, "run.sh"); + fs.writeFileSync(script, wrapper, { mode: 0o700 }); + const result = spawnSync("bash", [script], { encoding: "utf-8" }); + const baselineMode = fs.statSync(baselinePath).mode & 0o777; + fs.rmSync(root, { recursive: true, force: true }); + return { result, baselineMode }; + } + + it("keeps the baseline read-only after mutable permission normalization", () => { + const { result, baselineMode } = runNormalizeMutableConfigPermsWithBaseline(); + expect(result.status).toBe(0); + expect(baselineMode).toBe(0o440); + }); + type BaselineFixture = { configContent: string; baselineExists?: boolean; @@ -2324,7 +2367,10 @@ describe("openclaw.json baseline + recovery (#3118)", () => { fs.writeFileSync(baselinePath, JSON.stringify({ stale: true })); } - const helperFns = [extractShellFunction("openclaw_config_dir_owner")] + const helperFns = [ + extractShellFunction("openclaw_config_dir_owner"), + extractShellFunction("lock_openclaw_config_baseline_if_present"), + ] .join("\n") .replaceAll("/sandbox", root); const fn = extractShellFunction("write_openclaw_config_baseline").replaceAll( @@ -2349,8 +2395,9 @@ describe("openclaw.json baseline + recovery (#3118)", () => { const result = spawnSync("bash", [script], { encoding: "utf-8" }); const baselineExists = fs.existsSync(baselinePath); const baselineContent = baselineExists ? fs.readFileSync(baselinePath, "utf-8") : ""; + const baselineMode = baselineExists ? fs.statSync(baselinePath).mode & 0o777 : undefined; fs.rmSync(root, { recursive: true, force: true }); - return { result, baselineExists, baselineContent }; + return { result, baselineExists, baselineContent, baselineMode }; } it("captures baseline snapshot when openclaw.json is valid and no baseline exists", () => { @@ -2363,14 +2410,15 @@ describe("openclaw.json baseline + recovery (#3118)", () => { expect(baselineContent).toBe(config); }); - it("is idempotent — does not overwrite an existing baseline", () => { + it("is idempotent and re-locks an existing baseline", () => { const config = JSON.stringify({ source: "current" }); - const { result, baselineContent } = runWriteBaseline({ + const { result, baselineContent, baselineMode } = runWriteBaseline({ configContent: config, baselineExists: true, }); expect(result.status).toBe(0); expect(baselineContent).toBe(JSON.stringify({ stale: true })); + expect(baselineMode).toBe(0o440); }); it("refuses to capture an empty openclaw.json as baseline", () => { @@ -2399,9 +2447,9 @@ describe("openclaw.json baseline + recovery (#3118)", () => { const config = [ "{", ' // primary model', - ' "agents": { "defaults": { "model": { "primary": "x" } } },', + " agents: { defaults: { model: { primary: 'x' } } },", " /* trailing comma below is JSON5-only */", - ' "models": { "providers": { "inference": {} } },', + " models: { providers: { inference: {} } },", "}", ].join("\n"); const { result, baselineExists, baselineContent } = runWriteBaseline({ @@ -2422,6 +2470,18 @@ describe("openclaw.json baseline + recovery (#3118)", () => { expect(baselineExists).toBe(false); }); + it("re-locks an existing baseline even when shields are up", () => { + const config = JSON.stringify({ ok: true }); + const { result, baselineContent, baselineMode } = runWriteBaseline({ + configContent: config, + baselineExists: true, + dirOwner: "root", + }); + expect(result.status).toBe(0); + expect(baselineContent).toBe(JSON.stringify({ stale: true })); + expect(baselineMode).toBe(0o440); + }); + it("skips baseline write when not running as root", () => { const config = JSON.stringify({ ok: true }); const { result, baselineExists } = runWriteBaseline({ From 99644badbdd687c918e76e6955b010737cc5ea7f Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 12 May 2026 08:03:30 -0700 Subject: [PATCH 2/5] fix(blueprint): fail closed on baseline lock errors --- scripts/nemoclaw-start.sh | 96 ++++++++++++++++++++++++++++++------- test/nemoclaw-start.test.ts | 48 +++++++++++++++++-- 2 files changed, 122 insertions(+), 22 deletions(-) diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 462ea0754c9..9b570374d13 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -229,8 +229,14 @@ lock_openclaw_config_baseline_if_present() { return 0 fi - chown root:sandbox "$baseline_file" 2>/dev/null || true - chmod 0440 "$baseline_file" 2>/dev/null || true + if ! chown root:sandbox "$baseline_file"; then + printf '[SECURITY] Failed to set ownership on %s\n' "$baseline_file" >&2 + return 1 + fi + if ! chmod 0440 "$baseline_file"; then + printf '[SECURITY] Failed to set permissions on %s\n' "$baseline_file" >&2 + return 1 + fi } # Idempotent. Skips when shields are UP (config dir owned by root) so @@ -251,7 +257,7 @@ normalize_mutable_config_perms() { find "$config_dir" -type d -exec chmod g+s {} + 2>/dev/null || true chmod 2770 "$config_dir" 2>/dev/null || true chmod 660 "$config_dir/openclaw.json" "$config_dir/.config-hash" 2>/dev/null || true - lock_openclaw_config_baseline_if_present "$config_dir" + lock_openclaw_config_baseline_if_present "$config_dir" || return 1 } openclaw_config_dir_owner() { @@ -387,7 +393,7 @@ write_openclaw_config_baseline() { # baseline because mutable permission normalization is intentionally broad. if [ -f "$baseline_file" ]; then lock_openclaw_config_baseline_if_present "$config_dir" - return 0 + return $? fi # Skip in shields-up mode — config is supposed to be locked, baseline @@ -409,31 +415,87 @@ write_openclaw_config_baseline() { # JSON5.parse / parseJsonWithJson5Fallback, and migration-state.ts uses # JSON5.parse — so use the real JSON5 parser instead of approximating the # grammar with regexes. - if ! node - "$config_file" 2>/dev/null <<'NODE_VALIDATE'; then + local _json5_rc=0 + node - "$config_file" <<'NODE_VALIDATE' || _json5_rc=$? const fs = require("fs"); + const path = require("path"); + const { execFileSync } = require("child_process"); + + const configPath = process.argv[2]; + + function addResolved(candidates, specifier, roots) { + for (const root of roots) { + if (!root) continue; + try { + candidates.push(require.resolve(specifier, { paths: [root] })); + } catch { + // Try the next root. + } + } + } + function addExisting(candidates, candidatePath) { + if (candidatePath && fs.existsSync(candidatePath)) { + candidates.push(candidatePath); + } + } + + const candidates = []; + const repoPluginRoot = path.resolve(process.cwd(), "nemoclaw"); + addResolved(candidates, "json5", ["/opt/nemoclaw", repoPluginRoot, process.cwd()]); + addExisting(candidates, "/opt/nemoclaw/node_modules/json5"); + addExisting(candidates, path.join(repoPluginRoot, "node_modules", "json5")); + + try { + const globalRoot = execFileSync("npm", ["root", "-g"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + addResolved(candidates, "json5", [globalRoot]); + addExisting(candidates, path.join(globalRoot, "json5")); + addExisting(candidates, path.join(globalRoot, "openclaw", "node_modules", "json5")); + } catch { + // npm may be absent in minimal runtimes; packaged /opt/nemoclaw remains primary. + } + + const attempted = []; let JSON5; - for (const candidate of [ - "/opt/nemoclaw/node_modules/json5", - "./nemoclaw/node_modules/json5", - "json5", - ]) { + for (const candidate of [...new Set(candidates)]) { try { JSON5 = require(candidate); - break; + if (JSON5 && typeof JSON5.parse === "function") { + break; + } + attempted.push(`${candidate}: missing parse()`); + JSON5 = undefined; } catch { - // Try the next runtime location. + attempted.push(candidate); } } if (!JSON5) { - process.exit(1); + console.error( + `[config] ERROR: unable to load JSON5 parser for baseline validation. Tried: ${ + attempted.length ? attempted.join(", ") : "(no candidate module paths found)" + }`, + ); + process.exit(2); } - JSON5.parse(fs.readFileSync(process.argv[2], "utf8")); + try { + JSON5.parse(fs.readFileSync(configPath, "utf8")); + } catch { + process.exit(3); + } NODE_VALIDATE - return 0 - fi + case "$_json5_rc" in + 0) ;; + 3) return 0 ;; + *) + printf '[config] ERROR: JSON5 baseline validator failed for %s\n' "$config_file" >&2 + return 1 + ;; + esac if ! cp "$config_file" "$baseline_file" 2>/dev/null; then return 0 @@ -441,7 +503,7 @@ NODE_VALIDATE # 0440 root:sandbox so the gateway/sandbox user can READ for recovery but # cannot truncate or rewrite the baseline through the same path that # corrupts the active config. - lock_openclaw_config_baseline_if_present "$config_dir" + lock_openclaw_config_baseline_if_present "$config_dir" || return 1 printf '[config] Baseline snapshot created: %s\n' "$baseline_file" >&2 } diff --git a/test/nemoclaw-start.test.ts b/test/nemoclaw-start.test.ts index f7a3a2cfd8a..6f500fb0cd5 100644 --- a/test/nemoclaw-start.test.ts +++ b/test/nemoclaw-start.test.ts @@ -2304,13 +2304,16 @@ describe("openclaw.json baseline + recovery (#3118)", () => { }); // ── write_openclaw_config_baseline ──────────────────────────────────────── - function runNormalizeMutableConfigPermsWithBaseline() { + function runNormalizeMutableConfigPermsWithBaseline( + fixture: { failBaselineChown?: boolean; failBaselineChmod?: boolean } = {}, + ) { const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-baseline-lock-")); const openclawDir = path.join(root, ".openclaw"); fs.mkdirSync(openclawDir, { recursive: true }); const configPath = path.join(openclawDir, "openclaw.json"); const hashPath = path.join(openclawDir, ".config-hash"); const baselinePath = path.join(openclawDir, "openclaw.json.nemoclaw-baseline"); + const baselineName = path.basename(baselinePath); fs.writeFileSync(configPath, "{}"); fs.writeFileSync(hashPath, "oldhash\n"); @@ -2324,7 +2327,12 @@ describe("openclaw.json baseline + recovery (#3118)", () => { "#!/usr/bin/env bash", "set -euo pipefail", "id() { echo 0; }", - "chown() { return 0; }", + fixture.failBaselineChown + ? `chown() { case "$*" in *${baselineName}*) return 1 ;; esac; return 0; }` + : "chown() { return 0; }", + fixture.failBaselineChmod + ? `chmod() { case "$*" in *${baselineName}*) return 1 ;; esac; command chmod "$@"; }` + : "", `stat() { if [ "$1" = "-c" ] && [ "$2" = "%U" ] && [ "$3" = ${JSON.stringify(openclawDir)} ]; then echo sandbox; return 0; fi; command stat "$@"; }`, extractShellFunction("lock_openclaw_config_baseline_if_present").replaceAll( "/sandbox", @@ -2332,7 +2340,9 @@ describe("openclaw.json baseline + recovery (#3118)", () => { ), extractShellFunction("normalize_mutable_config_perms").replaceAll("/sandbox", root), "normalize_mutable_config_perms", - ].join("\n"); + ] + .filter(Boolean) + .join("\n"); const script = path.join(root, "run.sh"); fs.writeFileSync(script, wrapper, { mode: 0o700 }); const result = spawnSync("bash", [script], { encoding: "utf-8" }); @@ -2347,12 +2357,22 @@ describe("openclaw.json baseline + recovery (#3118)", () => { expect(baselineMode).toBe(0o440); }); + it("fails closed when mutable permission normalization cannot re-lock the baseline", () => { + const { result } = runNormalizeMutableConfigPermsWithBaseline({ + failBaselineChmod: true, + }); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("Failed to set permissions"); + }); + type BaselineFixture = { configContent: string; baselineExists?: boolean; /** Owner returned by stat — "sandbox" = mutable mode, "root" = shields-up */ dirOwner?: "sandbox" | "root"; asRoot?: boolean; + failBaselineChown?: boolean; + failBaselineChmod?: boolean; }; function runWriteBaseline(fixture: BaselineFixture) { @@ -2361,6 +2381,7 @@ describe("openclaw.json baseline + recovery (#3118)", () => { fs.mkdirSync(openclawDir, { recursive: true }); const configPath = path.join(openclawDir, "openclaw.json"); const baselinePath = path.join(openclawDir, "openclaw.json.nemoclaw-baseline"); + const baselineName = path.basename(baselinePath); fs.writeFileSync(configPath, fixture.configContent); if (fixture.baselineExists) { @@ -2384,12 +2405,19 @@ describe("openclaw.json baseline + recovery (#3118)", () => { "#!/usr/bin/env bash", "set -euo pipefail", `id() { echo ${uid}; }`, - "chown() { return 0; }", + fixture.failBaselineChown + ? `chown() { case "$*" in *${baselineName}*) return 1 ;; esac; return 0; }` + : "chown() { return 0; }", + fixture.failBaselineChmod + ? `chmod() { case "$*" in *${baselineName}*) return 1 ;; esac; command chmod "$@"; }` + : "", `stat() { if [ "$1" = "-c" ] && [ "$2" = "%U" ] && [ "$3" = ${JSON.stringify(openclawDir)} ]; then echo ${owner}; return 0; fi; command stat "$@"; }`, helperFns, fn, "write_openclaw_config_baseline", - ].join("\n"); + ] + .filter(Boolean) + .join("\n"); const script = path.join(root, "run.sh"); fs.writeFileSync(script, wrapper, { mode: 0o700 }); const result = spawnSync("bash", [script], { encoding: "utf-8" }); @@ -2410,6 +2438,16 @@ describe("openclaw.json baseline + recovery (#3118)", () => { expect(baselineContent).toBe(config); }); + it("fails closed when a newly captured baseline cannot be locked", () => { + const config = JSON.stringify({ agents: { defaults: { model: { primary: "x" } } } }); + const { result } = runWriteBaseline({ + configContent: config, + failBaselineChown: true, + }); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("Failed to set ownership"); + }); + it("is idempotent and re-locks an existing baseline", () => { const config = JSON.stringify({ source: "current" }); const { result, baselineContent, baselineMode } = runWriteBaseline({ From 0970193a9aa7705cf7ab4bcb63088d3d023e2c4d Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 12 May 2026 08:32:42 -0700 Subject: [PATCH 3/5] fix(blueprint): trust packaged json5 parser --- scripts/nemoclaw-start.sh | 39 +++---------------------------------- test/nemoclaw-start.test.ts | 27 +++++++++++++++++++++---- 2 files changed, 26 insertions(+), 40 deletions(-) diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 9b570374d13..a8dc9771ac1 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -418,45 +418,12 @@ write_openclaw_config_baseline() { local _json5_rc=0 node - "$config_file" <<'NODE_VALIDATE' || _json5_rc=$? const fs = require("fs"); - const path = require("path"); - const { execFileSync } = require("child_process"); const configPath = process.argv[2]; - function addResolved(candidates, specifier, roots) { - for (const root of roots) { - if (!root) continue; - try { - candidates.push(require.resolve(specifier, { paths: [root] })); - } catch { - // Try the next root. - } - } - } - - function addExisting(candidates, candidatePath) { - if (candidatePath && fs.existsSync(candidatePath)) { - candidates.push(candidatePath); - } - } - - const candidates = []; - const repoPluginRoot = path.resolve(process.cwd(), "nemoclaw"); - addResolved(candidates, "json5", ["/opt/nemoclaw", repoPluginRoot, process.cwd()]); - addExisting(candidates, "/opt/nemoclaw/node_modules/json5"); - addExisting(candidates, path.join(repoPluginRoot, "node_modules", "json5")); - - try { - const globalRoot = execFileSync("npm", ["root", "-g"], { - encoding: "utf8", - stdio: ["ignore", "pipe", "ignore"], - }).trim(); - addResolved(candidates, "json5", [globalRoot]); - addExisting(candidates, path.join(globalRoot, "json5")); - addExisting(candidates, path.join(globalRoot, "openclaw", "node_modules", "json5")); - } catch { - // npm may be absent in minimal runtimes; packaged /opt/nemoclaw remains primary. - } + // The entrypoint runs this validator as root. Only load the parser from the + // packaged plugin tree, never from sandbox-writable cwd or npm global roots. + const candidates = ["/opt/nemoclaw/node_modules/json5"]; const attempted = []; let JSON5; diff --git a/test/nemoclaw-start.test.ts b/test/nemoclaw-start.test.ts index 6f500fb0cd5..b7fb8b82a59 100644 --- a/test/nemoclaw-start.test.ts +++ b/test/nemoclaw-start.test.ts @@ -10,6 +10,7 @@ import { describe, it, expect } from "vitest"; const START_SCRIPT = path.join(import.meta.dirname, "..", "scripts", "nemoclaw-start.sh"); const PRELOAD_SCRIPTS = path.join(import.meta.dirname, "..", "nemoclaw-blueprint", "scripts"); +const JSON5_MODULE = path.join(import.meta.dirname, "..", "nemoclaw", "node_modules", "json5"); function configureGuardBlock(src: string): string { const start = src.indexOf("# nemoclaw-configure-guard begin"); @@ -2373,12 +2374,20 @@ describe("openclaw.json baseline + recovery (#3118)", () => { asRoot?: boolean; failBaselineChown?: boolean; failBaselineChmod?: boolean; + omitPackagedJson5?: boolean; }; function runWriteBaseline(fixture: BaselineFixture) { const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-baseline-")); const openclawDir = path.join(root, ".openclaw"); + const optNemoclaw = path.join(root, "opt", "nemoclaw"); fs.mkdirSync(openclawDir, { recursive: true }); + fs.mkdirSync(path.join(optNemoclaw, "node_modules"), { recursive: true }); + if (!fixture.omitPackagedJson5) { + fs.cpSync(JSON5_MODULE, path.join(optNemoclaw, "node_modules", "json5"), { + recursive: true, + }); + } const configPath = path.join(openclawDir, "openclaw.json"); const baselinePath = path.join(openclawDir, "openclaw.json.nemoclaw-baseline"); const baselineName = path.basename(baselinePath); @@ -2394,10 +2403,9 @@ describe("openclaw.json baseline + recovery (#3118)", () => { ] .join("\n") .replaceAll("/sandbox", root); - const fn = extractShellFunction("write_openclaw_config_baseline").replaceAll( - "/sandbox", - root, - ); + const fn = extractShellFunction("write_openclaw_config_baseline") + .replaceAll("/sandbox", root) + .replaceAll("/opt/nemoclaw", optNemoclaw); const owner = fixture.dirOwner ?? "sandbox"; const uid = fixture.asRoot === false ? 1000 : 0; @@ -2498,6 +2506,17 @@ describe("openclaw.json baseline + recovery (#3118)", () => { expect(baselineContent).toBe(config); }); + it("fails closed when the packaged JSON5 parser is unavailable", () => { + const config = JSON.stringify({ ok: true }); + const { result, baselineExists } = runWriteBaseline({ + configContent: config, + omitPackagedJson5: true, + }); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("JSON5 baseline validator failed"); + expect(baselineExists).toBe(false); + }); + it("skips baseline write in shields-up mode (config dir owned by root)", () => { const config = JSON.stringify({ ok: true }); const { result, baselineExists } = runWriteBaseline({ From c750808f62c83823c46d22c1df76b609d6f7405a Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 12 May 2026 08:38:21 -0700 Subject: [PATCH 4/5] test(blueprint): include baseline lock helper in perms fixture --- test/repro-2681-group-writable.test.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/test/repro-2681-group-writable.test.ts b/test/repro-2681-group-writable.test.ts index 089219383a0..1d42ead3de8 100644 --- a/test/repro-2681-group-writable.test.ts +++ b/test/repro-2681-group-writable.test.ts @@ -30,10 +30,13 @@ function extractShellFunctionFromSource(src: string, name: string): string { function normalizeMutableConfigPermsFor(configDir: string): string { const startScript = fs.readFileSync(START_SCRIPT, "utf-8"); - return extractShellFunctionFromSource(startScript, "normalize_mutable_config_perms").replace( - 'local config_dir="/sandbox/.openclaw"', - `local config_dir=${JSON.stringify(configDir)}`, - ); + return [ + extractShellFunctionFromSource(startScript, "lock_openclaw_config_baseline_if_present"), + extractShellFunctionFromSource(startScript, "normalize_mutable_config_perms").replace( + 'local config_dir="/sandbox/.openclaw"', + `local config_dir=${JSON.stringify(configDir)}`, + ), + ].join("\n"); } function modeBits(filePath: string): number { From 289dc4ea5677e15a825b9937b0b971393d4811f2 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 12 May 2026 08:51:53 -0700 Subject: [PATCH 5/5] test(blueprint): remove duplicate shields-up case --- test/repro-2681-group-writable.test.ts | 62 -------------------------- 1 file changed, 62 deletions(-) diff --git a/test/repro-2681-group-writable.test.ts b/test/repro-2681-group-writable.test.ts index 1d42ead3de8..88f6bbcfd44 100644 --- a/test/repro-2681-group-writable.test.ts +++ b/test/repro-2681-group-writable.test.ts @@ -228,68 +228,6 @@ process.stdout.write(JSON.stringify(calls)); expect(commands).toContainEqual(["chmod", "755", "/sandbox/.openclaw"]); }); - it("shields-up strips setgid from the OpenClaw config root before verifying lock", () => { - const probe = spawnSync( - process.execPath, - [ - "-e", - String.raw` -const Module = require("node:module"); -const originalLoad = Module._load; -const calls = []; -Module._load = function patchedLoad(request, parent, isMain) { - if (request === "../adapters/docker/exec") { - return { - dockerExecFileSync(args) { - const separator = args.indexOf("--"); - const command = separator >= 0 ? args.slice(separator + 1) : args; - calls.push(command); - if (command[0] === "stat" && command[1] === "-c") { - return command.at(-1) === "/sandbox/.openclaw" - ? "755 root:root\n" - : "444 root:root\n"; - } - if (command[0] === "lsattr") { - return "----i----------------- " + command.at(-1) + "\n"; - } - return ""; - }, - }; - } - return originalLoad.call(this, request, parent, isMain); -}; -const { lockAgentConfig } = require("./dist/lib/shields/index.js"); -lockAgentConfig("sandbox-pod", { - agentName: "openclaw", - configPath: "/sandbox/.openclaw/openclaw.json", - configDir: "/sandbox/.openclaw", - sensitiveFiles: ["/sandbox/.openclaw/.config-hash"], -}); -process.stdout.write(JSON.stringify(calls)); -`, - ], - { encoding: "utf-8", timeout: 5000 }, - ); - - expect(probe.status).toBe(0); - const commands = JSON.parse(probe.stdout) as string[][]; - const stateDirLockIndex = commands.findIndex( - (command) => - command[0] === "sh" && - command[1] === "-c" && - command.includes("/sandbox/.openclaw") && - command.includes("root:root") && - command.includes("go-w") && - command.includes("755"), - ); - const stripSetgidIndex = commands.findIndex((command) => - command.join("\0") === ["chmod", "g-s", "/sandbox/.openclaw"].join("\0"), - ); - expect(stateDirLockIndex).toBeGreaterThan(-1); - expect(stripSetgidIndex).toBeGreaterThan(stateDirLockIndex); - expect(commands).toContainEqual(["chmod", "755", "/sandbox/.openclaw"]); - }); - it("does not relax a root-owned config tree while shields are up", () => { const tmpDir = mkdtempOnPosixFs("nemoclaw-2681-locked-"); const configDir = path.join(tmpDir, ".openclaw");