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>
This commit is contained in:
Vincent Koc 2026-02-21 03:13:56 -05:00 committed by GitHub
parent fe609c0c77
commit 9abab6a2c9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 107 additions and 5 deletions

View File

@ -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,

View File

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

View File

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

View File

@ -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,

View File

@ -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",

View File

@ -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<string, string>;
@ -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 <think>...</think>.",

View File

@ -330,6 +330,10 @@ export const FIELD_HELP: Record<string, string> = {
"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":

View File

@ -224,6 +224,8 @@ export const FIELD_LABELS: Record<string, string> = {
"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",

View File

@ -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<string | number>;
/** 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.

View File

@ -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()