diff --git a/CHANGELOG.md b/CHANGELOG.md index b5f829e9f..0199e45b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -214,6 +214,7 @@ Docs: https://docs.openclaw.ai - Agents/Models config: preserve agent-level provider `apiKey` and `baseUrl` during merge-mode `models.json` updates when agent values are present. (#27293) thanks @Sid-Qin. - Azure OpenAI Responses: force `store=true` for `azure-openai-responses` direct responses API calls to avoid multi-turn 400 failures. Landed from contributor PR #27499 by @polarbear-Yang. (#27497) - Security/Node exec approvals: require structured `commandArgv` approvals for `host=node`, enforce versioned `systemRunBindingV1` matching for argv/cwd/session/agent/env context with fail-closed behavior on missing/mismatched bindings, and add `GIT_EXTERNAL_DIFF` to blocked host env keys. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting. +- Security/Command authorization: enforce sender authorization for natural-language abort triggers (`stop`-like text) and `/models` listings, preventing unauthorized session aborts and model-auth metadata disclosure. This ships in the next npm release (`2026.2.27`). Thanks @tdjackey for reporting. - Security/Plugin channel HTTP auth: normalize protected `/api/channels` path checks against canonicalized request paths (case + percent-decoding + slash normalization), resolve encoded dot-segment traversal variants, and fail closed on malformed `%`-encoded channel prefixes so alternate-path variants cannot bypass gateway auth. This ships in the next npm release (`2026.2.26`). Thanks @zpbrent for reporting. - Security/Gateway node pairing: pin paired-device `platform`/`deviceFamily` metadata across reconnects and bind those fields into device-auth signatures, so reconnect metadata spoofing cannot expand node command allowlists without explicit repair pairing. This ships in the next npm release (`2026.2.26`). Thanks @76embiid21 for reporting. - Security/Sandbox path alias guard: reject broken symlink targets by resolving through existing ancestors and failing closed on out-of-root targets, preventing workspace-only `apply_patch` writes from escaping sandbox/workspace boundaries via dangling symlinks. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting. diff --git a/src/auto-reply/reply/commands-models.ts b/src/auto-reply/reply/commands-models.ts index 920bf9cb7..c4e3bc944 100644 --- a/src/auto-reply/reply/commands-models.ts +++ b/src/auto-reply/reply/commands-models.ts @@ -19,6 +19,7 @@ import { type ProviderInfo, } from "../../telegram/model-buttons.js"; import type { ReplyPayload } from "../types.js"; +import { rejectUnauthorizedCommand } from "./command-gates.js"; import type { CommandHandler } from "./commands-types.js"; const PAGE_SIZE_DEFAULT = 20; @@ -363,6 +364,14 @@ export const handleModelsCommand: CommandHandler = async (params, allowTextComma if (!allowTextCommands) { return null; } + const commandBodyNormalized = params.command.commandBodyNormalized.trim(); + if (!commandBodyNormalized.startsWith("/models")) { + return null; + } + const unauthorized = rejectUnauthorizedCommand(params, "/models"); + if (unauthorized) { + return unauthorized; + } const modelsAgentId = params.agentId ?? @@ -374,7 +383,7 @@ export const handleModelsCommand: CommandHandler = async (params, allowTextComma const reply = await resolveModelsCommandReply({ cfg: params.cfg, - commandBodyNormalized: params.command.commandBodyNormalized, + commandBodyNormalized, surface: params.ctx.Surface, currentModel: params.model ? `${params.provider}/${params.model}` : undefined, agentDir: modelsAgentDir, diff --git a/src/auto-reply/reply/commands-session-abort.ts b/src/auto-reply/reply/commands-session-abort.ts index f6683cc4d..e8abdb845 100644 --- a/src/auto-reply/reply/commands-session-abort.ts +++ b/src/auto-reply/reply/commands-session-abort.ts @@ -14,6 +14,7 @@ import { setAbortMemory, stopSubagentsForRequester, } from "./abort.js"; +import { rejectUnauthorizedCommand } from "./command-gates.js"; import { persistAbortTargetEntry } from "./commands-session-store.js"; import type { CommandHandler } from "./commands-types.js"; import { clearSessionQueues } from "./queue.js"; @@ -92,11 +93,9 @@ export const handleStopCommand: CommandHandler = async (params, allowTextCommand if (params.command.commandBodyNormalized !== "/stop") { return null; } - if (!params.command.isAuthorizedSender) { - logVerbose( - `Ignoring /stop from unauthorized sender: ${params.command.senderId || ""}`, - ); - return { shouldContinue: false }; + const unauthorizedStop = rejectUnauthorizedCommand(params, "/stop"); + if (unauthorizedStop) { + return unauthorizedStop; } const abortTarget = resolveAbortTarget({ ctx: params.ctx, @@ -151,6 +150,10 @@ export const handleAbortTrigger: CommandHandler = async (params, allowTextComman if (!isAbortTrigger(params.command.rawBodyNormalized)) { return null; } + const unauthorizedAbortTrigger = rejectUnauthorizedCommand(params, "abort trigger"); + if (unauthorizedAbortTrigger) { + return unauthorizedAbortTrigger; + } const abortTarget = resolveAbortTarget({ ctx: params.ctx, sessionKey: params.sessionKey, diff --git a/src/auto-reply/reply/commands.test.ts b/src/auto-reply/reply/commands.test.ts index 921081921..c007e3317 100644 --- a/src/auto-reply/reply/commands.test.ts +++ b/src/auto-reply/reply/commands.test.ts @@ -2,14 +2,14 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { compactEmbeddedPiSession } from "../../agents/pi-embedded.js"; +import { abortEmbeddedPiRun, compactEmbeddedPiSession } from "../../agents/pi-embedded.js"; import { addSubagentRunForTests, listSubagentRunsForRequester, resetSubagentRegistryForTests, } from "../../agents/subagent-registry.js"; import type { OpenClawConfig } from "../../config/config.js"; -import { updateSessionStore } from "../../config/sessions.js"; +import { updateSessionStore, type SessionEntry } from "../../config/sessions.js"; import * as internalHooks from "../../hooks/internal-hooks.js"; import { clearPluginCommands, registerPluginCommand } from "../../plugins/commands.js"; import { typedCases } from "../../test-utils/typed-cases.js"; @@ -431,6 +431,43 @@ describe("/compact command", () => { }); }); +describe("abort trigger command", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("rejects unauthorized natural-language abort triggers", async () => { + const cfg = { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + } as OpenClawConfig; + const params = buildParams("stop", cfg); + const sessionEntry: SessionEntry = { + sessionId: "session-1", + updatedAt: Date.now(), + abortedLastRun: false, + }; + const sessionStore: Record = { + [params.sessionKey]: sessionEntry, + }; + + const result = await handleCommands({ + ...params, + sessionEntry, + sessionStore, + command: { + ...params.command, + isAuthorizedSender: false, + senderId: "unauthorized", + }, + }); + + expect(result).toEqual({ shouldContinue: false }); + expect(sessionStore[params.sessionKey]?.abortedLastRun).toBe(false); + expect(vi.mocked(abortEmbeddedPiRun)).not.toHaveBeenCalled(); + }); +}); + describe("buildCommandsPaginationKeyboard", () => { it("adds agent id to callback data when provided", () => { const keyboard = buildCommandsPaginationKeyboard(2, 3, "agent-main"); @@ -739,6 +776,19 @@ describe("/models command", () => { expect(result.reply?.text).toContain("Use: /models "); }); + it("rejects unauthorized /models commands", async () => { + const params = buildPolicyParams("/models", cfg, { Provider: "discord", Surface: "discord" }); + const result = await handleCommands({ + ...params, + command: { + ...params.command, + isAuthorizedSender: false, + senderId: "unauthorized", + }, + }); + expect(result).toEqual({ shouldContinue: false }); + }); + it("lists providers on telegram (buttons)", async () => { const params = buildPolicyParams("/models", cfg, { Provider: "telegram", Surface: "telegram" }); const result = await handleCommands(params);