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
16 changes: 8 additions & 8 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -327,8 +327,8 @@ USER sandbox
# Non-root mode: $XDG_RUNTIME_DIR/nemoclaw/gateway-token (sandbox:sandbox 0400)
# See: scripts/nemoclaw-start.sh generate_gateway_token()
#
# Config is mutable by default (600 sandbox:sandbox). Immutability is opt-in
# via `shields up` (DAC 444 root:root + chattr +i).
# Config is mutable by default (group-writable sandbox:sandbox). Immutability
# is opt-in via `shields up` (DAC 444 root:root + chattr +i).
# Build args (NEMOCLAW_MODEL, CHAT_UI_URL) customize per deployment.
#
# Temporary workaround for NemoClaw#1738: the OpenClaw Discord extension's
Expand Down Expand Up @@ -499,7 +499,7 @@ RUN if id gateway >/dev/null 2>&1 && id sandbox >/dev/null 2>&1; then \

# Keep the image readable to the root entrypoint after capabilities are
# dropped. OpenShell starts the runtime as the sandbox user; the entrypoint
# restores the stricter mutable-default 600/700 permissions there.
# and onboard flow normalize the mutable-default group-writable permissions.
# Shields-up applies 444 root:root + chattr +i on top.
#
# `chmod g+w` + setgid (chmod g+s on dirs) on the mutable config tree means
Expand All @@ -509,14 +509,14 @@ RUN if id gateway >/dev/null 2>&1 && id sandbox >/dev/null 2>&1; then \
# so OpenClaw's mutateConfigFile path (control-UI toggles) writes succeed
# without needing an EACCES-swallow patch (#2681 supersedes #2693).
RUN chown -R sandbox:sandbox /sandbox/.openclaw \
&& chmod 755 /sandbox/.openclaw \
&& chmod 644 /sandbox/.openclaw/openclaw.json \
&& chmod -R g+w /sandbox/.openclaw \
&& find /sandbox/.openclaw -type d -exec chmod g+s {} +
&& chmod -R g+rwX,o-rwx /sandbox/.openclaw \
&& find /sandbox/.openclaw -type d -exec chmod g+s {} + \
&& chmod 2770 /sandbox/.openclaw \
&& chmod 660 /sandbox/.openclaw/openclaw.json

# Pin config hash at build time so the entrypoint can verify integrity.
RUN sha256sum /sandbox/.openclaw/openclaw.json > /sandbox/.openclaw/.config-hash \
&& chmod 664 /sandbox/.openclaw/.config-hash \
&& chmod 660 /sandbox/.openclaw/.config-hash \
&& chown sandbox:sandbox /sandbox/.openclaw/.config-hash

