Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 82 additions & 17 deletions scripts/nemoclaw-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,29 @@ _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

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
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# Idempotent. Skips when shields are UP (config dir owned by root) so
# the lock is not weakened.
normalize_mutable_config_perms() {
Expand All @@ -261,6 +284,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" || return 1
}

openclaw_config_dir_owner() {
Expand Down Expand Up @@ -392,16 +416,20 @@ 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 $?
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.
if [ "$(openclaw_config_dir_owner "$config_dir")" = "root" ]; then
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
Expand All @@ -412,27 +440,64 @@ 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
return 0
fi
# JSON5.parse — so use the real JSON5 parser instead of approximating the
# grammar with regexes.
local _json5_rc=0
node - "$config_file" <<'NODE_VALIDATE' || _json5_rc=$?
const fs = require("fs");

const configPath = process.argv[2];

// 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;
for (const candidate of [...new Set(candidates)]) {
try {
JSON5 = require(candidate);
if (JSON5 && typeof JSON5.parse === "function") {
break;
}
attempted.push(`${candidate}: missing parse()`);
JSON5 = undefined;
} catch {
attempted.push(candidate);
}
}

if (!JSON5) {
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);
}

try {
JSON5.parse(fs.readFileSync(configPath, "utf8"));
} catch {
process.exit(3);
}
NODE_VALIDATE
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
fi
# 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" || return 1
printf '[config] Baseline snapshot created: %s\n' "$baseline_file" >&2
}

Expand Down
141 changes: 129 additions & 12 deletions test/nemoclaw-start.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -2229,53 +2230,135 @@ describe("openclaw.json baseline + recovery (#3118)", () => {
});

// ── write_openclaw_config_baseline ────────────────────────────────────────
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");
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; }",
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",
root,
),
extractShellFunction("normalize_mutable_config_perms").replaceAll("/sandbox", root),
"normalize_mutable_config_perms",
]
.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" });
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);
});

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;
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);

fs.writeFileSync(configPath, fixture.configContent);
if (fixture.baselineExists) {
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(
"/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;

const wrapper = [
"#!/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" });
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", () => {
Expand All @@ -2288,14 +2371,25 @@ describe("openclaw.json baseline + recovery (#3118)", () => {
expect(baselineContent).toBe(config);
});

it("is idempotent — does not overwrite an existing baseline", () => {
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 } = 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", () => {
Expand Down Expand Up @@ -2324,9 +2418,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({
Expand All @@ -2337,6 +2431,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({
Expand All @@ -2347,6 +2452,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({
Expand Down
Loading
Loading