Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
94 changes: 87 additions & 7 deletions scripts/nemoclaw-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,78 @@ PYCORS
printf '[config] Config hash recomputed after CORS override\n' >&2
}

# ── Slack token placeholder resolution ────────────────────────────
# Resolves openshell:resolve:env:SLACK_* placeholders in openclaw.json at
# container startup, before chattr +i locks the file. This ensures Bolt's
# in-process token validation (appToken must start with xapp-) succeeds even
# before the L7 proxy can intercept HTTP calls.
# Same trust model as apply_model_override: host-set env vars, root-only,
# applied before Landlock/chattr +i, hash recomputed. Tokens are unset from
# the process env after patching so they are not visible inside the sandbox.
# Ref: https://github.com/NVIDIA/NemoClaw/issues/2085

apply_slack_token_override() {
[ -n "${SLACK_BOT_TOKEN:-}" ] || return 0

# SECURITY: Only root can write to /sandbox/.openclaw (root:root 444).
if [ "$(id -u)" -ne 0 ]; then
printf '[SECURITY] Slack token override ignored — requires root (non-root mode cannot write to config)\n' >&2
return 0
fi

local config_file="/sandbox/.openclaw/openclaw.json"
local hash_file="/sandbox/.openclaw/.config-hash"

# SECURITY: Refuse to write through symlinks to prevent symlink-following attacks.
if [ -L "$config_file" ] || [ -L "$hash_file" ]; then
printf '[SECURITY] Refusing Slack token override — config or hash path is a symlink\n' >&2
return 1
fi

# SECURITY: Validate token prefixes — reject anything that doesn't look like a real Slack token.
case "${SLACK_BOT_TOKEN}" in
xoxb-*) ;;
*) printf '[channels] SLACK_BOT_TOKEN does not start with xoxb- — skipping Slack placeholder resolution\n' >&2; return 0 ;;
esac

if [ -n "${SLACK_APP_TOKEN:-}" ]; then
case "$SLACK_APP_TOKEN" in
xapp-*) ;;
*) printf '[channels] SLACK_APP_TOKEN does not start with xapp- — skipping Slack placeholder resolution\n' >&2; return 0 ;;
esac
fi

printf '[channels] Resolving Slack token placeholders in openclaw.json\n' >&2

SLACK_BOT_TOKEN="$SLACK_BOT_TOKEN" \
SLACK_APP_TOKEN="${SLACK_APP_TOKEN:-}" \
python3 - "$config_file" <<'PYSLACK'
import json, os, sys

config_file = sys.argv[1]
bot_token = os.environ["SLACK_BOT_TOKEN"]
app_token = os.environ.get("SLACK_APP_TOKEN", "")
placeholder_prefix = "openshell:resolve:env:"

with open(config_file) as f:
cfg = json.load(f)

slack = cfg.get("channels", {}).get("slack", {})
default_acct = slack.get("accounts", {}).get("default", {})

if default_acct.get("botToken", "").startswith(placeholder_prefix):
default_acct["botToken"] = bot_token
if app_token and default_acct.get("appToken", "").startswith(placeholder_prefix):
default_acct["appToken"] = app_token

with open(config_file, "w") as f:
json.dump(cfg, f, indent=2)
PYSLACK

(cd /sandbox/.openclaw && sha256sum openclaw.json >"$hash_file")
printf '[channels] Config hash recomputed after Slack token override\n' >&2
}