# DAC-protect .nemoclaw directory: /sandbox/.nemoclaw is Landlock read_write
Expand Down
4 changes: 3 additions & 1 deletion agents/hermes/Dockerfile.base
Original file line number Diff line number Diff line change
Expand Up @@ -111,14 +111,16 @@ RUN mkdir -p /sandbox/.hermes/memories \
# HERMES_HOME to /tmp/nemoclaw-proxy-env.sh (root-owned, mode 444); these rc
# files source it on every interactive `openshell sandbox connect` session.
# Ref: #2376.
# hadolint ignore=SC2028
# hadolint ignore=SC2016,SC2028
RUN printf '%s\n' \
'# Source runtime proxy + HERMES_HOME config (Landlock read-only home, #804)' \
'[ -f /tmp/nemoclaw-proxy-env.sh ] && . /tmp/nemoclaw-proxy-env.sh' \
'export PATH="/usr/local/bin:/opt/hermes/.venv/bin:${PATH}"' \
> /sandbox/.bashrc \
&& printf '%s\n' \
'# Source runtime proxy + HERMES_HOME config (Landlock read-only home, #804)' \
'[ -f /tmp/nemoclaw-proxy-env.sh ] && . /tmp/nemoclaw-proxy-env.sh' \
'export PATH="/usr/local/bin:/opt/hermes/.venv/bin:${PATH}"' \
> /sandbox/.profile \
&& chown root:root /sandbox/.bashrc /sandbox/.profile \
&& chmod 444 /sandbox/.bashrc /sandbox/.profile
Expand Down
17 changes: 8 additions & 9 deletions scripts/nemoclaw-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ _SANDBOX_HOME="/sandbox" # Home dir for the sandbox user (useradd -d /s
# In root mode the gateway runs as the gateway UID; the file is owned
# sandbox:sandbox. Without group write, every toggle EACCESs.
#
# Make the mutable-default tree group-writable + setgid so both
# Make the mutable-default tree group-readable/writable + setgid so both
# `gateway` (now a member of the sandbox group via Dockerfile.base
# usermod -aG) and `sandbox` can write. Setgid means new files
# inherit group=sandbox regardless of which UID created them, so the
Expand All @@ -219,11 +219,6 @@ _SANDBOX_HOME="/sandbox" # Home dir for the sandbox user (useradd -d /s
# Idempotent. Skips when shields are UP (config dir owned by root) so
# the lock is not weakened.
normalize_mutable_config_perms() {
# Only effective in root mode. Non-root containers can't chmod files
# they don't own; if shields are down they were normalized by an
# earlier root-mode startup.
[ "$(id -u)" -eq 0 ] || return 0

local config_dir="/sandbox/.openclaw"
[ -d "$config_dir" ] || return 0

Expand All @@ -235,8 +230,10 @@ normalize_mutable_config_perms() {
return 0
fi

chmod -R g+w "$config_dir" 2>/dev/null || true
chmod -R g+rwX,o-rwx "$config_dir" 2>/dev/null || true
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
}

# ── Runtime model/provider override ──────────────────────────────
Expand Down Expand Up @@ -2399,6 +2396,7 @@ if [ "$(id -u)" -ne 0 ]; then
echo "[SECURITY] Config integrity check failed — refusing to start (non-root mode)" >&2
exit 1
fi
normalize_mutable_config_perms
apply_model_override
apply_cors_override
export_gateway_token
Expand Down Expand Up @@ -2432,10 +2430,11 @@ if [ "$(id -u)" -ne 0 ]; then
&& echo "[setup] fixed ownership on ${openclaw_dir}" >&2 \
|| echo "[setup] could not fix ownership on ${openclaw_dir}; writes may fail" >&2
fi
chmod 700 "$openclaw_dir" 2>/dev/null || true
chmod 600 "$openclaw_dir/openclaw.json" "$openclaw_dir/.config-hash" 2>/dev/null || true
chmod 2770 "$openclaw_dir" 2>/dev/null || true
chmod 660 "$openclaw_dir/openclaw.json" "$openclaw_dir/.config-hash" 2>/dev/null || true
}
fix_openclaw_ownership
normalize_mutable_config_perms
write_auth_profile
harden_auth_profiles

Expand Down
19 changes: 15 additions & 4 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1624,16 +1624,27 @@ async function confirmRecreateForSelectionDrift(
}

