From 9abab6a2c988129b5fd22da92d893657a9d3c0a4 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 21 Feb 2026 03:13:56 -0500 Subject: [PATCH] Add explicit ownerDisplaySecret for owner ID hash obfuscation (#22520) * feat(config): add owner display secret setting * feat(prompt): add explicit owner hash secret to obfuscation path * test(prompt): assert owner hash secret mode behavior * Update src/agents/system-prompt.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- src/agents/cli-runner/helpers.ts | 5 +++ src/agents/pi-embedded-runner/compact.ts | 5 +++ src/agents/pi-embedded-runner/run/attempt.ts | 5 +++ .../pi-embedded-runner/system-prompt.ts | 4 ++ src/agents/system-prompt.e2e.test.ts | 39 +++++++++++++++++++ src/agents/system-prompt.ts | 39 ++++++++++++++++--- src/config/schema.help.ts | 4 ++ src/config/schema.labels.ts | 2 + src/config/types.messages.ts | 6 +++ src/config/zod-schema.session.ts | 3 ++ 10 files changed, 107 insertions(+), 5 deletions(-) diff --git a/src/agents/cli-runner/helpers.ts b/src/agents/cli-runner/helpers.ts index e48d79b71da..b6167670c4d 100644 --- a/src/agents/cli-runner/helpers.ts +++ b/src/agents/cli-runner/helpers.ts @@ -86,6 +86,11 @@ export function buildSystemPrompt(params: { 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, reasoningTagHint: false, heartbeatPrompt: params.heartbeatPrompt, docsPath: params.docsPath, diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index 21bb1568bbc..865cdd5c763 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -484,6 +484,11 @@ export async function compactEmbeddedPiSessionDirect( 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, 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 3ab80dd2661..889a44c9a04 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -443,6 +443,11 @@ 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, reasoningTagHint, heartbeatPrompt: isDefaultAgent ? resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt) diff --git a/src/agents/pi-embedded-runner/system-prompt.ts b/src/agents/pi-embedded-runner/system-prompt.ts index 9549619533a..67df4493695 100644 --- a/src/agents/pi-embedded-runner/system-prompt.ts +++ b/src/agents/pi-embedded-runner/system-prompt.ts @@ -14,6 +14,8 @@ export function buildEmbeddedSystemPrompt(params: { reasoningLevel?: ReasoningLevel; extraSystemPrompt?: string; ownerNumbers?: string[]; + ownerDisplay?: "raw" | "hash"; + ownerDisplaySecret?: string; reasoningTagHint: boolean; heartbeatPrompt?: string; skillsPrompt?: string; @@ -55,6 +57,8 @@ export function buildEmbeddedSystemPrompt(params: { reasoningLevel: params.reasoningLevel, extraSystemPrompt: params.extraSystemPrompt, ownerNumbers: params.ownerNumbers, + ownerDisplay: params.ownerDisplay, + ownerDisplaySecret: params.ownerDisplaySecret, reasoningTagHint: params.reasoningTagHint, heartbeatPrompt: params.heartbeatPrompt, skillsPrompt: params.skillsPrompt, diff --git a/src/agents/system-prompt.e2e.test.ts b/src/agents/system-prompt.e2e.test.ts index a03ac283365..cb9958fcb2e 100644 --- a/src/agents/system-prompt.e2e.test.ts +++ b/src/agents/system-prompt.e2e.test.ts @@ -16,6 +16,45 @@ describe("buildAgentSystemPrompt", () => { ); }); + it("hashes owner numbers when ownerDisplay is hash", () => { + const prompt = buildAgentSystemPrompt({ + workspaceDir: "/tmp/openclaw", + ownerNumbers: ["+123", "+456", ""], + ownerDisplay: "hash", + }); + + expect(prompt).toContain("## Authorized Senders"); + expect(prompt).toContain("Authorized senders:"); + expect(prompt).not.toContain("+123"); + expect(prompt).not.toContain("+456"); + expect(prompt).toMatch(/[a-f0-9]{12}/); + }); + + it("uses a stable, keyed HMAC when ownerDisplaySecret is provided", () => { + const secretA = buildAgentSystemPrompt({ + workspaceDir: "/tmp/openclaw", + ownerNumbers: ["+123"], + ownerDisplay: "hash", + ownerDisplaySecret: "secret-key-A", + }); + + const secretB = buildAgentSystemPrompt({ + workspaceDir: "/tmp/openclaw", + ownerNumbers: ["+123"], + ownerDisplay: "hash", + ownerDisplaySecret: "secret-key-B", + }); + + const lineA = secretA.split("## Authorized Senders")[1]?.split("\n")[1]; + const lineB = secretB.split("## Authorized Senders")[1]?.split("\n")[1]; + const tokenA = lineA?.match(/[a-f0-9]{12}/)?.[0]; + const tokenB = lineB?.match(/[a-f0-9]{12}/)?.[0]; + + expect(tokenA).toBeDefined(); + expect(tokenB).toBeDefined(); + expect(tokenA).not.toBe(tokenB); + }); + it("omits owner section when numbers are missing", () => { const prompt = buildAgentSystemPrompt({ workspaceDir: "/tmp/openclaw", diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index a0c087af1a7..0fd0b7a2272 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -5,6 +5,7 @@ import { listDeliverableMessageChannels } from "../utils/message-channel.js"; import type { ResolvedTimeFormat } from "./date-time.js"; import type { EmbeddedContextFile } from "./pi-embedded-helpers.js"; import { sanitizeForPromptLiteral } from "./sanitize-for-prompt.js"; +import { createHmac, createHash } from "node:crypto"; /** * Controls which hardcoded sections are included in the system prompt. @@ -13,6 +14,7 @@ import { sanitizeForPromptLiteral } from "./sanitize-for-prompt.js"; * - "none": Just basic identity line, no sections */ export type PromptMode = "full" | "minimal" | "none"; +type OwnerIdDisplay = "raw" | "hash"; function buildSkillsSection(params: { skillsPrompt?: string; @@ -73,6 +75,30 @@ function buildUserIdentitySection(ownerLine: string | undefined, isMinimal: bool return ["## Authorized Senders", ownerLine, ""]; } +function formatOwnerDisplayId(ownerId: string, ownerDisplaySecret?: string) { + const hasSecret = ownerDisplaySecret?.trim(); + const digest = hasSecret + ? createHmac("sha256", hasSecret).update(ownerId).digest("hex") + : createHash("sha256").update(ownerId).digest("hex"); + return digest.slice(0, 16); +} + +function buildOwnerIdentityLine( + ownerNumbers: string[], + ownerDisplay: OwnerIdDisplay, + ownerDisplaySecret?: string, +) { + const normalized = ownerNumbers.map((value) => value.trim()).filter(Boolean); + if (normalized.length === 0) { + return undefined; + } + const displayOwnerNumbers = + ownerDisplay === "hash" + ? normalized.map((ownerId) => formatOwnerDisplayId(ownerId, ownerDisplaySecret)) + : normalized; + return `Authorized senders: ${displayOwnerNumbers.join(", ")}. These senders are allowlisted; do not assume they are the owner.`; +} + function buildTimeSection(params: { userTimezone?: string }) { if (!params.userTimezone) { return []; @@ -172,6 +198,8 @@ export function buildAgentSystemPrompt(params: { reasoningLevel?: ReasoningLevel; extraSystemPrompt?: string; ownerNumbers?: string[]; + ownerDisplay?: OwnerIdDisplay; + ownerDisplaySecret?: string; reasoningTagHint?: boolean; toolNames?: string[]; toolSummaries?: Record; @@ -322,11 +350,12 @@ export function buildAgentSystemPrompt(params: { const execToolName = resolveToolName("exec"); const processToolName = resolveToolName("process"); const extraSystemPrompt = params.extraSystemPrompt?.trim(); - const ownerNumbers = (params.ownerNumbers ?? []).map((value) => value.trim()).filter(Boolean); - const ownerLine = - ownerNumbers.length > 0 - ? `Authorized senders: ${ownerNumbers.join(", ")}. These senders are allowlisted; do not assume they are the owner.` - : undefined; + const ownerDisplay = params.ownerDisplay === "hash" ? "hash" : "raw"; + const ownerLine = buildOwnerIdentityLine( + params.ownerNumbers ?? [], + ownerDisplay, + params.ownerDisplaySecret, + ); const reasoningHint = params.reasoningTagHint ? [ "ALL internal reasoning MUST be inside ....", diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index a2ea1122edd..d2dc3e24ef1 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -330,6 +330,10 @@ export const FIELD_HELP: Record = { "commands.useAccessGroups": "Enforce access-group allowlists/policies for commands.", "commands.ownerAllowFrom": "Explicit owner allowlist for owner-only tools/commands. Use channel-native IDs (optionally prefixed like \"whatsapp:+15551234567\"). '*' is ignored.", + "commands.ownerDisplay": + "Controls how owner IDs are rendered in the system prompt. Allowed values: raw, hash. Default: raw.", + "commands.ownerDisplaySecret": + "Optional secret used to HMAC hash owner IDs when ownerDisplay=hash. Prefer env substitution.", "session.dmScope": 'DM session scoping: "main" keeps continuity; "per-peer", "per-channel-peer", or "per-account-channel-peer" isolates DM history (recommended for shared inboxes/multi-account).', "session.identityLinks": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index e61f7e557ab..2f9a1a2e593 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -224,6 +224,8 @@ export const FIELD_LABELS: Record = { "commands.restart": "Allow Restart", "commands.useAccessGroups": "Use Access Groups", "commands.ownerAllowFrom": "Command Owners", + "commands.ownerDisplay": "Owner ID Display", + "commands.ownerDisplaySecret": "Owner ID Hash Secret", "ui.seamColor": "Accent Color", "ui.assistant.name": "Assistant Name", "ui.assistant.avatar": "Assistant Avatar", diff --git a/src/config/types.messages.ts b/src/config/types.messages.ts index f1f685deef9..ff71035e168 100644 --- a/src/config/types.messages.ts +++ b/src/config/types.messages.ts @@ -125,6 +125,8 @@ export type MessagesConfig = { export type NativeCommandsSetting = boolean | "auto"; +export type CommandOwnerDisplay = "raw" | "hash"; + /** * Per-provider allowlist for command authorization. * Keys are channel IDs (e.g., "discord", "whatsapp") or "*" for global default. @@ -153,6 +155,10 @@ export type CommandsConfig = { useAccessGroups?: boolean; /** Explicit owner allowlist for owner-only tools/commands (channel-native IDs). */ ownerAllowFrom?: Array; + /** How owner IDs are rendered in system prompts. */ + ownerDisplay?: CommandOwnerDisplay; + /** Secret used to key owner ID hashes when ownerDisplay is "hash". */ + ownerDisplaySecret?: string; /** * Per-provider allowlist restricting who can use slash commands. * If set, overrides the channel's allowFrom for command authorization. diff --git a/src/config/zod-schema.session.ts b/src/config/zod-schema.session.ts index 1b69b88eb9e..1b6e396b8f1 100644 --- a/src/config/zod-schema.session.ts +++ b/src/config/zod-schema.session.ts @@ -3,6 +3,7 @@ import { parseByteSize } from "../cli/parse-bytes.js"; import { parseDurationMs } from "../cli/parse-duration.js"; import { ElevatedAllowFromSchema } from "./zod-schema.agent-runtime.js"; import { createAllowDenyChannelRulesSchema } from "./zod-schema.allowdeny.js"; +import { sensitive } from "./zod-schema.sensitive.js"; import { GroupChatSchema, InboundDebounceSchema, @@ -161,6 +162,8 @@ export const CommandsSchema = z restart: z.boolean().optional().default(true), useAccessGroups: z.boolean().optional(), ownerAllowFrom: z.array(z.union([z.string(), z.number()])).optional(), + ownerDisplay: z.enum(["raw", "hash"]).optional().default("raw"), + ownerDisplaySecret: z.string().optional().register(sensitive), allowFrom: ElevatedAllowFromSchema.optional(), }) .strict()