From c99e7696e6893083b256f0a6c88fb060f3a76fb7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 22 Feb 2026 09:34:48 +0100 Subject: [PATCH] fix: decouple owner display secret from gateway auth token --- CHANGELOG.md | 1 + src/agents/cli-runner/helpers.ts | 9 +-- src/agents/owner-display.test.ts | 78 ++++++++++++++++++++ src/agents/owner-display.ts | 58 +++++++++++++++ src/agents/pi-embedded-runner/compact.ts | 9 +-- src/agents/pi-embedded-runner/run/attempt.ts | 9 +-- src/config/io.owner-display-secret.test.ts | 48 ++++++++++++ src/config/io.ts | 41 +++++++++- 8 files changed, 237 insertions(+), 16 deletions(-) create mode 100644 src/agents/owner-display.test.ts create mode 100644 src/agents/owner-display.ts create mode 100644 src/config/io.owner-display-secret.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b83b594b02b..96854f495f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,7 @@ Docs: https://docs.openclaw.ai - Security/Exec: in non-default setups that manually add `sort` to `tools.exec.safeBins`, block `sort --compress-program` so allowlist-mode safe-bin checks cannot bypass approval. Thanks @tdjackey for reporting. - Security/Exec approvals: when users choose `allow-always` for shell-wrapper commands (for example `/bin/zsh -lc ...`), persist allowlist patterns for the inner executable(s) instead of the wrapper shell binary, preventing accidental broad shell allowlisting in moderate mode. (#23276) Thanks @xrom2863. - Security/macOS app beta: enforce path-only `system.run` allowlist matching (drop basename matches like `echo`), migrate legacy basename entries to last resolved paths when available, and harden shell-chain handling to fail closed on unsafe parse/control syntax (including quoted command substitution/backticks). This is an optional allowlist-mode feature; default installs remain deny-by-default. This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/Agents: auto-generate and persist a dedicated `commands.ownerDisplaySecret` when `commands.ownerDisplay=hash`, remove gateway token fallback from owner-ID prompt hashing across CLI and embedded agent runners, and centralize owner-display secret resolution in one shared helper. This ships in the next npm release. Thanks @aether-ai-agent for reporting. - Security/SSRF: expand IPv4 fetch guard blocking to include RFC special-use/non-global ranges (including benchmarking, TEST-NET, multicast, and reserved/broadcast blocks), and centralize range checks into a single CIDR policy table to reduce classifier drift. - Security/Archive: block zip symlink escapes during archive extraction. - Security/Media sandbox: keep tmp media allowance for absolute tmp paths only and enforce symlink-escape checks before sandbox-validated reads, preventing tmp symlink exfiltration and relative `../` sandbox escapes when sandboxes live under tmp. (#17892) Thanks @dashed. diff --git a/src/agents/cli-runner/helpers.ts b/src/agents/cli-runner/helpers.ts index b6167670c4d..e211e3df49c 100644 --- a/src/agents/cli-runner/helpers.ts +++ b/src/agents/cli-runner/helpers.ts @@ -11,6 +11,7 @@ import { buildTtsSystemPromptHint } from "../../tts/tts.js"; import { isRecord } from "../../utils.js"; import { buildModelAliasLines } from "../model-alias-lines.js"; import { resolveDefaultModelForAgent } from "../model-selection.js"; +import { resolveOwnerDisplaySetting } from "../owner-display.js"; import type { EmbeddedContextFile } from "../pi-embedded-helpers.js"; import { detectRuntimeShell } from "../shell-utils.js"; import { buildSystemPromptParams } from "../system-prompt-params.js"; @@ -81,16 +82,14 @@ export function buildSystemPrompt(params: { }, }); const ttsHint = params.config ? buildTtsSystemPromptHint(params.config) : undefined; + const ownerDisplay = resolveOwnerDisplaySetting(params.config); return buildAgentSystemPrompt({ workspaceDir: params.workspaceDir, defaultThinkLevel: params.defaultThinkLevel, extraSystemPrompt: params.extraSystemPrompt, ownerNumbers: params.ownerNumbers, - ownerDisplay: params.config?.commands?.ownerDisplay, - ownerDisplaySecret: - params.config?.commands?.ownerDisplaySecret ?? - params.config?.gateway?.auth?.token ?? - params.config?.gateway?.remote?.token, + ownerDisplay: ownerDisplay.ownerDisplay, + ownerDisplaySecret: ownerDisplay.ownerDisplaySecret, reasoningTagHint: false, heartbeatPrompt: params.heartbeatPrompt, docsPath: params.docsPath, diff --git a/src/agents/owner-display.test.ts b/src/agents/owner-display.test.ts new file mode 100644 index 00000000000..42b3d156170 --- /dev/null +++ b/src/agents/owner-display.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { ensureOwnerDisplaySecret, resolveOwnerDisplaySetting } from "./owner-display.js"; + +describe("resolveOwnerDisplaySetting", () => { + it("returns keyed hash settings when hash mode has an explicit secret", () => { + const cfg = { + commands: { + ownerDisplay: "hash", + ownerDisplaySecret: " owner-secret ", + }, + } as OpenClawConfig; + + expect(resolveOwnerDisplaySetting(cfg)).toEqual({ + ownerDisplay: "hash", + ownerDisplaySecret: "owner-secret", + }); + }); + + it("does not fall back to gateway tokens when hash secret is missing", () => { + const cfg = { + commands: { + ownerDisplay: "hash", + }, + gateway: { + auth: { token: "gateway-auth-token" }, + remote: { token: "gateway-remote-token" }, + }, + } as OpenClawConfig; + + expect(resolveOwnerDisplaySetting(cfg)).toEqual({ + ownerDisplay: "hash", + ownerDisplaySecret: undefined, + }); + }); + + it("disables owner hash secret when display mode is raw", () => { + const cfg = { + commands: { + ownerDisplay: "raw", + ownerDisplaySecret: "owner-secret", + }, + } as OpenClawConfig; + + expect(resolveOwnerDisplaySetting(cfg)).toEqual({ + ownerDisplay: "raw", + ownerDisplaySecret: undefined, + }); + }); +}); + +describe("ensureOwnerDisplaySecret", () => { + it("generates a dedicated secret when hash mode is enabled without one", () => { + const cfg = { + commands: { + ownerDisplay: "hash", + }, + } as OpenClawConfig; + + const result = ensureOwnerDisplaySecret(cfg, () => "generated-owner-secret"); + expect(result.generatedSecret).toBe("generated-owner-secret"); + expect(result.config.commands?.ownerDisplaySecret).toBe("generated-owner-secret"); + expect(result.config.commands?.ownerDisplay).toBe("hash"); + }); + + it("does nothing when a hash secret is already configured", () => { + const cfg = { + commands: { + ownerDisplay: "hash", + ownerDisplaySecret: "existing-owner-secret", + }, + } as OpenClawConfig; + + const result = ensureOwnerDisplaySecret(cfg, () => "generated-owner-secret"); + expect(result.generatedSecret).toBeUndefined(); + expect(result.config).toEqual(cfg); + }); +}); diff --git a/src/agents/owner-display.ts b/src/agents/owner-display.ts new file mode 100644 index 00000000000..57d2006c656 --- /dev/null +++ b/src/agents/owner-display.ts @@ -0,0 +1,58 @@ +import crypto from "node:crypto"; +import type { OpenClawConfig } from "../config/config.js"; + +export type OwnerDisplaySetting = { + ownerDisplay?: "raw" | "hash"; + ownerDisplaySecret?: string; +}; + +export type OwnerDisplaySecretResolution = { + config: OpenClawConfig; + generatedSecret?: string; +}; + +function trimToUndefined(value?: string): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +/** + * Resolve owner display settings for prompt rendering. + * Keep auth secrets decoupled from owner hash secrets. + */ +export function resolveOwnerDisplaySetting(config?: OpenClawConfig): OwnerDisplaySetting { + const ownerDisplay = config?.commands?.ownerDisplay; + if (ownerDisplay !== "hash") { + return { ownerDisplay, ownerDisplaySecret: undefined }; + } + return { + ownerDisplay: "hash", + ownerDisplaySecret: trimToUndefined(config?.commands?.ownerDisplaySecret), + }; +} + +/** + * Ensure hash mode has a dedicated secret. + * Returns updated config and generated secret when autofill was needed. + */ +export function ensureOwnerDisplaySecret( + config: OpenClawConfig, + generateSecret: () => string = () => crypto.randomBytes(32).toString("hex"), +): OwnerDisplaySecretResolution { + const settings = resolveOwnerDisplaySetting(config); + if (settings.ownerDisplay !== "hash" || settings.ownerDisplaySecret) { + return { config }; + } + const generatedSecret = generateSecret(); + return { + config: { + ...config, + commands: { + ...config.commands, + ownerDisplay: "hash", + ownerDisplaySecret: generatedSecret, + }, + }, + generatedSecret, + }; +} diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index ffb42c6e2ef..b53b997a048 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -33,6 +33,7 @@ import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../defaults.js"; import { resolveOpenClawDocsPath } from "../docs-path.js"; import { getApiKeyForModel, resolveModelAuthMode } from "../model-auth.js"; import { ensureOpenClawModelsJson } from "../models-config.js"; +import { resolveOwnerDisplaySetting } from "../owner-display.js"; import { ensureSessionHeader, validateAnthropicTurns, @@ -480,17 +481,15 @@ export async function compactEmbeddedPiSessionDirect( moduleUrl: import.meta.url, }); const ttsHint = params.config ? buildTtsSystemPromptHint(params.config) : undefined; + const ownerDisplay = resolveOwnerDisplaySetting(params.config); const appendPrompt = buildEmbeddedSystemPrompt({ workspaceDir: effectiveWorkspace, defaultThinkLevel: params.thinkLevel, reasoningLevel: params.reasoningLevel ?? "off", extraSystemPrompt: params.extraSystemPrompt, ownerNumbers: params.ownerNumbers, - ownerDisplay: params.config?.commands?.ownerDisplay, - ownerDisplaySecret: - params.config?.commands?.ownerDisplaySecret ?? - params.config?.gateway?.auth?.token ?? - params.config?.gateway?.remote?.token, + ownerDisplay: ownerDisplay.ownerDisplay, + ownerDisplaySecret: ownerDisplay.ownerDisplaySecret, reasoningTagHint, heartbeatPrompt: isDefaultAgent ? resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt) diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 0ddc8899a59..383d810e76a 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -47,6 +47,7 @@ import { resolveImageSanitizationLimits } from "../../image-sanitization.js"; import { resolveModelAuthMode } from "../../model-auth.js"; import { resolveDefaultModelForAgent } from "../../model-selection.js"; import { createOllamaStreamFn, OLLAMA_NATIVE_BASE_URL } from "../../ollama-stream.js"; +import { resolveOwnerDisplaySetting } from "../../owner-display.js"; import { isCloudCodeAssistFormatError, resolveBootstrapMaxChars, @@ -505,6 +506,7 @@ export async function runEmbeddedAttempt( moduleUrl: import.meta.url, }); const ttsHint = params.config ? buildTtsSystemPromptHint(params.config) : undefined; + const ownerDisplay = resolveOwnerDisplaySetting(params.config); const appendPrompt = buildEmbeddedSystemPrompt({ workspaceDir: effectiveWorkspace, @@ -512,11 +514,8 @@ export async function runEmbeddedAttempt( reasoningLevel: params.reasoningLevel ?? "off", extraSystemPrompt: params.extraSystemPrompt, ownerNumbers: params.ownerNumbers, - ownerDisplay: params.config?.commands?.ownerDisplay, - ownerDisplaySecret: - params.config?.commands?.ownerDisplaySecret ?? - params.config?.gateway?.auth?.token ?? - params.config?.gateway?.remote?.token, + ownerDisplay: ownerDisplay.ownerDisplay, + ownerDisplaySecret: ownerDisplay.ownerDisplaySecret, reasoningTagHint, heartbeatPrompt: isDefaultAgent ? resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt) diff --git a/src/config/io.owner-display-secret.test.ts b/src/config/io.owner-display-secret.test.ts new file mode 100644 index 00000000000..99f8f6b3518 --- /dev/null +++ b/src/config/io.owner-display-secret.test.ts @@ -0,0 +1,48 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { withTempHome } from "./home-env.test-harness.js"; +import { createConfigIO } from "./io.js"; + +async function waitForPersistedSecret(configPath: string, expectedSecret: string): Promise { + const deadline = Date.now() + 3_000; + while (Date.now() < deadline) { + const raw = await fs.readFile(configPath, "utf-8"); + const parsed = JSON.parse(raw) as { + commands?: { ownerDisplaySecret?: string }; + }; + if (parsed.commands?.ownerDisplaySecret === expectedSecret) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } + throw new Error("timed out waiting for ownerDisplaySecret persistence"); +} + +describe("config io owner display secret autofill", () => { + it("auto-generates and persists commands.ownerDisplaySecret in hash mode", async () => { + await withTempHome("openclaw-owner-display-secret-", async (home) => { + const configPath = path.join(home, ".openclaw", "openclaw.json"); + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile( + configPath, + JSON.stringify({ commands: { ownerDisplay: "hash" } }, null, 2), + "utf-8", + ); + + const io = createConfigIO({ + env: {} as NodeJS.ProcessEnv, + homedir: () => home, + logger: { warn: () => {}, error: () => {} }, + }); + const cfg = io.loadConfig(); + const secret = cfg.commands?.ownerDisplaySecret; + + expect(secret).toMatch(/^[a-f0-9]{64}$/); + await waitForPersistedSecret(configPath, secret ?? ""); + + const cfgReloaded = io.loadConfig(); + expect(cfgReloaded.commands?.ownerDisplaySecret).toBe(secret); + }); + }); +}); diff --git a/src/config/io.ts b/src/config/io.ts index 51e85ec9233..c5df09e433a 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -4,6 +4,7 @@ import os from "node:os"; import path from "node:path"; import { isDeepStrictEqual } from "node:util"; import JSON5 from "json5"; +import { ensureOwnerDisplaySecret } from "../agents/owner-display.js"; import { loadDotEnv } from "../infra/dotenv.js"; import { resolveRequiredHomeDir } from "../infra/home-dir.js"; import { @@ -696,7 +697,42 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { }); } - return applyConfigOverrides(cfg); + const pendingSecret = AUTO_OWNER_DISPLAY_SECRET_BY_PATH.get(configPath); + const ownerDisplaySecretResolution = ensureOwnerDisplaySecret( + cfg, + () => pendingSecret ?? crypto.randomBytes(32).toString("hex"), + ); + const cfgWithOwnerDisplaySecret = ownerDisplaySecretResolution.config; + if (ownerDisplaySecretResolution.generatedSecret) { + AUTO_OWNER_DISPLAY_SECRET_BY_PATH.set( + configPath, + ownerDisplaySecretResolution.generatedSecret, + ); + if (!AUTO_OWNER_DISPLAY_SECRET_PERSIST_IN_FLIGHT.has(configPath)) { + AUTO_OWNER_DISPLAY_SECRET_PERSIST_IN_FLIGHT.add(configPath); + void writeConfigFile(cfgWithOwnerDisplaySecret, { expectedConfigPath: configPath }) + .then(() => { + AUTO_OWNER_DISPLAY_SECRET_BY_PATH.delete(configPath); + AUTO_OWNER_DISPLAY_SECRET_PERSIST_WARNED.delete(configPath); + }) + .catch((err) => { + if (!AUTO_OWNER_DISPLAY_SECRET_PERSIST_WARNED.has(configPath)) { + AUTO_OWNER_DISPLAY_SECRET_PERSIST_WARNED.add(configPath); + deps.logger.warn( + `Failed to persist auto-generated commands.ownerDisplaySecret at ${configPath}: ${String(err)}`, + ); + } + }) + .finally(() => { + AUTO_OWNER_DISPLAY_SECRET_PERSIST_IN_FLIGHT.delete(configPath); + }); + } + } else { + AUTO_OWNER_DISPLAY_SECRET_BY_PATH.delete(configPath); + AUTO_OWNER_DISPLAY_SECRET_PERSIST_WARNED.delete(configPath); + } + + return applyConfigOverrides(cfgWithOwnerDisplaySecret); } catch (err) { if (err instanceof DuplicateAgentDirError) { deps.logger.error(err.message); @@ -1149,6 +1185,9 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { // module scope. `OPENCLAW_CONFIG_PATH` (and friends) are expected to work even // when set after the module has been imported (tests, one-off scripts, etc.). const DEFAULT_CONFIG_CACHE_MS = 200; +const AUTO_OWNER_DISPLAY_SECRET_BY_PATH = new Map(); +const AUTO_OWNER_DISPLAY_SECRET_PERSIST_IN_FLIGHT = new Set(); +const AUTO_OWNER_DISPLAY_SECRET_PERSIST_WARNED = new Set(); let configCache: { configPath: string; expiresAt: number;