function buildSandboxConfigSyncScript(selectionConfig: ProviderSelectionConfig): string {
// openclaw.json is immutable (root:root 444, Landlock read-only) — never
// write to it at runtime. Model routing is handled by the host-side
// gateway (`openshell inference set` in Step 5), not from inside the
// sandbox. We only write the NemoClaw selection config (~/.nemoclaw/).
// Do not rewrite openclaw.json at runtime. Model routing is handled by the
// host-side gateway (`openshell inference set` in Step 5), not from inside
// the sandbox. We write the NemoClaw selection config and normalize the
// mutable-default OpenClaw config permissions after the gateway has had a
// chance to perform its own startup initialization.
return `
set -euo pipefail
mkdir -p ~/.nemoclaw
cat > ~/.nemoclaw/config.json <<'EOF_NEMOCLAW_CFG'
${JSON.stringify(selectionConfig, null, 2)}
EOF_NEMOCLAW_CFG
config_dir=/sandbox/.openclaw
if [ -d "$config_dir" ]; then
config_dir_owner="$(stat -c '%U' "$config_dir" 2>/dev/null || echo unknown)"
if [ "$config_dir_owner" != "root" ]; then
chmod -R g+rwX,o-rwx "$config_dir" 2>/dev/null || true
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
fi
fi
exit
`.trim();
}
Expand Down
59 changes: 35 additions & 24 deletions src/lib/shields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,17 +258,16 @@ const HIGH_RISK_STATE_DIRS = [

function applyStateDirLockMode(sandboxName: string, configDir: string, owner: string): void {
// Locking (shields-up) strips group + world write. Unlocking (shields-down)
// re-adds group write and strips world write, plus setgid so the gateway
// UID — now in the sandbox group via Dockerfile.base — can write to
// OpenClaw's mutable config tree (#2681).
// restores the same group-readable/writable + o-rwx mutable-default contract
// as startup, plus setgid so the gateway UID — now in the sandbox group via
// Dockerfile.base — can write to OpenClaw's mutable config tree (#2681).
//
// The unlock variant uses `g+w,o-w` (not `o-w` alone) because a prior
// lock already stripped g+w from descendants via `chmod -R go-w`. Without
// re-adding g+w explicitly, shields-down would leave nested files at
// 644 — group-write gone, contract broken.
// The unlock variant uses `g+rwX,o-rwx` because a prior lock can strip group
// access from descendants. Without re-adding group read/write explicitly,
// shields-down would leave nested files readable/writable only by owner.
const isLocking = owner === "root:root";
const writeStrip = isLocking ? "go-w" : "g+w,o-w";
const dirMode = isLocking ? "755" : "2775";
const recursiveMode = isLocking ? "go-w" : "g+rwX,o-rwx";
const dirMode = isLocking ? "755" : "2770";

for (const dirName of HIGH_RISK_STATE_DIRS) {
const dirPath = `${configDir}/${dirName}`;
Expand All @@ -290,14 +289,15 @@ function applyStateDirLockMode(sandboxName: string, configDir: string, owner: st
}
}
try {
kubectlExec(sandboxName, ["chmod", "-R", writeStrip, dirPath]);
kubectlExec(sandboxName, ["chmod", "-R", recursiveMode, dirPath]);
} catch {
// Silently skip
}
}

// Multi-agent OpenClaw workspaces are named workspace-<agent>. They are
// discovered dynamically because they are configured by openclaw.json.
const clearSetgid = isLocking ? "1" : "0";
try {
kubectlExec(sandboxName, [
"sh",
Expand All @@ -306,21 +306,23 @@ function applyStateDirLockMode(sandboxName: string, configDir: string, owner: st
set -u
config_dir="$1"
owner="$2"
write_strip="$3"
recursive_mode="$3"
dir_mode="$4"
clear_setgid="$5"
for dir in "$config_dir"/workspace-*; do
[ -d "$dir" ] || continue
chown -R "$owner" "$dir" 2>/dev/null || true
chmod "$dir_mode" "$dir" 2>/dev/null || true
if [ "$dir_mode" = "755" ]; then chmod g-s "$dir" 2>/dev/null || true; fi
chmod -R "$write_strip" "$dir" 2>/dev/null || true
[ "$clear_setgid" = "1" ] && chmod g-s "$dir" 2>/dev/null || true
chmod -R "$recursive_mode" "$dir" 2>/dev/null || true
done
`,
"sh",
configDir,
owner,
writeStrip,
recursiveMode,
dirMode,
clearSetgid,
]);
} catch {
// Best effort; verification below catches the primary config lock.
Expand Down Expand Up @@ -351,8 +353,9 @@ function assertNoLegacyStateLayout(sandboxName: string, configDir: string): void
// ---------------------------------------------------------------------------
// Config unlock — returns config to the default (mutable) state
//
// Sets permissions to sandbox:sandbox 0600/0700, matching what OpenClaw
// writes natively (mode 384 = 0o600). This keeps `openclaw doctor` happy.
// Sets OpenClaw permissions to sandbox:sandbox 0660/2770 so both the sandbox
// user and the gateway UID can write the mutable config tree. Hermes keeps its
// tighter single-user layout.
//
// Note on chattr: best-effort — it may silently fail if kubectl exec
// lacks CAP_LINUX_IMMUTABLE or if the file was never immutable. That's fine:
Expand Down Expand Up @@ -495,11 +498,6 @@ function lockAgentConfig(
} catch {
errors.push("chmod 755 config dir");
}
try {
kubectlExec(sandboxName, ["chmod", "g-s", target.configDir]);
} catch {
errors.push("chmod g-s config dir");
}

try {
kubectlExec(sandboxName, ["chown", "root:root", target.configDir]);
Expand All @@ -523,6 +521,20 @@ function lockAgentConfig(
// existing ones.
applyStateDirLockMode(sandboxName, target.configDir, "root:root");

// OpenClaw's mutable-default config root is setgid (#2681). Clear setgid
// after descendant locking so shields-up verifies the root config dir as
// plain 755, not 2755.
try {
kubectlExec(sandboxName, ["chmod", "g-s", target.configDir]);
} catch {
errors.push("chmod g-s config dir");
}
try {
kubectlExec(sandboxName, ["chmod", "755", target.configDir]);
} catch {
errors.push("chmod 755 config dir");
}

if (errors.length > 0) {
console.error(` Some lock operations failed: ${errors.join(", ")}`);
}
Expand Down Expand Up @@ -649,9 +661,8 @@ function shieldsDown(sandboxName: string, opts: ShieldsDownOpts = {}): void {
run(buildPolicySetCommand(policyFile, sandboxName));

// 2b. Return config to default mutable state.
// Permissions are set to sandbox:sandbox 0600/0700 to match what
// OpenClaw natively creates (mode 384 = 0o600) so `openclaw doctor`
// sees the expected owner and mode without recommending fixes.
// OpenClaw uses sandbox:sandbox 0660/2770 here so the gateway UID, which
// is a member of the sandbox group, can mutate runtime config.
const target = resolveAgentConfig(sandboxName);
console.log(` Unlocking ${target.agentName} config (${target.configPath})...`);
try {
Expand Down
40 changes: 16 additions & 24 deletions test/e2e/test-shields-config.sh
Original file line number Diff line number Diff line change
Expand Up @@ -156,22 +156,17 @@ pass "NemoClaw installed (sandbox: $SANDBOX_NAME)"
# ══════════════════════════════════════════════════════════════════
section "Phase 2: Config is writable (mutable default)"

# Verify file permissions — fresh OpenClaw sandboxes start sandbox-owned.
# Depending on whether the image entrypoint ran as root, the mutable-default
# tree may already include the #2681 group-write/setgid contract or may only
# gain it after the first host-side shields-down.
# Verify file permissions — OpenClaw mutable default is group-writable so the
# gateway UID can write through the shared sandbox group.
PERMS=$(openshell sandbox exec --name "${SANDBOX_NAME}" -- \
stat -c '%a %U:%G' "${CONFIG_PATH}" 2>/dev/null || true)
info "Config perms (default): ${PERMS}"

case "$(echo "$PERMS" | awk '{print $1}')" in
600 | 660)
pass "Config file mode is mutable default (${PERMS})"
;;
*)
fail "Config file should start as mutable mode 600 or 660: ${PERMS}"
;;
esac
if [ "$(echo "$PERMS" | awk '{print $1}')" = "660" ]; then
pass "Config file mode is 660 (mutable default)"
else
fail "Config file should start as mode 660: ${PERMS}"
fi

if [ "$(echo "$PERMS" | awk '{print $2}')" = "sandbox:sandbox" ]; then
pass "Config file owned by sandbox:sandbox (mutable default)"
Expand All @@ -183,14 +178,11 @@ DIR_PERMS=$(openshell sandbox exec --name "${SANDBOX_NAME}" -- \
stat -c '%a %U:%G' "$(dirname "${CONFIG_PATH}")" 2>/dev/null || true)
info "Config dir perms (default): ${DIR_PERMS}"

case "$(echo "$DIR_PERMS" | awk '{print $1}')" in
700 | 2700 | 2770)
pass "Config directory mode is mutable default (${DIR_PERMS})"
;;
*)
fail "Config directory should be mutable mode 700, 2700, or 2770: ${DIR_PERMS}"
;;
esac
if [ "$(echo "$DIR_PERMS" | awk '{print $1}')" = "2770" ]; then
pass "Config directory mode is 2770 (mutable default)"
else
fail "Config directory should be mode 2770: ${DIR_PERMS}"
fi

if [ "$(echo "$DIR_PERMS" | awk '{print $2}')" = "sandbox:sandbox" ]; then
pass "Config directory owned by sandbox:sandbox (mutable default)"
Expand Down Expand Up @@ -336,14 +328,14 @@ else
fail "shields down did not report success: ${SHIELDS_DOWN_OUTPUT}"
fi

# Check permissions changed — OpenClaw uses group-writable + setgid mutable mode
# so the gateway UID and sandbox UID can both update config/state (#2681).
# Check permissions changed — OpenClaw shields-down uses sandbox:sandbox
# 660/2770 so the gateway UID can write the mutable config tree.
PERMS_DOWN=$(openshell sandbox exec --name "${SANDBOX_NAME}" -- \
stat -c '%a %U:%G' "${CONFIG_PATH}" 2>/dev/null || true)
info "Config perms (shields DOWN): ${PERMS_DOWN}"

if [ "$(echo "$PERMS_DOWN" | awk '{print $1}')" = "660" ]; then
pass "Config file mode is 660 (restored to mutable group-writable default)"
pass "Config file mode is 660 (restored to mutable default)"
else
fail "Config file should be mode 660 after shields down: ${PERMS_DOWN}"
fi
Expand All @@ -359,7 +351,7 @@ DIR_PERMS_DOWN=$(openshell sandbox exec --name "${SANDBOX_NAME}" -- \
info "Config dir perms (shields DOWN): ${DIR_PERMS_DOWN}"

if [ "$(echo "$DIR_PERMS_DOWN" | awk '{print $1}')" = "2770" ]; then
pass "Config directory mode is 2770 (restored to mutable setgid default)"
pass "Config directory mode is 2770 (restored to mutable default)"
else
fail "Config directory should be mode 2770 after shields down: ${DIR_PERMS_DOWN}"
fi
Expand Down
9 changes: 6 additions & 3 deletions test/nemoclaw-start.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ describe("nemoclaw-start non-root fallback", () => {
"set -euo pipefail",
'id() { if [ "${1:-}" = "-u" ]; then printf "1000"; else command id "$@"; fi; }',
'verify_config_integrity_if_locked() { printf "nonroot:%s\\n" "$*"; }',
'normalize_mutable_config_perms() { :; }',
nonRootIntegrityGateBlock(src),
'echo "NONROOT_CONTINUED"',
].join("\n");
Expand Down Expand Up @@ -248,6 +249,7 @@ describe("nemoclaw-start non-root fallback", () => {
"set -euo pipefail",
'id() { if [ "${1:-}" = "-u" ]; then printf "1000"; else command id "$@"; fi; }',
'verify_config_integrity_if_locked() { :; }',
'normalize_mutable_config_perms() { :; }',
'apply_model_override() { :; }',
'apply_cors_override() { :; }',
'export_gateway_token() { :; }',
Expand Down Expand Up @@ -301,12 +303,13 @@ describe("nemoclaw-start non-root fallback", () => {
for (const dir of ["workspace", "memory", "credentials", "flows", "telegram", "media"]) {
expect(fs.statSync(path.join(openclawDir, dir)).isDirectory()).toBe(true);
}
expect((fs.statSync(openclawDir).mode & 0o777).toString(8)).toBe("700");
expect((fs.statSync(openclawDir).mode & 0o777).toString(8)).toBe("770");
expect(fs.statSync(openclawDir).mode & 0o2000).toBe(0o2000);
expect((fs.statSync(path.join(openclawDir, "openclaw.json")).mode & 0o777).toString(8)).toBe(
"600",
"660",
);
expect((fs.statSync(path.join(openclawDir, ".config-hash")).mode & 0o777).toString(8)).toBe(
"600",
"660",
);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
Expand Down
Loading
Loading