_read_gateway_token() {
python3 - <<'PYTOKEN'
import json
Expand Down Expand Up @@ -635,14 +707,16 @@ harden_auth_profiles() {

configure_messaging_channels() {
# Channel entries are baked into openclaw.json at image build time via
# NEMOCLAW_MESSAGING_CHANNELS_B64 (see Dockerfile). Placeholder tokens
# (openshell:resolve:env:*) flow through to API calls where the L7 proxy
# rewrites them with real secrets at egress. Real tokens are never visible
# inside the sandbox.
# NEMOCLAW_MESSAGING_CHANNELS_B64 (see Dockerfile).
#
# Runtime patching of /sandbox/.openclaw/openclaw.json is not possible:
# Landlock enforces read-only on /sandbox/.openclaw/ at the kernel level,
# regardless of DAC (file ownership/chmod). Writes fail with EPERM.
# Telegram/Discord: placeholder tokens (openshell:resolve:env:*) flow through
# to API calls where the L7 proxy rewrites them with real secrets at egress.
# Real tokens are never visible inside the sandbox for these channels.
#
# Slack: apply_slack_token_override (runs before this function) resolves
# SLACK_BOT_TOKEN/SLACK_APP_TOKEN placeholders directly into openclaw.json so
# Bolt's in-process token validation passes. Both env vars are unset before the
# gateway starts (root path) so they do not leak into the sandbox process env.
[ -n "${TELEGRAM_BOT_TOKEN:-}" ] || [ -n "${DISCORD_BOT_TOKEN:-}" ] || [ -n "${SLACK_BOT_TOKEN:-}" ] || return 0

echo "[channels] Messaging channels active (baked at build time):" >&2
Expand Down Expand Up @@ -858,6 +932,7 @@ if [ "$(id -u)" -ne 0 ]; then
fi
apply_model_override
apply_cors_override
apply_slack_token_override
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
export_gateway_token
install_configure_guard
configure_messaging_channels
Expand Down Expand Up @@ -957,6 +1032,7 @@ fi
verify_config_integrity
apply_model_override
apply_cors_override
apply_slack_token_override
export_gateway_token
install_configure_guard

Expand Down Expand Up @@ -995,6 +1071,10 @@ validate_openclaw_symlinks
# Ref: https://github.com/NVIDIA/NemoClaw/issues/1019
harden_openclaw_symlinks

# SECURITY: Slack tokens were resolved into openclaw.json by apply_slack_token_override.
# Clear them from the process env so neither the gateway nor the sandbox user inherits them.
unset SLACK_BOT_TOKEN SLACK_APP_TOKEN
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

# Start the gateway as the 'gateway' user.
# SECURITY: The sandbox user cannot kill this process because it runs
# under a different UID. The fake-HOME attack no longer works because
Expand Down
10 changes: 10 additions & 0 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3428,6 +3428,16 @@ async function createSandbox(
envArgs.push(formatEnvAssignment(webSearch.BRAVE_API_KEY_ENV, braveKey));
}
}
// Slack Socket Mode requires both tokens in the container env so the baked
// openshell:resolve:env: placeholders in openclaw.json are substituted.
// The provider registration above handles L7 proxy auth header rewriting;
// the --env args here ensure the container env vars hold the real values.
if (tokensByEnvKey["SLACK_BOT_TOKEN"]) {
envArgs.push(formatEnvAssignment("SLACK_BOT_TOKEN", tokensByEnvKey["SLACK_BOT_TOKEN"]));
if (tokensByEnvKey["SLACK_APP_TOKEN"]) {
envArgs.push(formatEnvAssignment("SLACK_APP_TOKEN", tokensByEnvKey["SLACK_APP_TOKEN"]));
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
const sandboxEnv = buildSubprocessEnv();
// Remove host-infrastructure credentials that the generic allowlist
// permits for host-side processes but that must not enter the sandbox.
Expand Down
86 changes: 85 additions & 1 deletion test/nemoclaw-start.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -423,7 +423,7 @@ describe("runtime CORS origin override (#719)", () => {
);

const rootBlock = src.match(
/# ── Root path[\s\S]*?apply_model_override\n\s*apply_cors_override\n\s*export_gateway_token/,
/# ── Root path[\s\S]*?apply_model_override\n\s*apply_cors_override\n\s*apply_slack_token_override\n\s*export_gateway_token/,
);
expect(rootBlock).toBeTruthy();
});
Expand Down Expand Up @@ -468,6 +468,90 @@ describe("runtime CORS origin override (#719)", () => {
});
});

describe("Slack token placeholder resolution (#2085)", () => {
const src = fs.readFileSync(START_SCRIPT, "utf-8");

it("defines apply_slack_token_override function", () => {
expect(src).toContain("apply_slack_token_override()");
expect(src).toContain("SLACK_BOT_TOKEN");
expect(src).toContain("SLACK_APP_TOKEN");
});

it("calls apply_slack_token_override after apply_cors_override in both paths", () => {
const nonRootBlock = src.match(/if \[ "\$\(id -u\)" -ne 0 \]; then([\s\S]*?)# ── Root path/);
expect(nonRootBlock).toBeTruthy();
expect(nonRootBlock[1]).toMatch(
/apply_cors_override[\s\S]*?apply_slack_token_override[\s\S]*?export_gateway_token/,
);

const rootBlock = src.match(
/# ── Root path[\s\S]*?apply_cors_override\n\s*apply_slack_token_override\n\s*export_gateway_token/,
);
expect(rootBlock).toBeTruthy();
});

it("is a no-op when SLACK_BOT_TOKEN is not set", () => {
const fn = src.match(/apply_slack_token_override\(\) \{([\s\S]*?)^}/m);
expect(fn).toBeTruthy();
expect(fn[1]).toMatch(/\[ -n "\$\{SLACK_BOT_TOKEN:-\}" \] \|\| return 0/);
});

it("only applies override in root mode", () => {
const fn = src.match(/apply_slack_token_override\(\) \{([\s\S]*?)^}/m);
expect(fn).toBeTruthy();
expect(fn[1]).toMatch(/id -u.*-ne 0/);
expect(fn[1]).toContain("requires root");
});

it("guards against symlink attacks", () => {
const fn = src.match(/apply_slack_token_override\(\) \{([\s\S]*?)^}/m);
expect(fn).toBeTruthy();
expect(fn[1]).toContain('-L "$config_file"');
expect(fn[1]).toContain("Refusing Slack token override");
});

it("validates botToken prefix is xoxb-", () => {
const fn = src.match(/apply_slack_token_override\(\) \{([\s\S]*?)^}/m);
expect(fn).toBeTruthy();
expect(fn[1]).toContain("xoxb-");
expect(fn[1]).toContain("does not start with xoxb-");
});

it("validates appToken prefix is xapp-", () => {
const fn = src.match(/apply_slack_token_override\(\) \{([\s\S]*?)^}/m);
expect(fn).toBeTruthy();
expect(fn[1]).toContain("xapp-");
expect(fn[1]).toContain("does not start with xapp-");
});

it("recomputes config hash after override", () => {
const fn = src.match(/apply_slack_token_override\(\) \{([\s\S]*?)^}/m);
expect(fn).toBeTruthy();
expect(fn[1]).toContain("sha256sum openclaw.json");
expect(fn[1]).toContain("config-hash");
});

it("resolves openshell:resolve:env: placeholders via Python", () => {
const fn = src.match(/apply_slack_token_override\(\) \{([\s\S]*?)^}/m);
expect(fn).toBeTruthy();
expect(fn[1]).toContain("openshell:resolve:env:");
expect(fn[1]).toContain("botToken");
expect(fn[1]).toContain("appToken");
});

it("unsets SLACK_BOT_TOKEN and SLACK_APP_TOKEN before gateway starts in root path", () => {
const gatewayLaunch = src.match(/harden_openclaw_symlinks([\s\S]*?)nohup gosu gateway/);
expect(gatewayLaunch).toBeTruthy();
expect(gatewayLaunch[1]).toContain("unset SLACK_BOT_TOKEN SLACK_APP_TOKEN");
});

it("passes tokens via env prefix, not as positional args", () => {
const fn = src.match(/apply_slack_token_override\(\) \{([\s\S]*?)^}/m);
expect(fn).toBeTruthy();
expect(fn[1]).toMatch(/SLACK_BOT_TOKEN="\$SLACK_BOT_TOKEN" \\/);
});
});

describe("nemoclaw-start auto-pair client whitelisting (#117)", () => {
const src = fs.readFileSync(START_SCRIPT, "utf-8");

Expand Down
20 changes: 17 additions & 3 deletions test/onboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2724,6 +2724,7 @@ const { createSandbox } = require(${onboardPath});
process.env.OPENSHELL_GATEWAY = "nemoclaw";
process.env.DISCORD_BOT_TOKEN = "test-discord-token-value";
process.env.SLACK_BOT_TOKEN = "xoxb-test-slack-token-value";
process.env.SLACK_APP_TOKEN = "xapp-test-slack-app-token-value";
process.env.TELEGRAM_BOT_TOKEN = "123456:ABC-test-telegram-token";
const sandboxName = await createSandbox(null, "gpt-5.4");
console.log(JSON.stringify({ sandboxName, commands }));
Expand Down Expand Up @@ -2784,10 +2785,14 @@ const { createSandbox } = require(${onboardPath});
assert.match(createCommand.command, /--provider my-assistant-slack-bridge/);
assert.match(createCommand.command, /--provider my-assistant-telegram-bridge/);

// Verify real token values are NOT in the sandbox create command
// Discord and Telegram tokens must NOT appear in the sandbox create command
// (they flow exclusively through the openshell provider credential system).
assert.doesNotMatch(createCommand.command, /test-discord-token-value/);
assert.doesNotMatch(createCommand.command, /xoxb-test-slack-token-value/);
assert.doesNotMatch(createCommand.command, /123456:ABC-test-telegram-token/);
// Slack tokens ARE injected as --env args so the baked openclaw.json
// openshell:resolve:env: placeholders resolve inside the container.
assert.match(createCommand.command, /SLACK_BOT_TOKEN=xoxb-test-slack-token-value/);
assert.match(createCommand.command, /SLACK_APP_TOKEN=xapp-test-slack-app-token-value/);

// Verify blocked credentials are NOT in the sandbox spawn environment
assert.ok(createCommand.env, "expected env to be captured from spawn call");
Expand All @@ -2801,6 +2806,11 @@ const { createSandbox } = require(${onboardPath});
undefined,
"SLACK_BOT_TOKEN must not be in sandbox env",
);
assert.equal(
createCommand.env.SLACK_APP_TOKEN,
undefined,
"SLACK_APP_TOKEN must not be in sandbox env",
);
assert.equal(
createCommand.env.TELEGRAM_BOT_TOKEN,
undefined,
Expand All @@ -2820,7 +2830,11 @@ const { createSandbox } = require(${onboardPath});
);
assert.ok(
!envString.includes("xoxb-test-slack-token-value"),
"Slack token value must not leak into sandbox env",
"Slack bot token value must not leak into sandbox spawn env",
);
assert.ok(
!envString.includes("xapp-test-slack-app-token-value"),
"Slack app token value must not leak into sandbox spawn env",
);
assert.ok(
!envString.includes("123456:ABC-test-telegram-token"),
Expand Down
Loading