From 7a89049d1d45b1e4cb7ef36012c185a9586b8aa0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 13:54:35 +0000 Subject: [PATCH 0001/1006] refactor: dedupe pending pairing request flow and add reuse tests --- src/infra/device-pairing.test.ts | 22 ++++++++++++++ src/infra/device-pairing.ts | 48 ++++++++++++++--------------- src/infra/node-pairing.test.ts | 22 ++++++++++++++ src/infra/node-pairing.ts | 52 +++++++++++++++----------------- src/infra/pairing-files.ts | 24 +++++++++++++++ 5 files changed, 117 insertions(+), 51 deletions(-) diff --git a/src/infra/device-pairing.test.ts b/src/infra/device-pairing.test.ts index ab0864b9f4c48..cb9bd0d2dd592 100644 --- a/src/infra/device-pairing.test.ts +++ b/src/infra/device-pairing.test.ts @@ -33,6 +33,28 @@ function requireToken(token: string | undefined): string { } describe("device pairing tokens", () => { + test("reuses existing pending requests for the same device", async () => { + const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-")); + const first = await requestDevicePairing( + { + deviceId: "device-1", + publicKey: "public-key-1", + }, + baseDir, + ); + const second = await requestDevicePairing( + { + deviceId: "device-1", + publicKey: "public-key-1", + }, + baseDir, + ); + + expect(first.created).toBe(true); + expect(second.created).toBe(false); + expect(second.request.requestId).toBe(first.request.requestId); + }); + test("generates base64url device tokens with 256-bit entropy output length", async () => { const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-")); await setupPairedOperatorDevice(baseDir, ["operator.admin"]); diff --git a/src/infra/device-pairing.ts b/src/infra/device-pairing.ts index 884f2e9dd313a..122463f6e634d 100644 --- a/src/infra/device-pairing.ts +++ b/src/infra/device-pairing.ts @@ -5,6 +5,7 @@ import { pruneExpiredPending, readJsonFile, resolvePairingPaths, + upsertPendingPairingRequest, writeJsonAtomic, } from "./pairing-files.js"; import { generatePairingToken, verifyPairingToken } from "./pairing-token.js"; @@ -226,30 +227,29 @@ export async function requestDevicePairing( if (!deviceId) { throw new Error("deviceId required"); } - const existing = Object.values(state.pendingById).find((p) => p.deviceId === deviceId); - if (existing) { - return { status: "pending", request: existing, created: false }; - } - const isRepair = Boolean(state.pairedByDeviceId[deviceId]); - const request: DevicePairingPendingRequest = { - requestId: randomUUID(), - deviceId, - publicKey: req.publicKey, - displayName: req.displayName, - platform: req.platform, - clientId: req.clientId, - clientMode: req.clientMode, - role: req.role, - roles: req.role ? [req.role] : undefined, - scopes: req.scopes, - remoteIp: req.remoteIp, - silent: req.silent, - isRepair, - ts: Date.now(), - }; - state.pendingById[request.requestId] = request; - await persistState(state, baseDir); - return { status: "pending", request, created: true }; + + return await upsertPendingPairingRequest({ + pendingById: state.pendingById, + isExisting: (pending) => pending.deviceId === deviceId, + isRepair: Boolean(state.pairedByDeviceId[deviceId]), + createRequest: (isRepair) => ({ + requestId: randomUUID(), + deviceId, + publicKey: req.publicKey, + displayName: req.displayName, + platform: req.platform, + clientId: req.clientId, + clientMode: req.clientMode, + role: req.role, + roles: req.role ? [req.role] : undefined, + scopes: req.scopes, + remoteIp: req.remoteIp, + silent: req.silent, + isRepair, + ts: Date.now(), + }), + persist: async () => await persistState(state, baseDir), + }); }); } diff --git a/src/infra/node-pairing.test.ts b/src/infra/node-pairing.test.ts index 17c83c03500dc..8700f3d034fe8 100644 --- a/src/infra/node-pairing.test.ts +++ b/src/infra/node-pairing.test.ts @@ -28,6 +28,28 @@ async function setupPairedNode(baseDir: string): Promise { } describe("node pairing tokens", () => { + test("reuses existing pending requests for the same node", async () => { + const baseDir = await mkdtemp(join(tmpdir(), "openclaw-node-pairing-")); + const first = await requestNodePairing( + { + nodeId: "node-1", + platform: "darwin", + }, + baseDir, + ); + const second = await requestNodePairing( + { + nodeId: "node-1", + platform: "darwin", + }, + baseDir, + ); + + expect(first.created).toBe(true); + expect(second.created).toBe(false); + expect(second.request.requestId).toBe(first.request.requestId); + }); + test("generates base64url node tokens with 256-bit entropy output length", async () => { const baseDir = await mkdtemp(join(tmpdir(), "openclaw-node-pairing-")); const token = await setupPairedNode(baseDir); diff --git a/src/infra/node-pairing.ts b/src/infra/node-pairing.ts index 88c428df13de3..d8a55b57621d8 100644 --- a/src/infra/node-pairing.ts +++ b/src/infra/node-pairing.ts @@ -4,6 +4,7 @@ import { pruneExpiredPending, readJsonFile, resolvePairingPaths, + upsertPendingPairingRequest, writeJsonAtomic, } from "./pairing-files.js"; import { generatePairingToken, verifyPairingToken } from "./pairing-token.js"; @@ -123,33 +124,30 @@ export async function requestNodePairing( throw new Error("nodeId required"); } - const existing = Object.values(state.pendingById).find((p) => p.nodeId === nodeId); - if (existing) { - return { status: "pending", request: existing, created: false }; - } - - const isRepair = Boolean(state.pairedByNodeId[nodeId]); - const request: NodePairingPendingRequest = { - requestId: randomUUID(), - nodeId, - displayName: req.displayName, - platform: req.platform, - version: req.version, - coreVersion: req.coreVersion, - uiVersion: req.uiVersion, - deviceFamily: req.deviceFamily, - modelIdentifier: req.modelIdentifier, - caps: req.caps, - commands: req.commands, - permissions: req.permissions, - remoteIp: req.remoteIp, - silent: req.silent, - isRepair, - ts: Date.now(), - }; - state.pendingById[request.requestId] = request; - await persistState(state, baseDir); - return { status: "pending", request, created: true }; + return await upsertPendingPairingRequest({ + pendingById: state.pendingById, + isExisting: (pending) => pending.nodeId === nodeId, + isRepair: Boolean(state.pairedByNodeId[nodeId]), + createRequest: (isRepair) => ({ + requestId: randomUUID(), + nodeId, + displayName: req.displayName, + platform: req.platform, + version: req.version, + coreVersion: req.coreVersion, + uiVersion: req.uiVersion, + deviceFamily: req.deviceFamily, + modelIdentifier: req.modelIdentifier, + caps: req.caps, + commands: req.commands, + permissions: req.permissions, + remoteIp: req.remoteIp, + silent: req.silent, + isRepair, + ts: Date.now(), + }), + persist: async () => await persistState(state, baseDir), + }); }); } diff --git a/src/infra/pairing-files.ts b/src/infra/pairing-files.ts index f2578facdfb05..6c5bf0ee7388c 100644 --- a/src/infra/pairing-files.ts +++ b/src/infra/pairing-files.ts @@ -24,3 +24,27 @@ export function pruneExpiredPending( } } } + +export type PendingPairingRequestResult = { + status: "pending"; + request: TPending; + created: boolean; +}; + +export async function upsertPendingPairingRequest(params: { + pendingById: Record; + isExisting: (pending: TPending) => boolean; + createRequest: (isRepair: boolean) => TPending; + isRepair: boolean; + persist: () => Promise; +}): Promise> { + const existing = Object.values(params.pendingById).find(params.isExisting); + if (existing) { + return { status: "pending", request: existing, created: false }; + } + + const request = params.createRequest(params.isRepair); + params.pendingById[request.requestId] = request; + await params.persist(); + return { status: "pending", request, created: true }; +} From 19348050bef01f8a2d0bb4ce36b51f7c36f9b3e5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 13:54:40 +0000 Subject: [PATCH 0002/1006] style: normalize acp translator import ordering --- src/acp/translator.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/acp/translator.ts b/src/acp/translator.ts index dc63020a3a48e..10a0a52bddb64 100644 --- a/src/acp/translator.ts +++ b/src/acp/translator.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import type { Agent, AgentSideConnection, @@ -19,7 +20,6 @@ import type { StopReason, } from "@agentclientprotocol/sdk"; import { PROTOCOL_VERSION } from "@agentclientprotocol/sdk"; -import { randomUUID } from "node:crypto"; import type { GatewayClient } from "../gateway/client.js"; import type { EventFrame } from "../gateway/protocol/index.js"; import type { SessionsListResult } from "../gateway/session-utils.js"; From f8b61bb4ed36c4861a691a34cad658badd503d57 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 14:55:00 +0100 Subject: [PATCH 0003/1006] refactor(acp): split session tests and share rate limiter --- src/acp/session-mapper.test.ts | 145 +-------------------- src/acp/session.test.ts | 146 ++++++++++++++++++++++ src/acp/session.ts | 4 + src/acp/translator.ts | 50 ++------ src/infra/fixed-window-rate-limit.test.ts | 31 +++++ src/infra/fixed-window-rate-limit.ts | 48 +++++++ 6 files changed, 240 insertions(+), 184 deletions(-) create mode 100644 src/acp/session.test.ts create mode 100644 src/infra/fixed-window-rate-limit.test.ts create mode 100644 src/infra/fixed-window-rate-limit.ts diff --git a/src/acp/session-mapper.test.ts b/src/acp/session-mapper.test.ts index be026e632a812..859b1da7380c3 100644 --- a/src/acp/session-mapper.test.ts +++ b/src/acp/session-mapper.test.ts @@ -1,7 +1,6 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { GatewayClient } from "../gateway/client.js"; import { parseSessionMeta, resolveSessionKey } from "./session-mapper.js"; -import { createInMemorySessionStore } from "./session.js"; function createGateway(resolveLabelKey = "agent:main:label"): { gateway: GatewayClient; @@ -55,145 +54,3 @@ describe("acp session mapper", () => { expect(request).not.toHaveBeenCalled(); }); }); - -describe("acp session manager", () => { - let nowMs = 0; - const now = () => nowMs; - const advance = (ms: number) => { - nowMs += ms; - }; - let store = createInMemorySessionStore({ now }); - - beforeEach(() => { - nowMs = 1_000; - store = createInMemorySessionStore({ now }); - }); - - afterEach(() => { - store.clearAllSessionsForTest(); - }); - - it("tracks active runs and clears on cancel", () => { - const session = store.createSession({ - sessionKey: "acp:test", - cwd: "/tmp", - }); - const controller = new AbortController(); - store.setActiveRun(session.sessionId, "run-1", controller); - - expect(store.getSessionByRunId("run-1")?.sessionId).toBe(session.sessionId); - - const cancelled = store.cancelActiveRun(session.sessionId); - expect(cancelled).toBe(true); - expect(store.getSessionByRunId("run-1")).toBeUndefined(); - }); - - it("refreshes existing session IDs instead of creating duplicates", () => { - const first = store.createSession({ - sessionId: "existing", - sessionKey: "acp:one", - cwd: "/tmp/one", - }); - advance(500); - - const refreshed = store.createSession({ - sessionId: "existing", - sessionKey: "acp:two", - cwd: "/tmp/two", - }); - - expect(refreshed).toBe(first); - expect(refreshed.sessionKey).toBe("acp:two"); - expect(refreshed.cwd).toBe("/tmp/two"); - expect(refreshed.createdAt).toBe(1_000); - expect(refreshed.lastTouchedAt).toBe(1_500); - }); - - it("reaps idle sessions before enforcing the max session cap", () => { - const boundedStore = createInMemorySessionStore({ - maxSessions: 1, - idleTtlMs: 1_000, - now, - }); - try { - boundedStore.createSession({ - sessionId: "old", - sessionKey: "acp:old", - cwd: "/tmp", - }); - advance(2_000); - const fresh = boundedStore.createSession({ - sessionId: "fresh", - sessionKey: "acp:fresh", - cwd: "/tmp", - }); - - expect(fresh.sessionId).toBe("fresh"); - expect(boundedStore.getSession("old")).toBeUndefined(); - } finally { - boundedStore.clearAllSessionsForTest(); - } - }); - - it("uses soft-cap eviction for the oldest idle session when full", () => { - const boundedStore = createInMemorySessionStore({ - maxSessions: 2, - idleTtlMs: 24 * 60 * 60 * 1_000, - now, - }); - try { - const first = boundedStore.createSession({ - sessionId: "first", - sessionKey: "acp:first", - cwd: "/tmp", - }); - advance(100); - const second = boundedStore.createSession({ - sessionId: "second", - sessionKey: "acp:second", - cwd: "/tmp", - }); - const controller = new AbortController(); - boundedStore.setActiveRun(second.sessionId, "run-2", controller); - advance(100); - - const third = boundedStore.createSession({ - sessionId: "third", - sessionKey: "acp:third", - cwd: "/tmp", - }); - - expect(third.sessionId).toBe("third"); - expect(boundedStore.getSession(first.sessionId)).toBeUndefined(); - expect(boundedStore.getSession(second.sessionId)).toBeDefined(); - } finally { - boundedStore.clearAllSessionsForTest(); - } - }); - - it("rejects when full and no session is evictable", () => { - const boundedStore = createInMemorySessionStore({ - maxSessions: 1, - idleTtlMs: 24 * 60 * 60 * 1_000, - now, - }); - try { - const only = boundedStore.createSession({ - sessionId: "only", - sessionKey: "acp:only", - cwd: "/tmp", - }); - boundedStore.setActiveRun(only.sessionId, "run-only", new AbortController()); - - expect(() => - boundedStore.createSession({ - sessionId: "next", - sessionKey: "acp:next", - cwd: "/tmp", - }), - ).toThrow(/session limit reached/i); - } finally { - boundedStore.clearAllSessionsForTest(); - } - }); -}); diff --git a/src/acp/session.test.ts b/src/acp/session.test.ts new file mode 100644 index 0000000000000..0f1e92c3bae90 --- /dev/null +++ b/src/acp/session.test.ts @@ -0,0 +1,146 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { createInMemorySessionStore } from "./session.js"; + +describe("acp session manager", () => { + let nowMs = 0; + const now = () => nowMs; + const advance = (ms: number) => { + nowMs += ms; + }; + let store = createInMemorySessionStore({ now }); + + beforeEach(() => { + nowMs = 1_000; + store = createInMemorySessionStore({ now }); + }); + + afterEach(() => { + store.clearAllSessionsForTest(); + }); + + it("tracks active runs and clears on cancel", () => { + const session = store.createSession({ + sessionKey: "acp:test", + cwd: "/tmp", + }); + const controller = new AbortController(); + store.setActiveRun(session.sessionId, "run-1", controller); + + expect(store.getSessionByRunId("run-1")?.sessionId).toBe(session.sessionId); + + const cancelled = store.cancelActiveRun(session.sessionId); + expect(cancelled).toBe(true); + expect(store.getSessionByRunId("run-1")).toBeUndefined(); + }); + + it("refreshes existing session IDs instead of creating duplicates", () => { + const first = store.createSession({ + sessionId: "existing", + sessionKey: "acp:one", + cwd: "/tmp/one", + }); + advance(500); + + const refreshed = store.createSession({ + sessionId: "existing", + sessionKey: "acp:two", + cwd: "/tmp/two", + }); + + expect(refreshed).toBe(first); + expect(refreshed.sessionKey).toBe("acp:two"); + expect(refreshed.cwd).toBe("/tmp/two"); + expect(refreshed.createdAt).toBe(1_000); + expect(refreshed.lastTouchedAt).toBe(1_500); + expect(store.hasSession("existing")).toBe(true); + }); + + it("reaps idle sessions before enforcing the max session cap", () => { + const boundedStore = createInMemorySessionStore({ + maxSessions: 1, + idleTtlMs: 1_000, + now, + }); + try { + boundedStore.createSession({ + sessionId: "old", + sessionKey: "acp:old", + cwd: "/tmp", + }); + advance(2_000); + const fresh = boundedStore.createSession({ + sessionId: "fresh", + sessionKey: "acp:fresh", + cwd: "/tmp", + }); + + expect(fresh.sessionId).toBe("fresh"); + expect(boundedStore.getSession("old")).toBeUndefined(); + expect(boundedStore.hasSession("old")).toBe(false); + } finally { + boundedStore.clearAllSessionsForTest(); + } + }); + + it("uses soft-cap eviction for the oldest idle session when full", () => { + const boundedStore = createInMemorySessionStore({ + maxSessions: 2, + idleTtlMs: 24 * 60 * 60 * 1_000, + now, + }); + try { + const first = boundedStore.createSession({ + sessionId: "first", + sessionKey: "acp:first", + cwd: "/tmp", + }); + advance(100); + const second = boundedStore.createSession({ + sessionId: "second", + sessionKey: "acp:second", + cwd: "/tmp", + }); + const controller = new AbortController(); + boundedStore.setActiveRun(second.sessionId, "run-2", controller); + advance(100); + + const third = boundedStore.createSession({ + sessionId: "third", + sessionKey: "acp:third", + cwd: "/tmp", + }); + + expect(third.sessionId).toBe("third"); + expect(boundedStore.getSession(first.sessionId)).toBeUndefined(); + expect(boundedStore.getSession(second.sessionId)).toBeDefined(); + } finally { + boundedStore.clearAllSessionsForTest(); + } + }); + + it("rejects when full and no session is evictable", () => { + const boundedStore = createInMemorySessionStore({ + maxSessions: 1, + idleTtlMs: 24 * 60 * 60 * 1_000, + now, + }); + try { + const only = boundedStore.createSession({ + sessionId: "only", + sessionKey: "acp:only", + cwd: "/tmp", + }); + boundedStore.setActiveRun(only.sessionId, "run-only", new AbortController()); + + expect(() => + boundedStore.createSession({ + sessionId: "next", + sessionKey: "acp:next", + cwd: "/tmp", + }), + ).toThrow(/session limit reached/i); + } finally { + boundedStore.clearAllSessionsForTest(); + } + }); +}); diff --git a/src/acp/session.ts b/src/acp/session.ts index 92c45d8752247..a098edf6fcd9a 100644 --- a/src/acp/session.ts +++ b/src/acp/session.ts @@ -3,6 +3,7 @@ import type { AcpSession } from "./types.js"; export type AcpSessionStore = { createSession: (params: { sessionKey: string; cwd: string; sessionId?: string }) => AcpSession; + hasSession: (sessionId: string) => boolean; getSession: (sessionId: string) => AcpSession | undefined; getSessionByRunId: (runId: string) => AcpSession | undefined; setActiveRun: (sessionId: string, runId: string, abortController: AbortController) => void; @@ -105,6 +106,8 @@ export function createInMemorySessionStore(options: AcpSessionStoreOptions = {}) return session; }; + const hasSession: AcpSessionStore["hasSession"] = (sessionId) => sessions.has(sessionId); + const getSession: AcpSessionStore["getSession"] = (sessionId) => { const session = sessions.get(sessionId); if (session) { @@ -174,6 +177,7 @@ export function createInMemorySessionStore(options: AcpSessionStoreOptions = {}) return { createSession, + hasSession, getSession, getSessionByRunId, setActiveRun, diff --git a/src/acp/translator.ts b/src/acp/translator.ts index 10a0a52bddb64..7557922216ed4 100644 --- a/src/acp/translator.ts +++ b/src/acp/translator.ts @@ -23,6 +23,10 @@ import { PROTOCOL_VERSION } from "@agentclientprotocol/sdk"; import type { GatewayClient } from "../gateway/client.js"; import type { EventFrame } from "../gateway/protocol/index.js"; import type { SessionsListResult } from "../gateway/session-utils.js"; +import { + createFixedWindowRateLimiter, + type FixedWindowRateLimiter, +} from "../infra/fixed-window-rate-limit.js"; import { getAvailableCommands } from "./commands.js"; import { extractAttachmentsFromPrompt, @@ -53,47 +57,13 @@ type AcpGatewayAgentOptions = AcpServerOptions & { const SESSION_CREATE_RATE_LIMIT_DEFAULT_MAX_REQUESTS = 120; const SESSION_CREATE_RATE_LIMIT_DEFAULT_WINDOW_MS = 10_000; -class SessionCreateRateLimiter { - private count = 0; - private windowStartMs = 0; - - constructor( - private readonly maxRequests: number, - private readonly windowMs: number, - private readonly now: () => number = Date.now, - ) {} - - consume(): { allowed: boolean; retryAfterMs: number; remaining: number } { - const nowMs = this.now(); - if (nowMs - this.windowStartMs >= this.windowMs) { - this.windowStartMs = nowMs; - this.count = 0; - } - - if (this.count >= this.maxRequests) { - return { - allowed: false, - retryAfterMs: Math.max(0, this.windowStartMs + this.windowMs - nowMs), - remaining: 0, - }; - } - - this.count += 1; - return { - allowed: true, - retryAfterMs: 0, - remaining: Math.max(0, this.maxRequests - this.count), - }; - } -} - export class AcpGatewayAgent implements Agent { private connection: AgentSideConnection; private gateway: GatewayClient; private opts: AcpGatewayAgentOptions; private log: (msg: string) => void; private sessionStore: AcpSessionStore; - private sessionCreateRateLimiter: SessionCreateRateLimiter; + private sessionCreateRateLimiter: FixedWindowRateLimiter; private pendingPrompts = new Map(); constructor( @@ -106,16 +76,16 @@ export class AcpGatewayAgent implements Agent { this.opts = opts; this.log = opts.verbose ? (msg: string) => process.stderr.write(`[acp] ${msg}\n`) : () => {}; this.sessionStore = opts.sessionStore ?? defaultAcpSessionStore; - this.sessionCreateRateLimiter = new SessionCreateRateLimiter( - Math.max( + this.sessionCreateRateLimiter = createFixedWindowRateLimiter({ + maxRequests: Math.max( 1, opts.sessionCreateRateLimit?.maxRequests ?? SESSION_CREATE_RATE_LIMIT_DEFAULT_MAX_REQUESTS, ), - Math.max( + windowMs: Math.max( 1_000, opts.sessionCreateRateLimit?.windowMs ?? SESSION_CREATE_RATE_LIMIT_DEFAULT_WINDOW_MS, ), - ); + }); } start(): void { @@ -203,7 +173,7 @@ export class AcpGatewayAgent implements Agent { if (params.mcpServers.length > 0) { this.log(`ignoring ${params.mcpServers.length} MCP servers`); } - if (!this.sessionStore.getSession(params.sessionId)) { + if (!this.sessionStore.hasSession(params.sessionId)) { this.enforceSessionCreateRateLimit("loadSession"); } diff --git a/src/infra/fixed-window-rate-limit.test.ts b/src/infra/fixed-window-rate-limit.test.ts new file mode 100644 index 0000000000000..1afc50974d03a --- /dev/null +++ b/src/infra/fixed-window-rate-limit.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { createFixedWindowRateLimiter } from "./fixed-window-rate-limit.js"; + +describe("fixed-window rate limiter", () => { + it("blocks after max requests until window reset", () => { + let nowMs = 1_000; + const limiter = createFixedWindowRateLimiter({ + maxRequests: 2, + windowMs: 1_000, + now: () => nowMs, + }); + + expect(limiter.consume()).toMatchObject({ allowed: true, remaining: 1 }); + expect(limiter.consume()).toMatchObject({ allowed: true, remaining: 0 }); + expect(limiter.consume()).toMatchObject({ allowed: false, retryAfterMs: 1_000 }); + + nowMs += 1_000; + expect(limiter.consume()).toMatchObject({ allowed: true, remaining: 1 }); + }); + + it("supports explicit reset", () => { + const limiter = createFixedWindowRateLimiter({ + maxRequests: 1, + windowMs: 10_000, + }); + expect(limiter.consume().allowed).toBe(true); + expect(limiter.consume().allowed).toBe(false); + limiter.reset(); + expect(limiter.consume().allowed).toBe(true); + }); +}); diff --git a/src/infra/fixed-window-rate-limit.ts b/src/infra/fixed-window-rate-limit.ts new file mode 100644 index 0000000000000..edd7f9771ed04 --- /dev/null +++ b/src/infra/fixed-window-rate-limit.ts @@ -0,0 +1,48 @@ +export type FixedWindowRateLimiter = { + consume: () => { + allowed: boolean; + retryAfterMs: number; + remaining: number; + }; + reset: () => void; +}; + +export function createFixedWindowRateLimiter(params: { + maxRequests: number; + windowMs: number; + now?: () => number; +}): FixedWindowRateLimiter { + const maxRequests = Math.max(1, Math.floor(params.maxRequests)); + const windowMs = Math.max(1, Math.floor(params.windowMs)); + const now = params.now ?? Date.now; + + let count = 0; + let windowStartMs = 0; + + return { + consume() { + const nowMs = now(); + if (nowMs - windowStartMs >= windowMs) { + windowStartMs = nowMs; + count = 0; + } + if (count >= maxRequests) { + return { + allowed: false, + retryAfterMs: Math.max(0, windowStartMs + windowMs - nowMs), + remaining: 0, + }; + } + count += 1; + return { + allowed: true, + retryAfterMs: 0, + remaining: Math.max(0, maxRequests - count), + }; + }, + reset() { + count = 0; + windowStartMs = 0; + }, + }; +} From 29118995ad31092c5fbe9940c6acb511e6d6a7b8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 14:58:01 +0100 Subject: [PATCH 0004/1006] refactor(lobster): remove lobsterPath overrides --- CHANGELOG.md | 1 + docs/automation/cron-vs-heartbeat.md | 2 +- docs/tools/lobster.md | 8 +- extensions/lobster/README.md | 2 +- extensions/lobster/src/lobster-tool.test.ts | 240 +++++-------------- extensions/lobster/src/lobster-tool.ts | 122 +++------- extensions/lobster/src/windows-spawn.test.ts | 148 ++++++++++++ extensions/lobster/src/windows-spawn.ts | 2 +- 8 files changed, 244 insertions(+), 281 deletions(-) create mode 100644 extensions/lobster/src/windows-spawn.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b298786540945..3c33779804f4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,7 @@ Docs: https://docs.openclaw.ai - Security/Refactor: centralize hardened temp-file path generation for Feishu and LINE media downloads via shared `buildRandomTempFilePath` helper to reduce drift risk. (#20810) Thanks @mbelinky. - Security/Media: harden local media ingestion against TOCTOU/symlink swap attacks by pinning reads to a single file descriptor with symlink rejection and inode/device verification in `saveMediaSource`. Thanks @dorjoos for reporting. - Security/Lobster (Windows): for the next npm release, remove shell-based fallback when launching Lobster wrappers (`.cmd`/`.bat`) and switch to explicit argv execution with wrapper entrypoint resolution, preventing command injection while preserving Windows wrapper compatibility. Thanks @allsmog for reporting. +- Lobster/Config: remove Lobster executable-path overrides (`lobsterPath`), require PATH-based execution, and add focused Windows wrapper-resolution tests to keep shell-free behavior stable. - Agents/Streaming: keep assistant partial streaming active during reasoning streams, handle native `thinking_*` stream events consistently, dedupe mixed reasoning-end signals, and clear stale mutating tool errors after same-target retry success. (#20635) Thanks @obviyus. - Security/OTEL: sanitize OTLP endpoint URL resolution. (#13791) Thanks @vincentkoc. - OTEL/diagnostics-otel: complete OpenTelemetry v2 API migration. (#12897) Thanks @vincentkoc. diff --git a/docs/automation/cron-vs-heartbeat.md b/docs/automation/cron-vs-heartbeat.md index a138e721ae4cd..c25cbcb80dbc9 100644 --- a/docs/automation/cron-vs-heartbeat.md +++ b/docs/automation/cron-vs-heartbeat.md @@ -211,7 +211,7 @@ For ad-hoc workflows, call Lobster directly. - Lobster runs as a **local subprocess** (`lobster` CLI) in tool mode and returns a **JSON envelope**. - If the tool returns `needs_approval`, you resume with a `resumeToken` and `approve` flag. - The tool is an **optional plugin**; enable it additively via `tools.alsoAllow: ["lobster"]` (recommended). -- If you pass `lobsterPath`, it must be an **absolute path**. +- Lobster expects the `lobster` CLI to be available on `PATH`. See [Lobster](/tools/lobster) for full usage and examples. diff --git a/docs/tools/lobster.md b/docs/tools/lobster.md index 31e4e17d52194..65ff4f56dfb75 100644 --- a/docs/tools/lobster.md +++ b/docs/tools/lobster.md @@ -154,7 +154,6 @@ Notes: ## Install Lobster Install the Lobster CLI on the **same host** that runs the OpenClaw Gateway (see the [Lobster repo](https://github.com/openclaw/lobster)), and ensure `lobster` is on `PATH`. -If you want to use a custom binary location, pass an **absolute** `lobsterPath` in the tool call. ## Enable the tool @@ -256,7 +255,7 @@ Run a pipeline in tool mode. { "action": "run", "pipeline": "gog.gmail.search --query 'newer_than:1d' | email.triage", - "cwd": "/path/to/workspace", + "cwd": "workspace", "timeoutMs": 30000, "maxStdoutBytes": 512000 } @@ -286,8 +285,7 @@ Continue a halted workflow after approval. ### Optional inputs -- `lobsterPath`: Absolute path to the Lobster binary (omit to use `PATH`). -- `cwd`: Working directory for the pipeline (defaults to the current process working directory). +- `cwd`: Relative working directory for the pipeline (must stay within the current process working directory). - `timeoutMs`: Kill the subprocess if it exceeds this duration (default: 20000). - `maxStdoutBytes`: Kill the subprocess if stdout exceeds this size (default: 512000). - `argsJson`: JSON string passed to `lobster run --args-json` (workflow files only). @@ -320,7 +318,7 @@ OpenProse pairs well with Lobster: use `/prose` to orchestrate multi-agent prep, - **Local subprocess only** — no network calls from the plugin itself. - **No secrets** — Lobster doesn't manage OAuth; it calls OpenClaw tools that do. - **Sandbox-aware** — disabled when the tool context is sandboxed. -- **Hardened** — `lobsterPath` must be absolute if specified; timeouts and output caps enforced. +- **Hardened** — fixed executable name (`lobster`) on `PATH`; timeouts and output caps enforced. ## Troubleshooting diff --git a/extensions/lobster/README.md b/extensions/lobster/README.md index 8a7d600f1c034..03c083e62279b 100644 --- a/extensions/lobster/README.md +++ b/extensions/lobster/README.md @@ -72,4 +72,4 @@ Notes: - Runs the `lobster` executable as a local subprocess. - Does not manage OAuth/tokens. - Uses timeouts, stdout caps, and strict JSON envelope parsing. -- Prefer an absolute `lobsterPath` in production to avoid PATH hijack. +- Ensure `lobster` is available on `PATH` for the gateway process. diff --git a/extensions/lobster/src/lobster-tool.test.ts b/extensions/lobster/src/lobster-tool.test.ts index 50a7dbf9358d6..294e625ce2b09 100644 --- a/extensions/lobster/src/lobster-tool.test.ts +++ b/extensions/lobster/src/lobster-tool.test.ts @@ -66,8 +66,6 @@ function setProcessPlatform(platform: NodeJS.Platform) { describe("lobster plugin tool", () => { let tempDir = ""; - let lobsterBinPath = ""; - let lobsterExePath = ""; const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform"); const originalPath = process.env.PATH; const originalPathAlt = process.env.Path; @@ -78,10 +76,6 @@ describe("lobster plugin tool", () => { ({ createLobsterTool } = await import("./lobster-tool.js")); tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lobster-plugin-")); - lobsterBinPath = path.join(tempDir, process.platform === "win32" ? "lobster.cmd" : "lobster"); - lobsterExePath = path.join(tempDir, "lobster.exe"); - await fs.writeFile(lobsterBinPath, "", { encoding: "utf8", mode: 0o755 }); - await fs.writeFile(lobsterExePath, "", { encoding: "utf8", mode: 0o755 }); }); afterEach(() => { @@ -151,6 +145,28 @@ describe("lobster plugin tool", () => { }); }); + const queueSuccessfulEnvelope = (hello = "world") => { + spawnState.queue.push({ + stdout: JSON.stringify({ + ok: true, + status: "ok", + output: [{ hello }], + requiresApproval: null, + }), + }); + }; + + const createWindowsShimFixture = async (params: { + shimPath: string; + scriptPath: string; + scriptToken: string; + }) => { + await fs.mkdir(path.dirname(params.scriptPath), { recursive: true }); + await fs.mkdir(path.dirname(params.shimPath), { recursive: true }); + await fs.writeFile(params.scriptPath, "module.exports = {};\n", "utf8"); + await fs.writeFile(params.shimPath, `@echo off\r\n"${params.scriptToken}" %*\r\n`, "utf8"); + }; + it("runs lobster and returns parsed envelope in details", async () => { spawnState.queue.push({ stdout: JSON.stringify({ @@ -188,26 +204,43 @@ describe("lobster plugin tool", () => { expect(res.details).toMatchObject({ ok: true, status: "ok" }); }); - it("requires absolute lobsterPath when provided (even though it is ignored)", async () => { + it("requires action", async () => { + const tool = createLobsterTool(fakeApi()); + await expect(tool.execute("call-action-missing", {})).rejects.toThrow(/action required/); + }); + + it("requires pipeline for run action", async () => { const tool = createLobsterTool(fakeApi()); await expect( - tool.execute("call2", { + tool.execute("call-pipeline-missing", { action: "run", - pipeline: "noop", - lobsterPath: "./lobster", }), - ).rejects.toThrow(/absolute path/); + ).rejects.toThrow(/pipeline required/); }); - it("rejects lobsterPath (deprecated) when invalid", async () => { + it("requires token and approve for resume action", async () => { const tool = createLobsterTool(fakeApi()); await expect( - tool.execute("call2b", { - action: "run", - pipeline: "noop", - lobsterPath: "/bin/bash", + tool.execute("call-resume-token-missing", { + action: "resume", + approve: true, + }), + ).rejects.toThrow(/token required/); + await expect( + tool.execute("call-resume-approve-missing", { + action: "resume", + token: "resume-token", + }), + ).rejects.toThrow(/approve required/); + }); + + it("rejects unknown action", async () => { + const tool = createLobsterTool(fakeApi()); + await expect( + tool.execute("call-action-unknown", { + action: "explode", }), - ).rejects.toThrow(/lobster executable/); + ).rejects.toThrow(/Unknown action/); }); it("rejects absolute cwd", async () => { @@ -232,32 +265,6 @@ describe("lobster plugin tool", () => { ).rejects.toThrow(/must stay within/); }); - it("uses pluginConfig.lobsterPath when provided", async () => { - spawnState.queue.push({ - stdout: JSON.stringify({ - ok: true, - status: "ok", - output: [{ hello: "world" }], - requiresApproval: null, - }), - }); - - const configuredLobsterPath = process.platform === "win32" ? lobsterExePath : lobsterBinPath; - const tool = createLobsterTool( - fakeApi({ pluginConfig: { lobsterPath: configuredLobsterPath } }), - ); - const res = await tool.execute("call-plugin-config", { - action: "run", - pipeline: "noop", - timeoutMs: 1000, - }); - - expect(spawnState.spawn).toHaveBeenCalled(); - const [execPath] = spawnState.spawn.mock.calls[0] ?? []; - expect(execPath).toBe(configuredLobsterPath); - expect(res.details).toMatchObject({ ok: true, status: "ok" }); - }); - it("rejects invalid JSON from lobster", async () => { spawnState.queue.push({ stdout: "nope" }); @@ -273,25 +280,17 @@ describe("lobster plugin tool", () => { it("runs Windows cmd shims through Node without enabling shell", async () => { setProcessPlatform("win32"); const shimScriptPath = path.join(tempDir, "shim-dist", "lobster-cli.cjs"); - const shimPath = path.join(tempDir, "shim", "lobster.cmd"); - await fs.mkdir(path.dirname(shimScriptPath), { recursive: true }); - await fs.mkdir(path.dirname(shimPath), { recursive: true }); - await fs.writeFile(shimScriptPath, "module.exports = {};\n", "utf8"); - await fs.writeFile( + const shimPath = path.join(tempDir, "shim-bin", "lobster.cmd"); + await createWindowsShimFixture({ shimPath, - `@echo off\r\n"%dp0%\\..\\shim-dist\\lobster-cli.cjs" %*\r\n`, - "utf8", - ); - spawnState.queue.push({ - stdout: JSON.stringify({ - ok: true, - status: "ok", - output: [{ hello: "world" }], - requiresApproval: null, - }), + scriptPath: shimScriptPath, + scriptToken: "%dp0%\\..\\shim-dist\\lobster-cli.cjs", }); + process.env.PATHEXT = ".CMD;.EXE"; + process.env.PATH = `${path.dirname(shimPath)};${process.env.PATH ?? ""}`; + queueSuccessfulEnvelope(); - const tool = createLobsterTool(fakeApi({ pluginConfig: { lobsterPath: shimPath } })); + const tool = createLobsterTool(fakeApi()); await tool.execute("call-win-shim", { action: "run", pipeline: "noop", @@ -304,127 +303,6 @@ describe("lobster plugin tool", () => { expect(options).not.toHaveProperty("shell"); }); - it("runs Windows cmd shims with rooted dp0 tokens through Node", async () => { - setProcessPlatform("win32"); - const shimScriptPath = path.join(tempDir, "shim-dist", "lobster-cli.cjs"); - const shimPath = path.join(tempDir, "shim", "lobster.cmd"); - await fs.mkdir(path.dirname(shimScriptPath), { recursive: true }); - await fs.mkdir(path.dirname(shimPath), { recursive: true }); - await fs.writeFile(shimScriptPath, "module.exports = {};\n", "utf8"); - await fs.writeFile( - shimPath, - `@echo off\r\n"%dp0%\\..\\shim-dist\\lobster-cli.cjs" %*\r\n`, - "utf8", - ); - spawnState.queue.push({ - stdout: JSON.stringify({ - ok: true, - status: "ok", - output: [{ hello: "rooted" }], - requiresApproval: null, - }), - }); - - const tool = createLobsterTool(fakeApi({ pluginConfig: { lobsterPath: shimPath } })); - await tool.execute("call-win-rooted-shim", { - action: "run", - pipeline: "noop", - }); - - const [command, argv] = spawnState.spawn.mock.calls[0] ?? []; - expect(command).toBe(process.execPath); - expect(argv).toEqual([shimScriptPath, "run", "--mode", "tool", "noop"]); - }); - - it("ignores node.exe shim entries and resolves the actual lobster script", async () => { - setProcessPlatform("win32"); - const shimDir = path.join(tempDir, "shim-with-node"); - const nodeExePath = path.join(shimDir, "node.exe"); - const scriptPath = path.join(tempDir, "shim-dist-node", "lobster-cli.cjs"); - const shimPath = path.join(shimDir, "lobster.cmd"); - await fs.mkdir(path.dirname(scriptPath), { recursive: true }); - await fs.mkdir(shimDir, { recursive: true }); - await fs.writeFile(nodeExePath, "", "utf8"); - await fs.writeFile(scriptPath, "module.exports = {};\n", "utf8"); - await fs.writeFile( - shimPath, - `@echo off\r\n"%~dp0%\\node.exe" "%~dp0%\\..\\shim-dist-node\\lobster-cli.cjs" %*\r\n`, - "utf8", - ); - spawnState.queue.push({ - stdout: JSON.stringify({ - ok: true, - status: "ok", - output: [{ hello: "node-first" }], - requiresApproval: null, - }), - }); - - const tool = createLobsterTool(fakeApi({ pluginConfig: { lobsterPath: shimPath } })); - await tool.execute("call-win-node-first", { - action: "run", - pipeline: "noop", - }); - - const [command, argv] = spawnState.spawn.mock.calls[0] ?? []; - expect(command).toBe(process.execPath); - expect(argv).toEqual([scriptPath, "run", "--mode", "tool", "noop"]); - }); - - it("resolves lobster.cmd from PATH and unwraps npm layout shim", async () => { - setProcessPlatform("win32"); - const binDir = path.join(tempDir, "node_modules", ".bin"); - const packageDir = path.join(tempDir, "node_modules", "lobster"); - const scriptPath = path.join(packageDir, "dist", "cli.js"); - const shimPath = path.join(binDir, "lobster.cmd"); - await fs.mkdir(path.dirname(scriptPath), { recursive: true }); - await fs.mkdir(binDir, { recursive: true }); - await fs.writeFile(shimPath, "@echo off\r\n", "utf8"); - await fs.writeFile( - path.join(packageDir, "package.json"), - JSON.stringify({ name: "lobster", version: "0.0.0", bin: { lobster: "dist/cli.js" } }), - "utf8", - ); - await fs.writeFile(scriptPath, "module.exports = {};\n", "utf8"); - process.env.PATHEXT = ".CMD;.EXE"; - process.env.PATH = `${binDir};${process.env.PATH ?? ""}`; - - spawnState.queue.push({ - stdout: JSON.stringify({ - ok: true, - status: "ok", - output: [{ hello: "path" }], - requiresApproval: null, - }), - }); - - const tool = createLobsterTool(fakeApi()); - await tool.execute("call-win-path", { - action: "run", - pipeline: "noop", - }); - - const [command, argv] = spawnState.spawn.mock.calls[0] ?? []; - expect(command).toBe(process.execPath); - expect(argv).toEqual([scriptPath, "run", "--mode", "tool", "noop"]); - }); - - it("fails fast when cmd wrapper cannot be resolved without shell execution", async () => { - setProcessPlatform("win32"); - const badShimPath = path.join(tempDir, "bad-shim", "lobster.cmd"); - await fs.mkdir(path.dirname(badShimPath), { recursive: true }); - await fs.writeFile(badShimPath, "@echo off\r\nREM no entrypoint\r\n", "utf8"); - - const tool = createLobsterTool(fakeApi({ pluginConfig: { lobsterPath: badShimPath } })); - await expect( - tool.execute("call-win-bad", { - action: "run", - pipeline: "noop", - }), - ).rejects.toThrow(/without shell execution/); - expect(spawnState.spawn).not.toHaveBeenCalled(); - }); - it("does not retry a failed Windows spawn with shell fallback", async () => { setProcessPlatform("win32"); spawnState.spawn.mockReset(); @@ -442,7 +320,7 @@ describe("lobster plugin tool", () => { return child; }); - const tool = createLobsterTool(fakeApi({ pluginConfig: { lobsterPath: lobsterExePath } })); + const tool = createLobsterTool(fakeApi()); await expect( tool.execute("call-win-no-retry", { action: "run", diff --git a/extensions/lobster/src/lobster-tool.ts b/extensions/lobster/src/lobster-tool.ts index 5c46261938f54..e4402861ef5d2 100644 --- a/extensions/lobster/src/lobster-tool.ts +++ b/extensions/lobster/src/lobster-tool.ts @@ -1,5 +1,4 @@ import { spawn } from "node:child_process"; -import fs from "node:fs"; import path from "node:path"; import { Type } from "@sinclair/typebox"; import type { OpenClawPluginApi } from "../../../src/plugins/types.js"; @@ -22,43 +21,6 @@ type LobsterEnvelope = error: { type?: string; message: string }; }; -function resolveExecutablePath(lobsterPathRaw: string | undefined) { - const lobsterPath = lobsterPathRaw?.trim() || "lobster"; - - // SECURITY: - // Never allow arbitrary executables (e.g. /bin/bash). If the caller overrides - // the path, it must still be the lobster binary (by name) and be absolute. - if (lobsterPath !== "lobster") { - if (!path.isAbsolute(lobsterPath)) { - throw new Error("lobsterPath must be an absolute path (or omit to use PATH)"); - } - const base = path.basename(lobsterPath).toLowerCase(); - const allowed = - process.platform === "win32" ? ["lobster.exe", "lobster.cmd", "lobster.bat"] : ["lobster"]; - if (!allowed.includes(base)) { - throw new Error("lobsterPath must point to the lobster executable"); - } - let stat: fs.Stats; - try { - stat = fs.statSync(lobsterPath); - } catch { - throw new Error("lobsterPath must exist"); - } - if (!stat.isFile()) { - throw new Error("lobsterPath must point to a file"); - } - if (process.platform !== "win32") { - try { - fs.accessSync(lobsterPath, fs.constants.X_OK); - } catch { - throw new Error("lobsterPath must be executable"); - } - } - } - - return lobsterPath; -} - function normalizeForCwdSandbox(p: string): string { const normalized = path.normalize(p); return process.platform === "win32" ? normalized.toLowerCase() : normalized; @@ -180,16 +142,6 @@ async function runLobsterSubprocessOnce(params: { }); } -async function runLobsterSubprocess(params: { - execPath: string; - argv: string[]; - cwd: string; - timeoutMs: number; - maxStdoutBytes: number; -}) { - return await runLobsterSubprocessOnce(params); -} - function parseEnvelope(stdout: string): LobsterEnvelope { const trimmed = stdout.trim(); @@ -228,6 +180,33 @@ function parseEnvelope(stdout: string): LobsterEnvelope { throw new Error("lobster returned invalid JSON envelope"); } +function buildLobsterArgv(action: string, params: Record): string[] { + if (action === "run") { + const pipeline = typeof params.pipeline === "string" ? params.pipeline : ""; + if (!pipeline.trim()) { + throw new Error("pipeline required"); + } + const argv = ["run", "--mode", "tool", pipeline]; + const argsJson = typeof params.argsJson === "string" ? params.argsJson : ""; + if (argsJson.trim()) { + argv.push("--args-json", argsJson); + } + return argv; + } + if (action === "resume") { + const token = typeof params.token === "string" ? params.token : ""; + if (!token.trim()) { + throw new Error("token required"); + } + const approve = params.approve; + if (typeof approve !== "boolean") { + throw new Error("approve required"); + } + return ["resume", "--token", token, "--approve", approve ? "yes" : "no"]; + } + throw new Error(`Unknown action: ${action}`); +} + export function createLobsterTool(api: OpenClawPluginApi) { return { name: "lobster", @@ -241,11 +220,6 @@ export function createLobsterTool(api: OpenClawPluginApi) { argsJson: Type.Optional(Type.String()), token: Type.Optional(Type.String()), approve: Type.Optional(Type.Boolean()), - // SECURITY: Do not allow the agent to choose an executable path. - // Host can configure the lobster binary via plugin config. - lobsterPath: Type.Optional( - Type.String({ description: "(deprecated) Use plugin config instead." }), - ), cwd: Type.Optional( Type.String({ description: @@ -261,55 +235,19 @@ export function createLobsterTool(api: OpenClawPluginApi) { throw new Error("action required"); } - // SECURITY: never allow tool callers (agent/user) to select executables. - // If a host needs to override the binary, it must do so via plugin config. - // We still validate the parameter shape to prevent reintroducing an RCE footgun. - if (typeof params.lobsterPath === "string" && params.lobsterPath.trim()) { - resolveExecutablePath(params.lobsterPath); - } - - const execPath = resolveExecutablePath( - typeof api.pluginConfig?.lobsterPath === "string" - ? api.pluginConfig.lobsterPath - : undefined, - ); + const execPath = "lobster"; const cwd = resolveCwd(params.cwd); const timeoutMs = typeof params.timeoutMs === "number" ? params.timeoutMs : 20_000; const maxStdoutBytes = typeof params.maxStdoutBytes === "number" ? params.maxStdoutBytes : 512_000; - const argv = (() => { - if (action === "run") { - const pipeline = typeof params.pipeline === "string" ? params.pipeline : ""; - if (!pipeline.trim()) { - throw new Error("pipeline required"); - } - const argv = ["run", "--mode", "tool", pipeline]; - const argsJson = typeof params.argsJson === "string" ? params.argsJson : ""; - if (argsJson.trim()) { - argv.push("--args-json", argsJson); - } - return argv; - } - if (action === "resume") { - const token = typeof params.token === "string" ? params.token : ""; - if (!token.trim()) { - throw new Error("token required"); - } - const approve = params.approve; - if (typeof approve !== "boolean") { - throw new Error("approve required"); - } - return ["resume", "--token", token, "--approve", approve ? "yes" : "no"]; - } - throw new Error(`Unknown action: ${action}`); - })(); + const argv = buildLobsterArgv(action, params); if (api.runtime?.version && api.logger?.debug) { api.logger.debug(`lobster plugin runtime=${api.runtime.version}`); } - const { stdout } = await runLobsterSubprocess({ + const { stdout } = await runLobsterSubprocessOnce({ execPath, argv, cwd, diff --git a/extensions/lobster/src/windows-spawn.test.ts b/extensions/lobster/src/windows-spawn.test.ts new file mode 100644 index 0000000000000..75f49f34b0584 --- /dev/null +++ b/extensions/lobster/src/windows-spawn.test.ts @@ -0,0 +1,148 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { resolveWindowsLobsterSpawn } from "./windows-spawn.js"; + +function setProcessPlatform(platform: NodeJS.Platform) { + Object.defineProperty(process, "platform", { + value: platform, + configurable: true, + }); +} + +describe("resolveWindowsLobsterSpawn", () => { + let tempDir = ""; + const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform"); + const originalPath = process.env.PATH; + const originalPathAlt = process.env.Path; + const originalPathExt = process.env.PATHEXT; + const originalPathExtAlt = process.env.Pathext; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lobster-win-spawn-")); + setProcessPlatform("win32"); + }); + + afterEach(async () => { + if (originalPlatform) { + Object.defineProperty(process, "platform", originalPlatform); + } + if (originalPath === undefined) { + delete process.env.PATH; + } else { + process.env.PATH = originalPath; + } + if (originalPathAlt === undefined) { + delete process.env.Path; + } else { + process.env.Path = originalPathAlt; + } + if (originalPathExt === undefined) { + delete process.env.PATHEXT; + } else { + process.env.PATHEXT = originalPathExt; + } + if (originalPathExtAlt === undefined) { + delete process.env.Pathext; + } else { + process.env.Pathext = originalPathExtAlt; + } + if (tempDir) { + await fs.rm(tempDir, { recursive: true, force: true }); + tempDir = ""; + } + }); + + it("unwraps cmd shim with %dp0% token", async () => { + const scriptPath = path.join(tempDir, "shim-dist", "lobster-cli.cjs"); + const shimPath = path.join(tempDir, "shim", "lobster.cmd"); + await fs.mkdir(path.dirname(scriptPath), { recursive: true }); + await fs.mkdir(path.dirname(shimPath), { recursive: true }); + await fs.writeFile(scriptPath, "module.exports = {};\n", "utf8"); + await fs.writeFile( + shimPath, + `@echo off\r\n"%dp0%\\..\\shim-dist\\lobster-cli.cjs" %*\r\n`, + "utf8", + ); + + const target = resolveWindowsLobsterSpawn(shimPath, ["run", "noop"], process.env); + expect(target.command).toBe(process.execPath); + expect(target.argv).toEqual([scriptPath, "run", "noop"]); + expect(target.windowsHide).toBe(true); + }); + + it("unwraps cmd shim with %~dp0% token", async () => { + const scriptPath = path.join(tempDir, "shim-dist", "lobster-cli.cjs"); + const shimPath = path.join(tempDir, "shim", "lobster.cmd"); + await fs.mkdir(path.dirname(scriptPath), { recursive: true }); + await fs.mkdir(path.dirname(shimPath), { recursive: true }); + await fs.writeFile(scriptPath, "module.exports = {};\n", "utf8"); + await fs.writeFile( + shimPath, + `@echo off\r\n"%~dp0%\\..\\shim-dist\\lobster-cli.cjs" %*\r\n`, + "utf8", + ); + + const target = resolveWindowsLobsterSpawn(shimPath, ["run", "noop"], process.env); + expect(target.command).toBe(process.execPath); + expect(target.argv).toEqual([scriptPath, "run", "noop"]); + expect(target.windowsHide).toBe(true); + }); + + it("ignores node.exe shim entries and picks lobster script", async () => { + const shimDir = path.join(tempDir, "shim-with-node"); + const scriptPath = path.join(tempDir, "shim-dist-node", "lobster-cli.cjs"); + const shimPath = path.join(shimDir, "lobster.cmd"); + await fs.mkdir(path.dirname(scriptPath), { recursive: true }); + await fs.mkdir(shimDir, { recursive: true }); + await fs.writeFile(path.join(shimDir, "node.exe"), "", "utf8"); + await fs.writeFile(scriptPath, "module.exports = {};\n", "utf8"); + await fs.writeFile( + shimPath, + `@echo off\r\n"%~dp0%\\node.exe" "%~dp0%\\..\\shim-dist-node\\lobster-cli.cjs" %*\r\n`, + "utf8", + ); + + const target = resolveWindowsLobsterSpawn(shimPath, ["run", "noop"], process.env); + expect(target.command).toBe(process.execPath); + expect(target.argv).toEqual([scriptPath, "run", "noop"]); + expect(target.windowsHide).toBe(true); + }); + + it("resolves lobster.cmd from PATH and unwraps npm layout shim", async () => { + const binDir = path.join(tempDir, "node_modules", ".bin"); + const packageDir = path.join(tempDir, "node_modules", "lobster"); + const scriptPath = path.join(packageDir, "dist", "cli.js"); + const shimPath = path.join(binDir, "lobster.cmd"); + await fs.mkdir(path.dirname(scriptPath), { recursive: true }); + await fs.mkdir(binDir, { recursive: true }); + await fs.writeFile(shimPath, "@echo off\r\n", "utf8"); + await fs.writeFile( + path.join(packageDir, "package.json"), + JSON.stringify({ name: "lobster", version: "0.0.0", bin: { lobster: "dist/cli.js" } }), + "utf8", + ); + await fs.writeFile(scriptPath, "module.exports = {};\n", "utf8"); + + const env = { + ...process.env, + PATH: `${binDir};${process.env.PATH ?? ""}`, + PATHEXT: ".CMD;.EXE", + }; + const target = resolveWindowsLobsterSpawn("lobster", ["run", "noop"], env); + expect(target.command).toBe(process.execPath); + expect(target.argv).toEqual([scriptPath, "run", "noop"]); + expect(target.windowsHide).toBe(true); + }); + + it("fails fast when wrapper cannot be resolved without shell execution", async () => { + const badShimPath = path.join(tempDir, "bad-shim", "lobster.cmd"); + await fs.mkdir(path.dirname(badShimPath), { recursive: true }); + await fs.writeFile(badShimPath, "@echo off\r\nREM no entrypoint\r\n", "utf8"); + + expect(() => resolveWindowsLobsterSpawn(badShimPath, ["run", "noop"], process.env)).toThrow( + /without shell execution/, + ); + }); +}); diff --git a/extensions/lobster/src/windows-spawn.ts b/extensions/lobster/src/windows-spawn.ts index a5c4c2bc9ff31..a416b759c938a 100644 --- a/extensions/lobster/src/windows-spawn.ts +++ b/extensions/lobster/src/windows-spawn.ts @@ -181,7 +181,7 @@ export function resolveWindowsLobsterSpawn( resolveLobsterScriptFromPackageJson(resolvedExecPath); if (!scriptPath) { throw new Error( - `lobsterPath resolved to ${path.basename(resolvedExecPath)} wrapper, but no Node entrypoint could be resolved without shell execution. Configure pluginConfig.lobsterPath to lobster.exe.`, + `${path.basename(resolvedExecPath)} wrapper resolved, but no Node entrypoint could be resolved without shell execution. Ensure Lobster is installed and runnable on PATH (prefer lobster.exe).`, ); } From b54ba3391bb4a972de4ee50a03a4958da2717094 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 14:59:30 +0100 Subject: [PATCH 0005/1006] fix: credit contributor in changelog (#20916) (thanks @orlyjamie) --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c33779804f4a..5d8f2c87af756 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,7 +36,7 @@ Docs: https://docs.openclaw.ai - Scripts: update clawdock helper command support to include `docker-compose.extra.yml` where available. (#17094) Thanks @zerone0x. - Security/iMessage: harden remote attachment SSH/SCP handling by requiring strict host-key verification, validating `channels.imessage.remoteHost` as `host`/`user@host`, and rejecting unsafe host tokens from config or auto-detection. Thanks @allsmog for reporting. - Security/Feishu: prevent path traversal in Feishu inbound media temp-file writes by replacing key-derived temp filenames with UUID-based names. Thanks @allsmog for reporting. -- Security/Feishu: escape mention regex metacharacters in `stripBotMention` so crafted mention metadata cannot trigger regex injection or ReDoS during inbound message parsing. (#20916) Thanks @allsmog for reporting. +- Security/Feishu: escape mention regex metacharacters in `stripBotMention` so crafted mention metadata cannot trigger regex injection or ReDoS during inbound message parsing. (#20916) Thanks @orlyjamie for the fix and @allsmog for reporting. - LINE/Security: harden inbound media temp-file naming by using UUID-based temp paths for downloaded media instead of external message IDs. (#20792) Thanks @mbelinky. - Security/Refactor: centralize hardened temp-file path generation for Feishu and LINE media downloads via shared `buildRandomTempFilePath` helper to reduce drift risk. (#20810) Thanks @mbelinky. - Security/Media: harden local media ingestion against TOCTOU/symlink swap attacks by pinning reads to a single file descriptor with symlink rejection and inode/device verification in `saveMediaSource`. Thanks @dorjoos for reporting. From f4b288b8f7be98faa43a899ac0b25b422e1b0cd1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 15:04:40 +0100 Subject: [PATCH 0006/1006] refactor(feishu): dedupe mention regex escaping --- .../feishu/src/bot.stripBotMention.test.ts | 38 +++++++++++++++++++ extensions/feishu/src/bot.ts | 23 +++++------ extensions/feishu/src/mention.ts | 9 ++++- 3 files changed, 58 insertions(+), 12 deletions(-) create mode 100644 extensions/feishu/src/bot.stripBotMention.test.ts diff --git a/extensions/feishu/src/bot.stripBotMention.test.ts b/extensions/feishu/src/bot.stripBotMention.test.ts new file mode 100644 index 0000000000000..98016115a1b32 --- /dev/null +++ b/extensions/feishu/src/bot.stripBotMention.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { stripBotMention, type FeishuMessageEvent } from "./bot.js"; + +type Mentions = FeishuMessageEvent["message"]["mentions"]; + +describe("stripBotMention", () => { + it("returns original text when mentions are missing", () => { + expect(stripBotMention("hello world", undefined)).toBe("hello world"); + }); + + it("strips mention name and key for normal mentions", () => { + const mentions: Mentions = [{ key: "@_bot_1", name: "Bot", id: { open_id: "ou_bot" } }]; + expect(stripBotMention("@Bot hello @_bot_1", mentions)).toBe("hello"); + }); + + it("treats mention.name regex metacharacters as literal text", () => { + const mentions: Mentions = [{ key: "@_bot_1", name: ".*", id: { open_id: "ou_bot" } }]; + expect(stripBotMention("@NotBot hello", mentions)).toBe("@NotBot hello"); + }); + + it("treats mention.key regex metacharacters as literal text", () => { + const mentions: Mentions = [{ key: ".*", name: "Bot", id: { open_id: "ou_bot" } }]; + expect(stripBotMention("hello world", mentions)).toBe("hello world"); + }); + + it("trims once after all mention replacements", () => { + const mentions: Mentions = [{ key: "@_bot_1", name: "Bot", id: { open_id: "ou_bot" } }]; + expect(stripBotMention(" @_bot_1 hello ", mentions)).toBe("hello"); + }); + + it("strips multiple mentions in one pass", () => { + const mentions: Mentions = [ + { key: "@_bot_1", name: "Bot One", id: { open_id: "ou_bot_1" } }, + { key: "@_bot_2", name: "Bot Two", id: { open_id: "ou_bot_2" } }, + ]; + expect(stripBotMention("@Bot One @_bot_1 hi @Bot Two @_bot_2", mentions)).toBe("hi"); + }); +}); diff --git a/extensions/feishu/src/bot.ts b/extensions/feishu/src/bot.ts index 8ac28430a07fe..1a534ed40c0c0 100644 --- a/extensions/feishu/src/bot.ts +++ b/extensions/feishu/src/bot.ts @@ -7,13 +7,20 @@ import { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry, } from "openclaw/plugin-sdk"; +import type { FeishuMessageContext, FeishuMediaInfo, ResolvedFeishuAccount } from "./types.js"; +import type { DynamicAgentCreationConfig } from "./types.js"; import { resolveFeishuAccount } from "./accounts.js"; import { createFeishuClient } from "./client.js"; import { tryRecordMessage } from "./dedup.js"; import { maybeCreateDynamicAgent } from "./dynamic-agent.js"; import { normalizeFeishuExternalKey } from "./external-keys.js"; import { downloadMessageResourceFeishu } from "./media.js"; -import { extractMentionTargets, extractMessageBody, isMentionForwardRequest } from "./mention.js"; +import { + escapeRegExp, + extractMentionTargets, + extractMessageBody, + isMentionForwardRequest, +} from "./mention.js"; import { resolveFeishuGroupConfig, resolveFeishuReplyPolicy, @@ -23,8 +30,6 @@ import { import { createFeishuReplyDispatcher } from "./reply-dispatcher.js"; import { getFeishuRuntime } from "./runtime.js"; import { getMessageFeishu, sendMessageFeishu } from "./send.js"; -import type { FeishuMessageContext, FeishuMediaInfo, ResolvedFeishuAccount } from "./types.js"; -import type { DynamicAgentCreationConfig } from "./types.js"; // --- Permission error extraction --- // Extract permission grant URL from Feishu API error response. @@ -199,21 +204,17 @@ function checkBotMentioned(event: FeishuMessageEvent, botOpenId?: string): boole return false; } -function escapeRegExp(s: string): string { - return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - -function stripBotMention( +export function stripBotMention( text: string, mentions?: FeishuMessageEvent["message"]["mentions"], ): string { if (!mentions || mentions.length === 0) return text; let result = text; for (const mention of mentions) { - result = result.replace(new RegExp(`@${escapeRegExp(mention.name)}\\s*`, "g"), "").trim(); - result = result.replace(new RegExp(escapeRegExp(mention.key), "g"), "").trim(); + result = result.replace(new RegExp(`@${escapeRegExp(mention.name)}\\s*`, "g"), ""); + result = result.replace(new RegExp(escapeRegExp(mention.key), "g"), ""); } - return result; + return result.trim(); } /** diff --git a/extensions/feishu/src/mention.ts b/extensions/feishu/src/mention.ts index 1b7acb85d167c..50c6fae5ed26b 100644 --- a/extensions/feishu/src/mention.ts +++ b/extensions/feishu/src/mention.ts @@ -1,5 +1,12 @@ import type { FeishuMessageEvent } from "./bot.js"; +/** + * Escape regex metacharacters so user-controlled mention fields are treated literally. + */ +export function escapeRegExp(input: string): string { + return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + /** * Mention target user info */ @@ -67,7 +74,7 @@ export function extractMessageBody(text: string, allMentionKeys: string[]): stri // Remove all @ placeholders for (const key of allMentionKeys) { - result = result.replace(new RegExp(key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g"), ""); + result = result.replace(new RegExp(escapeRegExp(key), "g"), ""); } return result.replace(/\s+/g, " ").trim(); From 2777d8ad937d0f62afa26ca648bcc51d56529acc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 15:06:28 +0100 Subject: [PATCH 0007/1006] refactor(security): unify gateway scope authorization flows --- CHANGELOG.md | 1 + .../bash-tools.exec.approval-id.e2e.test.ts | 1 + src/agents/openclaw-gateway-tool.e2e.test.ts | 1 + src/agents/tool-policy.ts | 4 +- src/agents/tools/common.ts | 8 +++ src/agents/tools/cron-tool.ts | 16 ++--- src/agents/tools/gateway-tool.ts | 27 ++------ src/gateway/call.test.ts | 19 ++++-- src/gateway/call.ts | 67 ++++++++++++++++--- src/gateway/method-scopes.test.ts | 50 ++++++++++++++ src/gateway/method-scopes.ts | 48 +++++++++++-- src/gateway/server-methods.ts | 41 ++---------- src/infra/outbound/message.e2e.test.ts | 1 + src/infra/outbound/message.ts | 4 +- 14 files changed, 202 insertions(+), 86 deletions(-) create mode 100644 src/gateway/method-scopes.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d8f2c87af756..be92e53993ba7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,6 +76,7 @@ Docs: https://docs.openclaw.ai - Security/Exec: for the next npm release, harden safe-bin stdin-only enforcement by blocking output/recursive flags (`sort -o/--output`, grep recursion) and tightening default safe bins to remove `sort`/`grep`, preventing safe-bin allowlist bypass for file writes/recursive reads. Thanks @nedlir for reporting. - Cron/Webhooks: protect cron webhook POST delivery with SSRF-guarded outbound fetch (`fetchWithSsrFGuard`) to block private/metadata destinations before request dispatch. Thanks @Adam55A-code. - Security/Gateway/Agents: remove implicit admin scopes from agent tool gateway calls by classifying methods to least-privilege operator scopes, and restrict `cron`/`gateway` tools to owner senders (with explicit runtime owner checks) to prevent non-owner DM privilege escalation. Ships in the next npm release. Thanks @Adam55A-code for reporting. +- Security/Gateway: centralize gateway method-scope authorization and default non-CLI gateway callers to least-privilege method scopes, with explicit CLI scope handling and regression coverage to prevent scope drift. - Security/Net: block SSRF bypass via NAT64 (`64:ff9b::/96`, `64:ff9b:1::/48`), 6to4 (`2002::/16`), and Teredo (`2001:0000::/32`) IPv6 transition addresses, and fail closed on IPv6 parse errors. Thanks @jackhax. ## 2026.2.17 diff --git a/src/agents/bash-tools.exec.approval-id.e2e.test.ts b/src/agents/bash-tools.exec.approval-id.e2e.test.ts index 527e45fa5e1eb..3d90797b22a1e 100644 --- a/src/agents/bash-tools.exec.approval-id.e2e.test.ts +++ b/src/agents/bash-tools.exec.approval-id.e2e.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("./tools/gateway.js", () => ({ callGatewayTool: vi.fn(), + readGatewayCallOptions: vi.fn(() => ({})), })); vi.mock("./tools/nodes-utils.js", () => ({ diff --git a/src/agents/openclaw-gateway-tool.e2e.test.ts b/src/agents/openclaw-gateway-tool.e2e.test.ts index bf800e4b1fd2c..04137f7dc1a06 100644 --- a/src/agents/openclaw-gateway-tool.e2e.test.ts +++ b/src/agents/openclaw-gateway-tool.e2e.test.ts @@ -13,6 +13,7 @@ vi.mock("./tools/gateway.js", () => ({ } return { ok: true }; }), + readGatewayCallOptions: vi.fn(() => ({})), })); describe("gateway tool", () => { diff --git a/src/agents/tool-policy.ts b/src/agents/tool-policy.ts index ab5faf3a49184..0b3edfbb3997f 100644 --- a/src/agents/tool-policy.ts +++ b/src/agents/tool-policy.ts @@ -1,4 +1,4 @@ -import type { AnyAgentTool } from "./tools/common.js"; +import { OWNER_ONLY_TOOL_ERROR, type AnyAgentTool } from "./tools/common.js"; export type ToolProfileId = "minimal" | "coding" | "messaging" | "full"; @@ -101,7 +101,7 @@ export function applyOwnerOnlyToolPolicy(tools: AnyAgentTool[], senderIsOwner: b return { ...tool, execute: async () => { - throw new Error("Tool restricted to owner senders."); + throw new Error(OWNER_ONLY_TOOL_ERROR); }, }; }); diff --git a/src/agents/tools/common.ts b/src/agents/tools/common.ts index bca56ceada300..27f22be1d05eb 100644 --- a/src/agents/tools/common.ts +++ b/src/agents/tools/common.ts @@ -19,6 +19,8 @@ export type ActionGate> = ( defaultValue?: boolean, ) => boolean; +export const OWNER_ONLY_TOOL_ERROR = "Tool restricted to owner senders."; + export class ToolInputError extends Error { readonly status = 400; @@ -208,6 +210,12 @@ export function jsonResult(payload: unknown): AgentToolResult { }; } +export function assertOwnerSender(senderIsOwner?: boolean): void { + if (senderIsOwner === false) { + throw new Error(OWNER_ONLY_TOOL_ERROR); + } +} + export async function imageResult(params: { label: string; path: string; diff --git a/src/agents/tools/cron-tool.ts b/src/agents/tools/cron-tool.ts index b692fdd79118a..8a5b51519fa3a 100644 --- a/src/agents/tools/cron-tool.ts +++ b/src/agents/tools/cron-tool.ts @@ -8,8 +8,8 @@ import { extractTextFromChatContent } from "../../shared/chat-content.js"; import { isRecord, truncateUtf16Safe } from "../../utils.js"; import { resolveSessionAgentId } from "../agent-scope.js"; import { optionalStringEnum, stringEnum } from "../schema/typebox.js"; -import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js"; -import { callGatewayTool, type GatewayCallOptions } from "./gateway.js"; +import { assertOwnerSender, type AnyAgentTool, jsonResult, readStringParam } from "./common.js"; +import { callGatewayTool, readGatewayCallOptions, type GatewayCallOptions } from "./gateway.js"; import { resolveInternalSessionKey, resolveMainSessionAlias } from "./sessions-helpers.js"; // NOTE: We use Type.Object({}, { additionalProperties: true }) for job/patch @@ -260,15 +260,15 @@ WAKE MODES (for wake action): Use jobId as the canonical identifier; id is accepted for compatibility. Use contextMessages (0-10) to add previous messages as context to the job text.`, parameters: CronToolSchema, execute: async (_toolCallId, args) => { - if (opts?.senderIsOwner === false) { - throw new Error("Tool restricted to owner senders."); - } + assertOwnerSender(opts?.senderIsOwner); const params = args as Record; const action = readStringParam(params, "action", { required: true }); const gatewayOpts: GatewayCallOptions = { - gatewayUrl: readStringParam(params, "gatewayUrl", { trim: false }), - gatewayToken: readStringParam(params, "gatewayToken", { trim: false }), - timeoutMs: typeof params.timeoutMs === "number" ? params.timeoutMs : 60_000, + ...readGatewayCallOptions(params), + timeoutMs: + typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs) + ? params.timeoutMs + : 60_000, }; switch (action) { diff --git a/src/agents/tools/gateway-tool.ts b/src/agents/tools/gateway-tool.ts index 1e2f2cf7f4afc..ee360082455ea 100644 --- a/src/agents/tools/gateway-tool.ts +++ b/src/agents/tools/gateway-tool.ts @@ -10,8 +10,8 @@ import { } from "../../infra/restart-sentinel.js"; import { scheduleGatewaySigusr1Restart } from "../../infra/restart.js"; import { stringEnum } from "../schema/typebox.js"; -import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js"; -import { callGatewayTool } from "./gateway.js"; +import { assertOwnerSender, type AnyAgentTool, jsonResult, readStringParam } from "./common.js"; +import { callGatewayTool, readGatewayCallOptions } from "./gateway.js"; const DEFAULT_UPDATE_TIMEOUT_MS = 20 * 60_000; @@ -74,9 +74,7 @@ export function createGatewayTool(opts?: { "Restart, apply config, or update the gateway in-place (SIGUSR1). Use config.patch for safe partial config updates (merges with existing). Use config.apply only when replacing entire config. Both trigger restart after writing. Always pass a human-readable completion message via the `note` parameter so the system can deliver it to the user after restart.", parameters: GatewayToolSchema, execute: async (_toolCallId, args) => { - if (opts?.senderIsOwner === false) { - throw new Error("Tool restricted to owner senders."); - } + assertOwnerSender(opts?.senderIsOwner); const params = args as Record; const action = readStringParam(params, "action", { required: true }); if (action === "restart") { @@ -129,19 +127,7 @@ export function createGatewayTool(opts?: { return jsonResult(scheduled); } - const gatewayUrl = - typeof params.gatewayUrl === "string" && params.gatewayUrl.trim() - ? params.gatewayUrl.trim() - : undefined; - const gatewayToken = - typeof params.gatewayToken === "string" && params.gatewayToken.trim() - ? params.gatewayToken.trim() - : undefined; - const timeoutMs = - typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs) - ? Math.max(1, Math.floor(params.timeoutMs)) - : undefined; - const gatewayOpts = { gatewayUrl, gatewayToken, timeoutMs }; + const gatewayOpts = readGatewayCallOptions(params); const resolveGatewayWriteMeta = (): { sessionKey: string | undefined; @@ -214,15 +200,16 @@ export function createGatewayTool(opts?: { } if (action === "update.run") { const { sessionKey, note, restartDelayMs } = resolveGatewayWriteMeta(); + const updateTimeoutMs = gatewayOpts.timeoutMs ?? DEFAULT_UPDATE_TIMEOUT_MS; const updateGatewayOpts = { ...gatewayOpts, - timeoutMs: timeoutMs ?? DEFAULT_UPDATE_TIMEOUT_MS, + timeoutMs: updateTimeoutMs, }; const result = await callGatewayTool("update.run", updateGatewayOpts, { sessionKey, note, restartDelayMs, - timeoutMs: timeoutMs ?? DEFAULT_UPDATE_TIMEOUT_MS, + timeoutMs: updateTimeoutMs, }); return jsonResult({ ok: true, result }); } diff --git a/src/gateway/call.test.ts b/src/gateway/call.test.ts index 554c5f9b312a8..0435ed705d315 100644 --- a/src/gateway/call.test.ts +++ b/src/gateway/call.test.ts @@ -75,7 +75,8 @@ vi.mock("./client.js", () => ({ }, })); -const { buildGatewayConnectionDetails, callGateway } = await import("./call.js"); +const { buildGatewayConnectionDetails, callGateway, callGatewayCli, callGatewayScoped } = + await import("./call.js"); function resetGatewayCallMocks() { loadConfig.mockReset(); @@ -198,13 +199,23 @@ describe("callGateway url resolution", () => { expect(lastClientOptions?.token).toBe("explicit-token"); }); - it("keeps legacy admin scopes when call scopes are omitted", async () => { + it("uses least-privilege scopes by default for non-CLI callers", async () => { loadConfig.mockReturnValue({ gateway: { mode: "local", bind: "loopback" } }); resolveGatewayPort.mockReturnValue(18789); pickPrimaryTailnetIPv4.mockReturnValue(undefined); await callGateway({ method: "health" }); + expect(lastClientOptions?.scopes).toEqual(["operator.read"]); + }); + + it("keeps legacy admin scopes for explicit CLI callers", async () => { + loadConfig.mockReturnValue({ gateway: { mode: "local", bind: "loopback" } }); + resolveGatewayPort.mockReturnValue(18789); + pickPrimaryTailnetIPv4.mockReturnValue(undefined); + + await callGatewayCli({ method: "health" }); + expect(lastClientOptions?.scopes).toEqual([ "operator.admin", "operator.approvals", @@ -217,10 +228,10 @@ describe("callGateway url resolution", () => { resolveGatewayPort.mockReturnValue(18789); pickPrimaryTailnetIPv4.mockReturnValue(undefined); - await callGateway({ method: "health", scopes: ["operator.read"] }); + await callGatewayScoped({ method: "health", scopes: ["operator.read"] }); expect(lastClientOptions?.scopes).toEqual(["operator.read"]); - await callGateway({ method: "health", scopes: [] }); + await callGatewayScoped({ method: "health", scopes: [] }); expect(lastClientOptions?.scopes).toEqual([]); }); }); diff --git a/src/gateway/call.ts b/src/gateway/call.ts index 85660662861c1..09845b7e1cf3d 100644 --- a/src/gateway/call.ts +++ b/src/gateway/call.ts @@ -16,11 +16,15 @@ import { type GatewayClientName, } from "../utils/message-channel.js"; import { GatewayClient } from "./client.js"; -import type { OperatorScope } from "./method-scopes.js"; +import { + CLI_DEFAULT_OPERATOR_SCOPES, + resolveLeastPrivilegeOperatorScopesForMethod, + type OperatorScope, +} from "./method-scopes.js"; import { isSecureWebSocketUrl, pickPrimaryLanIPv4 } from "./net.js"; import { PROTOCOL_VERSION } from "./protocol/index.js"; -export type CallGatewayOptions = { +type CallGatewayBaseOptions = { url?: string; token?: string; password?: string; @@ -38,7 +42,6 @@ export type CallGatewayOptions = { instanceId?: string; minProtocol?: number; maxProtocol?: number; - scopes?: OperatorScope[]; /** * Overrides the config path shown in connection error details. * Does not affect config loading; callers still control auth via opts.token/password/env/config. @@ -46,6 +49,18 @@ export type CallGatewayOptions = { configPath?: string; }; +export type CallGatewayScopedOptions = CallGatewayBaseOptions & { + scopes: OperatorScope[]; +}; + +export type CallGatewayCliOptions = CallGatewayBaseOptions & { + scopes?: OperatorScope[]; +}; + +export type CallGatewayOptions = CallGatewayBaseOptions & { + scopes?: OperatorScope[]; +}; + export type GatewayConnectionDetails = { url: string; urlSource: string; @@ -171,8 +186,9 @@ export function buildGatewayConnectionDetails( }; } -export async function callGateway>( - opts: CallGatewayOptions, +async function callGatewayWithScopes>( + opts: CallGatewayBaseOptions, + scopes: OperatorScope[], ): Promise { const timeoutMs = typeof opts.timeoutMs === "number" && Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : 10_000; @@ -259,9 +275,6 @@ export async function callGateway>( }; const formatTimeoutError = () => `gateway timeout after ${timeoutMs}ms\n${connectionDetails.message}`; - const scopes = Array.isArray(opts.scopes) - ? opts.scopes - : ["operator.admin", "operator.approvals", "operator.pairing"]; return await new Promise((resolve, reject) => { let settled = false; let ignoreClose = false; @@ -328,6 +341,44 @@ export async function callGateway>( }); } +export async function callGatewayScoped>( + opts: CallGatewayScopedOptions, +): Promise { + return await callGatewayWithScopes(opts, opts.scopes); +} + +export async function callGatewayCli>( + opts: CallGatewayCliOptions, +): Promise { + const scopes = Array.isArray(opts.scopes) ? opts.scopes : CLI_DEFAULT_OPERATOR_SCOPES; + return await callGatewayWithScopes(opts, scopes); +} + +export async function callGatewayLeastPrivilege>( + opts: CallGatewayBaseOptions, +): Promise { + const scopes = resolveLeastPrivilegeOperatorScopesForMethod(opts.method); + return await callGatewayWithScopes(opts, scopes); +} + +export async function callGateway>( + opts: CallGatewayOptions, +): Promise { + if (Array.isArray(opts.scopes)) { + return await callGatewayWithScopes(opts, opts.scopes); + } + const callerMode = opts.mode ?? GATEWAY_CLIENT_MODES.BACKEND; + const callerName = opts.clientName ?? GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT; + if (callerMode === GATEWAY_CLIENT_MODES.CLI || callerName === GATEWAY_CLIENT_NAMES.CLI) { + return await callGatewayCli(opts); + } + return await callGatewayLeastPrivilege({ + ...opts, + mode: callerMode, + clientName: callerName, + }); +} + export function randomIdempotencyKey() { return randomUUID(); } diff --git a/src/gateway/method-scopes.test.ts b/src/gateway/method-scopes.test.ts new file mode 100644 index 0000000000000..61a80bfeaae94 --- /dev/null +++ b/src/gateway/method-scopes.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { + authorizeOperatorScopesForMethod, + resolveLeastPrivilegeOperatorScopesForMethod, +} from "./method-scopes.js"; + +describe("method scope resolution", () => { + it("classifies sessions.resolve as read and poll as write", () => { + expect(resolveLeastPrivilegeOperatorScopesForMethod("sessions.resolve")).toEqual([ + "operator.read", + ]); + expect(resolveLeastPrivilegeOperatorScopesForMethod("poll")).toEqual(["operator.write"]); + }); + + it("returns empty scopes for unknown methods", () => { + expect(resolveLeastPrivilegeOperatorScopesForMethod("totally.unknown.method")).toEqual([]); + }); +}); + +describe("operator scope authorization", () => { + it("allows read methods with operator.read or operator.write", () => { + expect(authorizeOperatorScopesForMethod("health", ["operator.read"])).toEqual({ + allowed: true, + }); + expect(authorizeOperatorScopesForMethod("health", ["operator.write"])).toEqual({ + allowed: true, + }); + }); + + it("requires operator.write for write methods", () => { + expect(authorizeOperatorScopesForMethod("send", ["operator.read"])).toEqual({ + allowed: false, + missingScope: "operator.write", + }); + }); + + it("requires approvals scope for approval methods", () => { + expect(authorizeOperatorScopesForMethod("exec.approval.resolve", ["operator.write"])).toEqual({ + allowed: false, + missingScope: "operator.approvals", + }); + }); + + it("requires admin for unknown methods", () => { + expect(authorizeOperatorScopesForMethod("unknown.method", ["operator.read"])).toEqual({ + allowed: false, + missingScope: "operator.admin", + }); + }); +}); diff --git a/src/gateway/method-scopes.ts b/src/gateway/method-scopes.ts index 699f9691e2ed7..c270a4495ab5e 100644 --- a/src/gateway/method-scopes.ts +++ b/src/gateway/method-scopes.ts @@ -11,6 +11,12 @@ export type OperatorScope = | typeof APPROVALS_SCOPE | typeof PAIRING_SCOPE; +export const CLI_DEFAULT_OPERATOR_SCOPES: OperatorScope[] = [ + ADMIN_SCOPE, + APPROVALS_SCOPE, + PAIRING_SCOPE, +]; + const APPROVAL_METHODS = new Set([ "exec.approval.request", "exec.approval.waitDecision", @@ -52,6 +58,7 @@ const READ_METHODS = new Set([ "voicewake.get", "sessions.list", "sessions.preview", + "sessions.resolve", "cron.list", "cron.status", "cron.runs", @@ -66,6 +73,7 @@ const READ_METHODS = new Set([ const WRITE_METHODS = new Set([ "send", + "poll", "agent", "agent.wait", "wake", @@ -133,22 +141,50 @@ export function isAdminOnlyMethod(method: string): boolean { return ADMIN_METHODS.has(method); } -export function resolveLeastPrivilegeOperatorScopesForMethod(method: string): OperatorScope[] { +export function resolveRequiredOperatorScopeForMethod(method: string): OperatorScope | undefined { if (isApprovalMethod(method)) { - return [APPROVALS_SCOPE]; + return APPROVALS_SCOPE; } if (isPairingMethod(method)) { - return [PAIRING_SCOPE]; + return PAIRING_SCOPE; } if (isReadMethod(method)) { - return [READ_SCOPE]; + return READ_SCOPE; } if (isWriteMethod(method)) { - return [WRITE_SCOPE]; + return WRITE_SCOPE; } if (isAdminOnlyMethod(method)) { - return [ADMIN_SCOPE]; + return ADMIN_SCOPE; + } + return undefined; +} + +export function resolveLeastPrivilegeOperatorScopesForMethod(method: string): OperatorScope[] { + const requiredScope = resolveRequiredOperatorScopeForMethod(method); + if (requiredScope) { + return [requiredScope]; } // Default-deny for unclassified methods. return []; } + +export function authorizeOperatorScopesForMethod( + method: string, + scopes: readonly string[], +): { allowed: true } | { allowed: false; missingScope: OperatorScope } { + if (scopes.includes(ADMIN_SCOPE)) { + return { allowed: true }; + } + const requiredScope = resolveRequiredOperatorScopeForMethod(method) ?? ADMIN_SCOPE; + if (requiredScope === READ_SCOPE) { + if (scopes.includes(READ_SCOPE) || scopes.includes(WRITE_SCOPE)) { + return { allowed: true }; + } + return { allowed: false, missingScope: READ_SCOPE }; + } + if (scopes.includes(requiredScope)) { + return { allowed: true }; + } + return { allowed: false, missingScope: requiredScope }; +} diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index c772bc1c36749..0ac6ba1ece276 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -2,16 +2,8 @@ import { formatControlPlaneActor, resolveControlPlaneActor } from "./control-pla import { consumeControlPlaneWriteBudget } from "./control-plane-rate-limit.js"; import { ADMIN_SCOPE, - APPROVALS_SCOPE, - isAdminOnlyMethod, - isApprovalMethod, + authorizeOperatorScopesForMethod, isNodeRoleMethod, - isPairingMethod, - isReadMethod, - isWriteMethod, - PAIRING_SCOPE, - READ_SCOPE, - WRITE_SCOPE, } from "./method-scopes.js"; import { ErrorCodes, errorShape } from "./protocol/index.js"; import { agentHandlers } from "./server-methods/agent.js"; @@ -64,34 +56,11 @@ function authorizeGatewayMethod(method: string, client: GatewayRequestOptions["c if (scopes.includes(ADMIN_SCOPE)) { return null; } - if (isApprovalMethod(method) && !scopes.includes(APPROVALS_SCOPE)) { - return errorShape(ErrorCodes.INVALID_REQUEST, "missing scope: operator.approvals"); + const scopeAuth = authorizeOperatorScopesForMethod(method, scopes); + if (!scopeAuth.allowed) { + return errorShape(ErrorCodes.INVALID_REQUEST, `missing scope: ${scopeAuth.missingScope}`); } - if (isPairingMethod(method) && !scopes.includes(PAIRING_SCOPE)) { - return errorShape(ErrorCodes.INVALID_REQUEST, "missing scope: operator.pairing"); - } - if (isReadMethod(method) && !(scopes.includes(READ_SCOPE) || scopes.includes(WRITE_SCOPE))) { - return errorShape(ErrorCodes.INVALID_REQUEST, "missing scope: operator.read"); - } - if (isWriteMethod(method) && !scopes.includes(WRITE_SCOPE)) { - return errorShape(ErrorCodes.INVALID_REQUEST, "missing scope: operator.write"); - } - if (isApprovalMethod(method)) { - return null; - } - if (isPairingMethod(method)) { - return null; - } - if (isReadMethod(method)) { - return null; - } - if (isWriteMethod(method)) { - return null; - } - if (isAdminOnlyMethod(method)) { - return errorShape(ErrorCodes.INVALID_REQUEST, "missing scope: operator.admin"); - } - return errorShape(ErrorCodes.INVALID_REQUEST, "missing scope: operator.admin"); + return null; } export const coreGatewayHandlers: GatewayRequestHandlers = { diff --git a/src/infra/outbound/message.e2e.test.ts b/src/infra/outbound/message.e2e.test.ts index cd2212928ac56..fda22fc19e916 100644 --- a/src/infra/outbound/message.e2e.test.ts +++ b/src/infra/outbound/message.e2e.test.ts @@ -13,6 +13,7 @@ const setRegistry = (registry: ReturnType) => { const callGatewayMock = vi.fn(); vi.mock("../../gateway/call.js", () => ({ callGateway: (...args: unknown[]) => callGatewayMock(...args), + callGatewayLeastPrivilege: (...args: unknown[]) => callGatewayMock(...args), randomIdempotencyKey: () => "idem-1", })); diff --git a/src/infra/outbound/message.ts b/src/infra/outbound/message.ts index e7e0a0a93c0ca..71b36eca6b19c 100644 --- a/src/infra/outbound/message.ts +++ b/src/infra/outbound/message.ts @@ -1,7 +1,7 @@ import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js"; import type { OpenClawConfig } from "../../config/config.js"; import { loadConfig } from "../../config/config.js"; -import { callGateway, randomIdempotencyKey } from "../../gateway/call.js"; +import { callGatewayLeastPrivilege, randomIdempotencyKey } from "../../gateway/call.js"; import type { PollInput } from "../../polls.js"; import { normalizePollInput } from "../../polls.js"; import { @@ -151,7 +151,7 @@ async function callMessageGateway(params: { params: Record; }): Promise { const gateway = resolveGatewayOptions(params.gateway); - return await callGateway({ + return await callGatewayLeastPrivilege({ url: gateway.url, token: gateway.token, method: params.method, From 5dc50b8a3f80089b41525bd43e878da1ab8c4cfb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 15:10:57 +0100 Subject: [PATCH 0008/1006] fix(security): harden npm plugin and hook install integrity flow --- CHANGELOG.md | 1 + docs/cli/hooks.md | 6 + docs/cli/plugins.md | 8 + docs/cli/security.md | 1 + docs/tools/plugin.md | 2 + src/cli/hooks-cli.ts | 58 ++- src/cli/plugins-cli.ts | 39 +- src/commands/onboarding/plugin-install.ts | 12 +- src/config/schema.help.ts | 11 + src/config/schema.labels.ts | 6 + src/config/types.hooks.ts | 6 + src/config/types.plugins.ts | 6 + src/config/zod-schema.installs.ts | 6 + src/hooks/install.test.ts | 53 ++- src/hooks/install.ts | 61 +++- src/infra/install-source-utils.test.ts | 45 ++- src/infra/install-source-utils.ts | 143 +++++++- src/plugins/install.e2e.test.ts | 60 ++- src/plugins/install.ts | 61 +++- src/plugins/update.ts | 61 ++++ src/security/audit-extra.async.ts | 421 +++++++++++++++------- src/security/audit.test.ts | 139 ++++++- src/test-utils/exec-assertions.ts | 2 +- 23 files changed, 1036 insertions(+), 172 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index be92e53993ba7..acf0e9083314d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Security/ACP: harden ACP bridge session management with duplicate-session refresh, idle-session reaping, oldest-idle soft-cap eviction, and burst rate limiting on session creation to reduce local DoS risk without disrupting normal IDE usage. +- Security/Plugins/Hooks: add optional `--pin` for npm plugin/hook installs, persist resolved npm metadata (`name`, `version`, `spec`, integrity, shasum, timestamp), warn/confirm on integrity drift during updates, and extend `openclaw security audit` to flag unpinned specs, missing integrity metadata, and install-record version drift. - Security/Gateway: rate-limit control-plane write RPCs (`config.apply`, `config.patch`, `update.run`) to 3 requests per minute per `deviceId+clientIp`, add restart single-flight coalescing plus a 30-second restart cooldown, and log actor/device/ip with changed-path audit details for config/update-triggered restarts. - Commands/Doctor: skip embedding-provider warnings when `memory.backend` is `qmd`, because QMD manages embeddings internally and does not require `memorySearch` providers. (#17263) Thanks @miloudbelarebia. - Security/Webhooks: harden Feishu and Zalo webhook ingress with webhook-mode token preconditions, loopback-default Feishu bind host, JSON content-type enforcement, per-path rate limiting, replay dedupe for Zalo events, constant-time Zalo secret comparison, and anomaly status counters. diff --git a/docs/cli/hooks.md b/docs/cli/hooks.md index a676a709acb98..6dadb26970ed9 100644 --- a/docs/cli/hooks.md +++ b/docs/cli/hooks.md @@ -188,6 +188,7 @@ openclaw hooks disable command-logger ```bash openclaw hooks install +openclaw hooks install --pin ``` Install a hook pack from a local folder/archive or npm. @@ -204,6 +205,7 @@ specs are rejected. Dependency installs run with `--ignore-scripts` for safety. **Options:** - `-l, --link`: Link a local directory instead of copying (adds it to `hooks.internal.load.extraDirs`) +- `--pin`: Record npm installs as exact resolved `name@version` in `hooks.internal.installs` **Supported archives:** `.zip`, `.tgz`, `.tar.gz`, `.tar` @@ -237,6 +239,10 @@ Update installed hook packs (npm installs only). - `--all`: Update all tracked hook packs - `--dry-run`: Show what would change without writing +When a stored integrity hash exists and the fetched artifact hash changes, +OpenClaw prints a warning and asks for confirmation before proceeding. Use +global `--yes` to bypass prompts in CI/non-interactive runs. + ## Bundled Hooks ### session-memory diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index cc7eeb18f97ff..6f3cb103cfd9a 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -40,6 +40,7 @@ the plugin from loading and fail config validation. ```bash openclaw plugins install +openclaw plugins install --pin ``` Security note: treat plugin installs like running code. Prefer pinned versions. @@ -55,6 +56,9 @@ Use `--link` to avoid copying a local directory (adds to `plugins.load.paths`): openclaw plugins install -l ./my-plugin ``` +Use `--pin` on npm installs to save the resolved exact spec (`name@version`) in +`plugins.installs` while keeping the default behavior unpinned. + ### Uninstall ```bash @@ -82,3 +86,7 @@ openclaw plugins update --dry-run ``` Updates only apply to plugins installed from npm (tracked in `plugins.installs`). + +When a stored integrity hash exists and the fetched artifact hash changes, +OpenClaw prints a warning and asks for confirmation before proceeding. Use +global `--yes` to bypass prompts in CI/non-interactive runs. diff --git a/docs/cli/security.md b/docs/cli/security.md index aed7d3c190482..fee202300662a 100644 --- a/docs/cli/security.md +++ b/docs/cli/security.md @@ -27,6 +27,7 @@ The audit warns when multiple DM senders share the main session and recommends * It also warns when small models (`<=300B`) are used without sandboxing and with web/browser tools enabled. For webhook ingress, it warns when `hooks.defaultSessionKey` is unset, when request `sessionKey` overrides are enabled, and when overrides are enabled without `hooks.allowedSessionKeyPrefixes`. It also warns when sandbox Docker settings are configured while sandbox mode is off, when `gateway.nodes.denyCommands` uses ineffective pattern-like/unknown entries, when global `tools.profile="minimal"` is overridden by agent tool profiles, and when installed extension plugin tools may be reachable under permissive tool policy. +It also warns when npm-based plugin/hook install records are unpinned, missing integrity metadata, or drift from currently installed package versions. It warns when `gateway.auth.mode="none"` leaves Gateway HTTP APIs reachable without a shared secret (`/tools/invoke` plus any enabled `/v1/*` endpoint). ## JSON output diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 2135ce8e885ff..ab031c389b754 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -295,6 +295,7 @@ openclaw plugins install ./plugin.tgz # install from a local tarball openclaw plugins install ./plugin.zip # install from a local zip openclaw plugins install -l ./extensions/voice-call # link (no copy) for dev openclaw plugins install @openclaw/voice-call # install from npm +openclaw plugins install @openclaw/voice-call --pin # store exact resolved name@version openclaw plugins update openclaw plugins update --all openclaw plugins enable @@ -303,6 +304,7 @@ openclaw plugins doctor ``` `plugins update` only works for npm installs tracked under `plugins.installs`. +If stored integrity metadata changes between updates, OpenClaw warns and asks for confirmation (use global `--yes` to bypass prompts). Plugins may also register their own top‑level commands (example: `openclaw voicecall`). diff --git a/src/cli/hooks-cli.ts b/src/cli/hooks-cli.ts index ffa7383c5e873..dfa578cc9bde6 100644 --- a/src/cli/hooks-cli.ts +++ b/src/cli/hooks-cli.ts @@ -1,9 +1,10 @@ +import type { Command } from "commander"; import fs from "node:fs"; import fsp from "node:fs/promises"; import path from "node:path"; -import type { Command } from "commander"; -import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; import type { OpenClawConfig } from "../config/config.js"; +import type { HookEntry } from "../hooks/types.js"; +import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; import { loadConfig, writeConfigFile } from "../config/io.js"; import { buildWorkspaceHookStatus, @@ -16,7 +17,6 @@ import { resolveHookInstallDir, } from "../hooks/install.js"; import { recordHookInstall } from "../hooks/installs.js"; -import type { HookEntry } from "../hooks/types.js"; import { loadWorkspaceHookEntries } from "../hooks/workspace.js"; import { resolveArchiveKind } from "../infra/archive.js"; import { buildPluginStatusReport } from "../plugins/status.js"; @@ -26,6 +26,7 @@ import { renderTable } from "../terminal/table.js"; import { theme } from "../terminal/theme.js"; import { resolveUserPath, shortenHomePath } from "../utils.js"; import { formatCliCommand } from "./command-format.js"; +import { promptYesNo } from "./prompt.js"; export type HooksListOptions = { json?: boolean; @@ -550,7 +551,8 @@ export function registerHooksCli(program: Command): void { .description("Install a hook pack (path, archive, or npm spec)") .argument("", "Path to a hook pack or npm package spec") .option("-l, --link", "Link a local path instead of copying", false) - .action(async (raw: string, opts: { link?: boolean }) => { + .option("--pin", "Record npm installs as exact resolved @", false) + .action(async (raw: string, opts: { link?: boolean; pin?: boolean }) => { const resolved = resolveUserPath(raw); const cfg = loadConfig(); @@ -658,13 +660,29 @@ export function registerHooksCli(program: Command): void { } let next = enableInternalHookEntries(cfg, result.hooks); + const resolvedSpec = result.npmResolution?.resolvedSpec; + const recordSpec = opts.pin && resolvedSpec ? resolvedSpec : raw; + if (opts.pin && !resolvedSpec) { + defaultRuntime.log( + theme.warn("Could not resolve exact npm version for --pin; storing original npm spec."), + ); + } + if (opts.pin && resolvedSpec) { + defaultRuntime.log(`Pinned npm install record to ${resolvedSpec}.`); + } next = recordHookInstall(next, { hookId: result.hookPackId, source: "npm", - spec: raw, + spec: recordSpec, installPath: result.targetDir, version: result.version, + resolvedName: result.npmResolution?.name, + resolvedVersion: result.npmResolution?.version, + resolvedSpec: result.npmResolution?.resolvedSpec, + integrity: result.npmResolution?.integrity, + shasum: result.npmResolution?.shasum, + resolvedAt: result.npmResolution?.resolvedAt, hooks: result.hooks, }); await writeConfigFile(next); @@ -721,6 +739,18 @@ export function registerHooksCli(program: Command): void { mode: "update", dryRun: true, expectedHookPackId: hookId, + expectedIntegrity: record.integrity, + onIntegrityDrift: async (drift) => { + const specLabel = drift.resolution.resolvedSpec ?? drift.spec; + defaultRuntime.log( + theme.warn( + `Integrity drift detected for "${hookId}" (${specLabel})` + + `\nExpected: ${drift.expectedIntegrity}` + + `\nActual: ${drift.actualIntegrity}`, + ), + ); + return true; + }, logger: createInstallLogger(), }); if (!probe.ok) { @@ -742,6 +772,18 @@ export function registerHooksCli(program: Command): void { spec: record.spec, mode: "update", expectedHookPackId: hookId, + expectedIntegrity: record.integrity, + onIntegrityDrift: async (drift) => { + const specLabel = drift.resolution.resolvedSpec ?? drift.spec; + defaultRuntime.log( + theme.warn( + `Integrity drift detected for "${hookId}" (${specLabel})` + + `\nExpected: ${drift.expectedIntegrity}` + + `\nActual: ${drift.actualIntegrity}`, + ), + ); + return await promptYesNo(`Continue updating "${hookId}" with this artifact?`); + }, logger: createInstallLogger(), }); if (!result.ok) { @@ -756,6 +798,12 @@ export function registerHooksCli(program: Command): void { spec: record.spec, installPath: result.targetDir, version: nextVersion, + resolvedName: result.npmResolution?.name, + resolvedVersion: result.npmResolution?.version, + resolvedSpec: result.npmResolution?.resolvedSpec, + integrity: result.npmResolution?.integrity, + shasum: result.npmResolution?.shasum, + resolvedAt: result.npmResolution?.resolvedAt, hooks: result.hooks, }); updatedCount += 1; diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index bca81b40af51b..dba4b58b82488 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -1,15 +1,15 @@ +import type { Command } from "commander"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import type { Command } from "commander"; import type { OpenClawConfig } from "../config/config.js"; +import type { PluginRecord } from "../plugins/registry.js"; import { loadConfig, writeConfigFile } from "../config/config.js"; import { resolveStateDir } from "../config/paths.js"; import { resolveArchiveKind } from "../infra/archive.js"; import { installPluginFromNpmSpec, installPluginFromPath } from "../plugins/install.js"; import { recordPluginInstall } from "../plugins/installs.js"; import { clearPluginManifestRegistryCache } from "../plugins/manifest-registry.js"; -import type { PluginRecord } from "../plugins/registry.js"; import { applyExclusiveSlotSelection } from "../plugins/slots.js"; import { resolvePluginSourceRoots, formatPluginSourceForTable } from "../plugins/source-display.js"; import { buildPluginStatusReport } from "../plugins/status.js"; @@ -535,7 +535,8 @@ export function registerPluginsCli(program: Command) { .description("Install a plugin (path, archive, or npm spec)") .argument("", "Path (.ts/.js/.zip/.tgz/.tar.gz) or an npm package spec") .option("-l, --link", "Link a local path instead of copying", false) - .action(async (raw: string, opts: { link?: boolean }) => { + .option("--pin", "Record npm installs as exact resolved @", false) + .action(async (raw: string, opts: { link?: boolean; pin?: boolean }) => { const fileSpec = resolveFileNpmSpecToLocalPath(raw); if (fileSpec && !fileSpec.ok) { defaultRuntime.error(fileSpec.error); @@ -648,12 +649,28 @@ export function registerPluginsCli(program: Command) { clearPluginManifestRegistryCache(); let next = enablePluginInConfig(cfg, result.pluginId); + const resolvedSpec = result.npmResolution?.resolvedSpec; + const recordSpec = opts.pin && resolvedSpec ? resolvedSpec : raw; + if (opts.pin && !resolvedSpec) { + defaultRuntime.log( + theme.warn("Could not resolve exact npm version for --pin; storing original npm spec."), + ); + } + if (opts.pin && resolvedSpec) { + defaultRuntime.log(`Pinned npm install record to ${resolvedSpec}.`); + } next = recordPluginInstall(next, { pluginId: result.pluginId, source: "npm", - spec: raw, + spec: recordSpec, installPath: result.targetDir, version: result.version, + resolvedName: result.npmResolution?.name, + resolvedVersion: result.npmResolution?.version, + resolvedSpec: result.npmResolution?.resolvedSpec, + integrity: result.npmResolution?.integrity, + shasum: result.npmResolution?.shasum, + resolvedAt: result.npmResolution?.resolvedAt, }); const slotResult = applySlotSelectionForPlugin(next, result.pluginId); next = slotResult.config; @@ -691,6 +708,20 @@ export function registerPluginsCli(program: Command) { info: (msg) => defaultRuntime.log(msg), warn: (msg) => defaultRuntime.log(theme.warn(msg)), }, + onIntegrityDrift: async (drift) => { + const specLabel = drift.resolvedSpec ?? drift.spec; + defaultRuntime.log( + theme.warn( + `Integrity drift detected for "${drift.pluginId}" (${specLabel})` + + `\nExpected: ${drift.expectedIntegrity}` + + `\nActual: ${drift.actualIntegrity}`, + ), + ); + if (drift.dryRun) { + return true; + } + return await promptYesNo(`Continue updating "${drift.pluginId}" with this artifact?`); + }, }); for (const outcome of result.outcomes) { diff --git a/src/commands/onboarding/plugin-install.ts b/src/commands/onboarding/plugin-install.ts index 6f5f6b9e56dd6..9714d18550e98 100644 --- a/src/commands/onboarding/plugin-install.ts +++ b/src/commands/onboarding/plugin-install.ts @@ -1,16 +1,16 @@ import fs from "node:fs"; import path from "node:path"; -import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; import type { ChannelPluginCatalogEntry } from "../../channels/plugins/catalog.js"; import type { OpenClawConfig } from "../../config/config.js"; +import type { RuntimeEnv } from "../../runtime.js"; +import type { WizardPrompter } from "../../wizard/prompts.js"; +import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { enablePluginInConfig } from "../../plugins/enable.js"; import { installPluginFromNpmSpec } from "../../plugins/install.js"; import { recordPluginInstall } from "../../plugins/installs.js"; import { loadOpenClawPlugins } from "../../plugins/loader.js"; import { createPluginLoaderLogger } from "../../plugins/logger.js"; -import type { RuntimeEnv } from "../../runtime.js"; -import type { WizardPrompter } from "../../wizard/prompts.js"; type InstallChoice = "npm" | "local" | "skip"; @@ -175,6 +175,12 @@ export async function ensureOnboardingPluginInstalled(params: { spec: entry.install.npmSpec, installPath: result.targetDir, version: result.version, + resolvedName: result.npmResolution?.name, + resolvedVersion: result.npmResolution?.version, + resolvedSpec: result.npmResolution?.resolvedSpec, + integrity: result.npmResolution?.integrity, + shasum: result.npmResolution?.shasum, + resolvedAt: result.npmResolution?.resolvedAt, }); return { cfg: next, installed: true }; } diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 2a59c153b027f..48d54abd1e749 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -283,6 +283,17 @@ export const FIELD_HELP: Record = { "plugins.installs.*.installPath": "Resolved install directory (usually ~/.openclaw/extensions/).", "plugins.installs.*.version": "Version recorded at install time (if available).", + "plugins.installs.*.resolvedName": "Resolved npm package name from the fetched artifact.", + "plugins.installs.*.resolvedVersion": + "Resolved npm package version from the fetched artifact (useful for non-pinned specs).", + "plugins.installs.*.resolvedSpec": + "Resolved exact npm spec (@) from the fetched artifact.", + "plugins.installs.*.integrity": + "Resolved npm dist integrity hash for the fetched artifact (if reported by npm).", + "plugins.installs.*.shasum": + "Resolved npm dist shasum for the fetched artifact (if reported by npm).", + "plugins.installs.*.resolvedAt": + "ISO timestamp when npm package metadata was last resolved for this install record.", "plugins.installs.*.installedAt": "ISO timestamp of last install/update.", "agents.list.*.identity.avatar": "Agent avatar (workspace-relative path, http(s) URL, or data URI).", diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index e84abca05700f..277cecfcfdb80 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -322,5 +322,11 @@ export const FIELD_LABELS: Record = { "plugins.installs.*.sourcePath": "Plugin Install Source Path", "plugins.installs.*.installPath": "Plugin Install Path", "plugins.installs.*.version": "Plugin Install Version", + "plugins.installs.*.resolvedName": "Plugin Resolved Package Name", + "plugins.installs.*.resolvedVersion": "Plugin Resolved Package Version", + "plugins.installs.*.resolvedSpec": "Plugin Resolved Package Spec", + "plugins.installs.*.integrity": "Plugin Resolved Integrity", + "plugins.installs.*.shasum": "Plugin Resolved Shasum", + "plugins.installs.*.resolvedAt": "Plugin Resolution Time", "plugins.installs.*.installedAt": "Plugin Install Time", }; diff --git a/src/config/types.hooks.ts b/src/config/types.hooks.ts index 3e13931cd608c..2345c9ba7df1f 100644 --- a/src/config/types.hooks.ts +++ b/src/config/types.hooks.ts @@ -93,6 +93,12 @@ export type HookInstallRecord = { sourcePath?: string; installPath?: string; version?: string; + resolvedName?: string; + resolvedVersion?: string; + resolvedSpec?: string; + integrity?: string; + shasum?: string; + resolvedAt?: string; installedAt?: string; hooks?: string[]; }; diff --git a/src/config/types.plugins.ts b/src/config/types.plugins.ts index 68268683e1e6d..f1e4211b1f67b 100644 --- a/src/config/types.plugins.ts +++ b/src/config/types.plugins.ts @@ -27,6 +27,12 @@ export type PluginInstallRecord = { sourcePath?: string; installPath?: string; version?: string; + resolvedName?: string; + resolvedVersion?: string; + resolvedSpec?: string; + integrity?: string; + shasum?: string; + resolvedAt?: string; installedAt?: string; }; diff --git a/src/config/zod-schema.installs.ts b/src/config/zod-schema.installs.ts index 3c2710096f2d5..7853948a10cd8 100644 --- a/src/config/zod-schema.installs.ts +++ b/src/config/zod-schema.installs.ts @@ -12,5 +12,11 @@ export const InstallRecordShape = { sourcePath: z.string().optional(), installPath: z.string().optional(), version: z.string().optional(), + resolvedName: z.string().optional(), + resolvedVersion: z.string().optional(), + resolvedSpec: z.string().optional(), + integrity: z.string().optional(), + shasum: z.string().optional(), + resolvedAt: z.string().optional(), installedAt: z.string().optional(), } as const; diff --git a/src/hooks/install.test.ts b/src/hooks/install.test.ts index 20e26a7fe8fb8..b8ef3f761ef74 100644 --- a/src/hooks/install.test.ts +++ b/src/hooks/install.test.ts @@ -253,7 +253,16 @@ describe("installHooksFromNpmSpec", () => { fs.writeFileSync(path.join(packTmpDir, packedName), npmPackHooksBuffer); return { code: 0, - stdout: `${packedName}\n`, + stdout: JSON.stringify([ + { + id: "@openclaw/test-hooks@0.0.1", + name: "@openclaw/test-hooks", + version: "0.0.1", + filename: packedName, + integrity: "sha512-hook-test", + shasum: "hookshasum", + }, + ]), stderr: "", signal: null, killed: false, @@ -274,6 +283,8 @@ describe("installHooksFromNpmSpec", () => { return; } expect(result.hookPackId).toBe("test-hooks"); + expect(result.npmResolution?.resolvedSpec).toBe("@openclaw/test-hooks@0.0.1"); + expect(result.npmResolution?.integrity).toBe("sha512-hook-test"); expect(fs.existsSync(path.join(result.targetDir, "hooks", "one-hook", "HOOK.md"))).toBe(true); expectSingleNpmPackIgnoreScriptsCall({ @@ -293,6 +304,46 @@ describe("installHooksFromNpmSpec", () => { } expect(result.error).toContain("unsupported npm spec"); }); + + it("aborts when integrity drift callback rejects the fetched artifact", async () => { + const run = vi.mocked(runCommandWithTimeout); + run.mockResolvedValue({ + code: 0, + stdout: JSON.stringify([ + { + id: "@openclaw/test-hooks@0.0.1", + name: "@openclaw/test-hooks", + version: "0.0.1", + filename: "test-hooks-0.0.1.tgz", + integrity: "sha512-new", + shasum: "newshasum", + }, + ]), + stderr: "", + signal: null, + killed: false, + termination: "exit", + }); + + const onIntegrityDrift = vi.fn(async () => false); + const result = await installHooksFromNpmSpec({ + spec: "@openclaw/test-hooks@0.0.1", + expectedIntegrity: "sha512-old", + onIntegrityDrift, + }); + + expect(onIntegrityDrift).toHaveBeenCalledWith( + expect.objectContaining({ + expectedIntegrity: "sha512-old", + actualIntegrity: "sha512-new", + }), + ); + expect(result.ok).toBe(false); + if (result.ok) { + return; + } + expect(result.error).toContain("integrity drift"); + }); }); describe("gmail watcher", () => { diff --git a/src/hooks/install.ts b/src/hooks/install.ts index 2a1daac746e3e..6bd0614640063 100644 --- a/src/hooks/install.ts +++ b/src/hooks/install.ts @@ -11,6 +11,8 @@ import { import { installPackageDir } from "../infra/install-package-dir.js"; import { resolveSafeInstallDir, unscopedPackageName } from "../infra/install-safe-path.js"; import { + type NpmIntegrityDrift, + type NpmSpecResolution, packNpmSpecToArchive, resolveArchiveSourcePath, withTempDir, @@ -37,9 +39,18 @@ export type InstallHooksResult = hooks: string[]; targetDir: string; version?: string; + npmResolution?: NpmSpecResolution; + integrityDrift?: NpmIntegrityDrift; } | { ok: false; error: string }; +export type HookNpmIntegrityDriftParams = { + spec: string; + expectedIntegrity: string; + actualIntegrity: string; + resolution: NpmSpecResolution; +}; + const defaultLogger: HookInstallLogger = {}; function validateHookId(hookId: string): string | null { @@ -375,6 +386,8 @@ export async function installHooksFromNpmSpec(params: { mode?: "install" | "update"; dryRun?: boolean; expectedHookPackId?: string; + expectedIntegrity?: string; + onIntegrityDrift?: (params: HookNpmIntegrityDriftParams) => boolean | Promise; }): Promise { const { logger, timeoutMs, mode, dryRun } = resolveTimedHookInstallModeOptions(params); const expectedHookPackId = params.expectedHookPackId; @@ -395,7 +408,44 @@ export async function installHooksFromNpmSpec(params: { return packedResult; } - return await installHooksFromArchive({ + const npmResolution: NpmSpecResolution = { + ...packedResult.metadata, + resolvedAt: new Date().toISOString(), + }; + + let integrityDrift: NpmIntegrityDrift | undefined; + if ( + params.expectedIntegrity && + npmResolution.integrity && + params.expectedIntegrity !== npmResolution.integrity + ) { + integrityDrift = { + expectedIntegrity: params.expectedIntegrity, + actualIntegrity: npmResolution.integrity, + }; + const driftPayload: HookNpmIntegrityDriftParams = { + spec, + expectedIntegrity: integrityDrift.expectedIntegrity, + actualIntegrity: integrityDrift.actualIntegrity, + resolution: npmResolution, + }; + let proceed = true; + if (params.onIntegrityDrift) { + proceed = await params.onIntegrityDrift(driftPayload); + } else { + logger.warn?.( + `Integrity drift detected for ${driftPayload.resolution.resolvedSpec ?? driftPayload.spec}: expected ${driftPayload.expectedIntegrity}, got ${driftPayload.actualIntegrity}`, + ); + } + if (!proceed) { + return { + ok: false, + error: `aborted: npm package integrity drift detected for ${driftPayload.resolution.resolvedSpec ?? driftPayload.spec}`, + }; + } + } + + const installResult = await installHooksFromArchive({ archivePath: packedResult.archivePath, hooksDir: params.hooksDir, timeoutMs, @@ -404,6 +454,15 @@ export async function installHooksFromNpmSpec(params: { dryRun, expectedHookPackId, }); + if (!installResult.ok) { + return installResult; + } + + return { + ...installResult, + npmResolution, + integrityDrift, + }; }); } diff --git a/src/infra/install-source-utils.test.ts b/src/infra/install-source-utils.test.ts index 143572479ae2b..a46c6c9aabc5a 100644 --- a/src/infra/install-source-utils.test.ts +++ b/src/infra/install-source-utils.test.ts @@ -85,10 +85,19 @@ describe("resolveArchiveSourcePath", () => { }); describe("packNpmSpecToArchive", () => { - it("packs spec and returns archive path using the final non-empty stdout line", async () => { + it("packs spec and returns archive path using JSON output metadata", async () => { const cwd = await createTempDir("openclaw-install-source-utils-"); runCommandWithTimeoutMock.mockResolvedValue({ - stdout: "npm notice created package\nopenclaw-plugin-1.2.3.tgz\n", + stdout: JSON.stringify([ + { + id: "openclaw-plugin@1.2.3", + name: "openclaw-plugin", + version: "1.2.3", + filename: "openclaw-plugin-1.2.3.tgz", + integrity: "sha512-test-integrity", + shasum: "abc123", + }, + ]), stderr: "", code: 0, signal: null, @@ -104,9 +113,16 @@ describe("packNpmSpecToArchive", () => { expect(result).toEqual({ ok: true, archivePath: path.join(cwd, "openclaw-plugin-1.2.3.tgz"), + metadata: { + name: "openclaw-plugin", + version: "1.2.3", + resolvedSpec: "openclaw-plugin@1.2.3", + integrity: "sha512-test-integrity", + shasum: "abc123", + }, }); expect(runCommandWithTimeoutMock).toHaveBeenCalledWith( - ["npm", "pack", "openclaw-plugin@1.2.3", "--ignore-scripts"], + ["npm", "pack", "openclaw-plugin@1.2.3", "--ignore-scripts", "--json"], expect.objectContaining({ cwd, timeoutMs: 300_000, @@ -114,6 +130,29 @@ describe("packNpmSpecToArchive", () => { ); }); + it("falls back to parsing final stdout line when npm json output is unavailable", async () => { + const cwd = await createTempDir("openclaw-install-source-utils-"); + runCommandWithTimeoutMock.mockResolvedValue({ + stdout: "npm notice created package\nopenclaw-plugin-1.2.3.tgz\n", + stderr: "", + code: 0, + signal: null, + killed: false, + }); + + const result = await packNpmSpecToArchive({ + spec: "openclaw-plugin@1.2.3", + timeoutMs: 1000, + cwd, + }); + + expect(result).toEqual({ + ok: true, + archivePath: path.join(cwd, "openclaw-plugin-1.2.3.tgz"), + metadata: {}, + }); + }); + it("returns npm pack error details when command fails", async () => { const cwd = await createTempDir("openclaw-install-source-utils-"); runCommandWithTimeoutMock.mockResolvedValue({ diff --git a/src/infra/install-source-utils.ts b/src/infra/install-source-utils.ts index f51be1e7e469e..d4a2ac025d737 100644 --- a/src/infra/install-source-utils.ts +++ b/src/infra/install-source-utils.ts @@ -5,6 +5,20 @@ import { runCommandWithTimeout } from "../process/exec.js"; import { resolveUserPath } from "../utils.js"; import { fileExists, resolveArchiveKind } from "./archive.js"; +export type NpmSpecResolution = { + name?: string; + version?: string; + resolvedSpec?: string; + integrity?: string; + shasum?: string; + resolvedAt?: string; +}; + +export type NpmIntegrityDrift = { + expectedIntegrity: string; + actualIntegrity: string; +}; + export async function withTempDir( prefix: string, fn: (tmpDir: string) => Promise, @@ -39,6 +53,97 @@ export async function resolveArchiveSourcePath(archivePath: string): Promise< return { ok: true, path: resolved }; } +function toOptionalString(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function parseResolvedSpecFromId(id: string): string | undefined { + const at = id.lastIndexOf("@"); + if (at <= 0 || at >= id.length - 1) { + return undefined; + } + const name = id.slice(0, at).trim(); + const version = id.slice(at + 1).trim(); + if (!name || !version) { + return undefined; + } + return `${name}@${version}`; +} + +function normalizeNpmPackEntry( + entry: unknown, +): { filename?: string; metadata: NpmSpecResolution } | null { + if (!entry || typeof entry !== "object") { + return null; + } + const rec = entry as Record; + const name = toOptionalString(rec.name); + const version = toOptionalString(rec.version); + const id = toOptionalString(rec.id); + const resolvedSpec = + (name && version ? `${name}@${version}` : undefined) ?? + (id ? parseResolvedSpecFromId(id) : undefined); + + return { + filename: toOptionalString(rec.filename), + metadata: { + name, + version, + resolvedSpec, + integrity: toOptionalString(rec.integrity), + shasum: toOptionalString(rec.shasum), + }, + }; +} + +function parseNpmPackJsonOutput( + raw: string, +): { filename?: string; metadata: NpmSpecResolution } | null { + const trimmed = raw.trim(); + if (!trimmed) { + return null; + } + + const candidates = [trimmed]; + const arrayStart = trimmed.indexOf("["); + if (arrayStart > 0) { + candidates.push(trimmed.slice(arrayStart)); + } + + for (const candidate of candidates) { + let parsed: unknown; + try { + parsed = JSON.parse(candidate); + } catch { + continue; + } + + const entries = Array.isArray(parsed) ? parsed : [parsed]; + let fallback: { filename?: string; metadata: NpmSpecResolution } | null = null; + for (let i = entries.length - 1; i >= 0; i -= 1) { + const normalized = normalizeNpmPackEntry(entries[i]); + if (!normalized) { + continue; + } + if (!fallback) { + fallback = normalized; + } + if (normalized.filename) { + return normalized; + } + } + if (fallback) { + return fallback; + } + } + + return null; +} + export async function packNpmSpecToArchive(params: { spec: string; timeoutMs: number; @@ -47,32 +152,44 @@ export async function packNpmSpecToArchive(params: { | { ok: true; archivePath: string; + metadata: NpmSpecResolution; } | { ok: false; error: string; } > { - const res = await runCommandWithTimeout(["npm", "pack", params.spec, "--ignore-scripts"], { - timeoutMs: Math.max(params.timeoutMs, 300_000), - cwd: params.cwd, - env: { - COREPACK_ENABLE_DOWNLOAD_PROMPT: "0", - NPM_CONFIG_IGNORE_SCRIPTS: "true", + const res = await runCommandWithTimeout( + ["npm", "pack", params.spec, "--ignore-scripts", "--json"], + { + timeoutMs: Math.max(params.timeoutMs, 300_000), + cwd: params.cwd, + env: { + COREPACK_ENABLE_DOWNLOAD_PROMPT: "0", + NPM_CONFIG_IGNORE_SCRIPTS: "true", + }, }, - }); + ); if (res.code !== 0) { return { ok: false, error: `npm pack failed: ${res.stderr.trim() || res.stdout.trim()}` }; } - const packed = (res.stdout || "") - .split("\n") - .map((line) => line.trim()) - .filter(Boolean) - .pop(); + const parsedJson = parseNpmPackJsonOutput(res.stdout || ""); + + const packed = + parsedJson?.filename ?? + (res.stdout || "") + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .pop(); if (!packed) { return { ok: false, error: "npm pack produced no archive" }; } - return { ok: true, archivePath: path.join(params.cwd, packed) }; + return { + ok: true, + archivePath: path.join(params.cwd, packed), + metadata: parsedJson?.metadata ?? {}, + }; } diff --git a/src/plugins/install.e2e.test.ts b/src/plugins/install.e2e.test.ts index cf870b79253ed..ca36983491ccf 100644 --- a/src/plugins/install.e2e.test.ts +++ b/src/plugins/install.e2e.test.ts @@ -1,8 +1,8 @@ +import JSZip from "jszip"; import { randomUUID } from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import JSZip from "jszip"; import * as tar from "tar"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import * as skillScanner from "../security/skill-scanner.js"; @@ -491,7 +491,16 @@ describe("installPluginFromNpmSpec", () => { await packToArchive({ pkgDir, outDir: packTmpDir, outName: packedName }); return { code: 0, - stdout: `${packedName}\n`, + stdout: JSON.stringify([ + { + id: "@openclaw/voice-call@0.0.1", + name: "@openclaw/voice-call", + version: "0.0.1", + filename: packedName, + integrity: "sha512-plugin-test", + shasum: "pluginshasum", + }, + ]), stderr: "", signal: null, killed: false, @@ -508,6 +517,11 @@ describe("installPluginFromNpmSpec", () => { logger: { info: () => {}, warn: () => {} }, }); expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + expect(result.npmResolution?.resolvedSpec).toBe("@openclaw/voice-call@0.0.1"); + expect(result.npmResolution?.integrity).toBe("sha512-plugin-test"); expectSingleNpmPackIgnoreScriptsCall({ calls: run.mock.calls, @@ -527,4 +541,46 @@ describe("installPluginFromNpmSpec", () => { } expect(result.error).toContain("unsupported npm spec"); }); + + it("aborts when integrity drift callback rejects the fetched artifact", async () => { + const { runCommandWithTimeout } = await import("../process/exec.js"); + const run = vi.mocked(runCommandWithTimeout); + run.mockResolvedValue({ + code: 0, + stdout: JSON.stringify([ + { + id: "@openclaw/voice-call@0.0.1", + name: "@openclaw/voice-call", + version: "0.0.1", + filename: "voice-call-0.0.1.tgz", + integrity: "sha512-new", + shasum: "newshasum", + }, + ]), + stderr: "", + signal: null, + killed: false, + termination: "exit", + }); + + const onIntegrityDrift = vi.fn(async () => false); + const { installPluginFromNpmSpec } = await import("./install.js"); + const result = await installPluginFromNpmSpec({ + spec: "@openclaw/voice-call@0.0.1", + expectedIntegrity: "sha512-old", + onIntegrityDrift, + }); + + expect(onIntegrityDrift).toHaveBeenCalledWith( + expect.objectContaining({ + expectedIntegrity: "sha512-old", + actualIntegrity: "sha512-new", + }), + ); + expect(result.ok).toBe(false); + if (result.ok) { + return; + } + expect(result.error).toContain("integrity drift"); + }); }); diff --git a/src/plugins/install.ts b/src/plugins/install.ts index 537ece54a2ec8..32e33bb9f3147 100644 --- a/src/plugins/install.ts +++ b/src/plugins/install.ts @@ -15,6 +15,8 @@ import { unscopedPackageName, } from "../infra/install-safe-path.js"; import { + type NpmIntegrityDrift, + type NpmSpecResolution, packNpmSpecToArchive, resolveArchiveSourcePath, withTempDir, @@ -43,9 +45,18 @@ export type InstallPluginResult = manifestName?: string; version?: string; extensions: string[]; + npmResolution?: NpmSpecResolution; + integrityDrift?: NpmIntegrityDrift; } | { ok: false; error: string }; +export type PluginNpmIntegrityDriftParams = { + spec: string; + expectedIntegrity: string; + actualIntegrity: string; + resolution: NpmSpecResolution; +}; + const defaultLogger: PluginInstallLogger = {}; function safeFileName(input: string): string { return safeDirName(input); @@ -420,6 +431,8 @@ export async function installPluginFromNpmSpec(params: { mode?: "install" | "update"; dryRun?: boolean; expectedPluginId?: string; + expectedIntegrity?: string; + onIntegrityDrift?: (params: PluginNpmIntegrityDriftParams) => boolean | Promise; }): Promise { const { logger, timeoutMs, mode, dryRun } = resolveTimedPluginInstallModeOptions(params); const expectedPluginId = params.expectedPluginId; @@ -440,7 +453,44 @@ export async function installPluginFromNpmSpec(params: { return packedResult; } - return await installPluginFromArchive({ + const npmResolution: NpmSpecResolution = { + ...packedResult.metadata, + resolvedAt: new Date().toISOString(), + }; + + let integrityDrift: NpmIntegrityDrift | undefined; + if ( + params.expectedIntegrity && + npmResolution.integrity && + params.expectedIntegrity !== npmResolution.integrity + ) { + integrityDrift = { + expectedIntegrity: params.expectedIntegrity, + actualIntegrity: npmResolution.integrity, + }; + const driftPayload: PluginNpmIntegrityDriftParams = { + spec, + expectedIntegrity: integrityDrift.expectedIntegrity, + actualIntegrity: integrityDrift.actualIntegrity, + resolution: npmResolution, + }; + let proceed = true; + if (params.onIntegrityDrift) { + proceed = await params.onIntegrityDrift(driftPayload); + } else { + logger.warn?.( + `Integrity drift detected for ${driftPayload.resolution.resolvedSpec ?? driftPayload.spec}: expected ${driftPayload.expectedIntegrity}, got ${driftPayload.actualIntegrity}`, + ); + } + if (!proceed) { + return { + ok: false, + error: `aborted: npm package integrity drift detected for ${driftPayload.resolution.resolvedSpec ?? driftPayload.spec}`, + }; + } + } + + const installResult = await installPluginFromArchive({ archivePath: packedResult.archivePath, extensionsDir: params.extensionsDir, timeoutMs, @@ -449,6 +499,15 @@ export async function installPluginFromNpmSpec(params: { dryRun, expectedPluginId, }); + if (!installResult.ok) { + return installResult; + } + + return { + ...installResult, + npmResolution, + integrityDrift, + }; }); } diff --git a/src/plugins/update.ts b/src/plugins/update.ts index 46b8d4abed9bd..628ff84995a7b 100644 --- a/src/plugins/update.ts +++ b/src/plugins/update.ts @@ -29,6 +29,16 @@ export type PluginUpdateSummary = { outcomes: PluginUpdateOutcome[]; }; +export type PluginUpdateIntegrityDriftParams = { + pluginId: string; + spec: string; + expectedIntegrity: string; + actualIntegrity: string; + resolvedSpec?: string; + resolvedVersion?: string; + dryRun: boolean; +}; + export type PluginChannelSyncSummary = { switchedToBundled: string[]; switchedToNpm: string[]; @@ -143,6 +153,7 @@ export async function updateNpmInstalledPlugins(params: { pluginIds?: string[]; skipIds?: Set; dryRun?: boolean; + onIntegrityDrift?: (params: PluginUpdateIntegrityDriftParams) => boolean | Promise; }): Promise { const logger = params.logger ?? {}; const installs = params.config.plugins?.installs ?? {}; @@ -210,6 +221,25 @@ export async function updateNpmInstalledPlugins(params: { mode: "update", dryRun: true, expectedPluginId: pluginId, + expectedIntegrity: record.integrity, + onIntegrityDrift: async (drift) => { + const payload: PluginUpdateIntegrityDriftParams = { + pluginId, + spec: drift.spec, + expectedIntegrity: drift.expectedIntegrity, + actualIntegrity: drift.actualIntegrity, + resolvedSpec: drift.resolution.resolvedSpec, + resolvedVersion: drift.resolution.version, + dryRun: true, + }; + if (params.onIntegrityDrift) { + return await params.onIntegrityDrift(payload); + } + logger.warn?.( + `Integrity drift for "${pluginId}" (${payload.resolvedSpec ?? payload.spec}): expected ${payload.expectedIntegrity}, got ${payload.actualIntegrity}`, + ); + return true; + }, logger, }); } catch (err) { @@ -257,6 +287,25 @@ export async function updateNpmInstalledPlugins(params: { spec: record.spec, mode: "update", expectedPluginId: pluginId, + expectedIntegrity: record.integrity, + onIntegrityDrift: async (drift) => { + const payload: PluginUpdateIntegrityDriftParams = { + pluginId, + spec: drift.spec, + expectedIntegrity: drift.expectedIntegrity, + actualIntegrity: drift.actualIntegrity, + resolvedSpec: drift.resolution.resolvedSpec, + resolvedVersion: drift.resolution.version, + dryRun: false, + }; + if (params.onIntegrityDrift) { + return await params.onIntegrityDrift(payload); + } + logger.warn?.( + `Integrity drift for "${pluginId}" (${payload.resolvedSpec ?? payload.spec}): expected ${payload.expectedIntegrity}, got ${payload.actualIntegrity}`, + ); + return true; + }, logger, }); } catch (err) { @@ -283,6 +332,12 @@ export async function updateNpmInstalledPlugins(params: { spec: record.spec, installPath: result.targetDir, version: nextVersion, + resolvedName: result.npmResolution?.name, + resolvedVersion: result.npmResolution?.version, + resolvedSpec: result.npmResolution?.resolvedSpec, + integrity: result.npmResolution?.integrity, + shasum: result.npmResolution?.shasum, + resolvedAt: result.npmResolution?.resolvedAt, }); changed = true; @@ -406,6 +461,12 @@ export async function syncPluginsForUpdateChannel(params: { spec, installPath: result.targetDir, version: result.version, + resolvedName: result.npmResolution?.name, + resolvedVersion: result.npmResolution?.version, + resolvedSpec: result.npmResolution?.resolvedSpec, + integrity: result.npmResolution?.integrity, + shasum: result.npmResolution?.shasum, + resolvedAt: result.npmResolution?.resolvedAt, sourcePath: undefined, }); summary.switchedToNpm.push(pluginId); diff --git a/src/security/audit-extra.async.ts b/src/security/audit-extra.async.ts index 520159cfdcb97..aa70b6575c8bf 100644 --- a/src/security/audit-extra.async.ts +++ b/src/security/audit-extra.async.ts @@ -5,23 +5,25 @@ */ import fs from "node:fs/promises"; import path from "node:path"; +import type { SandboxToolPolicy } from "../agents/sandbox/types.js"; +import type { OpenClawConfig, ConfigFileSnapshot } from "../config/config.js"; +import type { AgentToolsConfig } from "../config/types.tools.js"; +import type { SkillScanFinding } from "./skill-scanner.js"; +import type { ExecFn } from "./windows-acl.js"; import { resolveDefaultAgentId } from "../agents/agent-scope.js"; import { isToolAllowedByPolicies } from "../agents/pi-tools.policy.js"; import { resolveSandboxConfigForAgent, resolveSandboxToolPolicyForAgent, } from "../agents/sandbox.js"; -import type { SandboxToolPolicy } from "../agents/sandbox/types.js"; import { loadWorkspaceSkillEntries } from "../agents/skills.js"; import { resolveToolProfilePolicy } from "../agents/tool-policy.js"; import { listAgentWorkspaceDirs } from "../agents/workspace-dirs.js"; import { MANIFEST_KEY } from "../compat/legacy-names.js"; import { resolveNativeSkillsEnabled } from "../config/commands.js"; -import type { OpenClawConfig, ConfigFileSnapshot } from "../config/config.js"; import { createConfigIO } from "../config/config.js"; import { collectIncludePathsRecursive } from "../config/includes-scan.js"; import { resolveOAuthDir } from "../config/paths.js"; -import type { AgentToolsConfig } from "../config/types.tools.js"; import { normalizePluginsConfig } from "../plugins/config-state.js"; import { normalizeAgentId } from "../routing/session-key.js"; import { @@ -32,9 +34,7 @@ import { } from "./audit-fs.js"; import { pickSandboxToolPolicy } from "./audit-tool-policy.js"; import { extensionUsesSkippedScannerPath, isPathInside } from "./scan-paths.js"; -import type { SkillScanFinding } from "./skill-scanner.js"; import * as skillScanner from "./skill-scanner.js"; -import type { ExecFn } from "./windows-acl.js"; export type SecurityAuditFinding = { checkId: string; @@ -215,6 +215,29 @@ function hasProviderPluginAllow(params: { return false; } +function isPinnedRegistrySpec(spec: string): boolean { + const value = spec.trim(); + if (!value) { + return false; + } + const at = value.lastIndexOf("@"); + if (at <= 0 || at >= value.length - 1) { + return false; + } + const version = value.slice(at + 1).trim(); + return /^v?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(version); +} + +async function readInstalledPackageVersion(dir: string): Promise { + try { + const raw = await fs.readFile(path.join(dir, "package.json"), "utf-8"); + const parsed = JSON.parse(raw) as { version?: unknown }; + return typeof parsed.version === "string" ? parsed.version : undefined; + } catch { + return undefined; + } +} + // -------------------------------------------------------------------------- // Exported collectors // -------------------------------------------------------------------------- @@ -227,155 +250,279 @@ export async function collectPluginsTrustFindings(params: { const { extensionsDir, pluginDirs } = await listInstalledPluginDirs({ stateDir: params.stateDir, }); - if (pluginDirs.length === 0) { - return findings; - } + if (pluginDirs.length > 0) { + const allow = params.cfg.plugins?.allow; + const allowConfigured = Array.isArray(allow) && allow.length > 0; + if (!allowConfigured) { + const hasString = (value: unknown) => typeof value === "string" && value.trim().length > 0; + const hasAccountStringKey = (account: unknown, key: string) => + Boolean( + account && + typeof account === "object" && + hasString((account as Record)[key]), + ); - const allow = params.cfg.plugins?.allow; - const allowConfigured = Array.isArray(allow) && allow.length > 0; - if (!allowConfigured) { - const hasString = (value: unknown) => typeof value === "string" && value.trim().length > 0; - const hasAccountStringKey = (account: unknown, key: string) => - Boolean( - account && - typeof account === "object" && - hasString((account as Record)[key]), - ); + const discordConfigured = + hasString(params.cfg.channels?.discord?.token) || + Boolean( + params.cfg.channels?.discord?.accounts && + Object.values(params.cfg.channels.discord.accounts).some((a) => + hasAccountStringKey(a, "token"), + ), + ) || + hasString(process.env.DISCORD_BOT_TOKEN); + + const telegramConfigured = + hasString(params.cfg.channels?.telegram?.botToken) || + hasString(params.cfg.channels?.telegram?.tokenFile) || + Boolean( + params.cfg.channels?.telegram?.accounts && + Object.values(params.cfg.channels.telegram.accounts).some( + (a) => hasAccountStringKey(a, "botToken") || hasAccountStringKey(a, "tokenFile"), + ), + ) || + hasString(process.env.TELEGRAM_BOT_TOKEN); + + const slackConfigured = + hasString(params.cfg.channels?.slack?.botToken) || + hasString(params.cfg.channels?.slack?.appToken) || + Boolean( + params.cfg.channels?.slack?.accounts && + Object.values(params.cfg.channels.slack.accounts).some( + (a) => hasAccountStringKey(a, "botToken") || hasAccountStringKey(a, "appToken"), + ), + ) || + hasString(process.env.SLACK_BOT_TOKEN) || + hasString(process.env.SLACK_APP_TOKEN); + + const skillCommandsLikelyExposed = + (discordConfigured && + resolveNativeSkillsEnabled({ + providerId: "discord", + providerSetting: params.cfg.channels?.discord?.commands?.nativeSkills, + globalSetting: params.cfg.commands?.nativeSkills, + })) || + (telegramConfigured && + resolveNativeSkillsEnabled({ + providerId: "telegram", + providerSetting: params.cfg.channels?.telegram?.commands?.nativeSkills, + globalSetting: params.cfg.commands?.nativeSkills, + })) || + (slackConfigured && + resolveNativeSkillsEnabled({ + providerId: "slack", + providerSetting: params.cfg.channels?.slack?.commands?.nativeSkills, + globalSetting: params.cfg.commands?.nativeSkills, + })); + + findings.push({ + checkId: "plugins.extensions_no_allowlist", + severity: skillCommandsLikelyExposed ? "critical" : "warn", + title: "Extensions exist but plugins.allow is not set", + detail: + `Found ${pluginDirs.length} extension(s) under ${extensionsDir}. Without plugins.allow, any discovered plugin id may load (depending on config and plugin behavior).` + + (skillCommandsLikelyExposed + ? "\nNative skill commands are enabled on at least one configured chat surface; treat unpinned/unallowlisted extensions as high risk." + : ""), + remediation: "Set plugins.allow to an explicit list of plugin ids you trust.", + }); + } - const discordConfigured = - hasString(params.cfg.channels?.discord?.token) || - Boolean( - params.cfg.channels?.discord?.accounts && - Object.values(params.cfg.channels.discord.accounts).some((a) => - hasAccountStringKey(a, "token"), - ), - ) || - hasString(process.env.DISCORD_BOT_TOKEN); - - const telegramConfigured = - hasString(params.cfg.channels?.telegram?.botToken) || - hasString(params.cfg.channels?.telegram?.tokenFile) || - Boolean( - params.cfg.channels?.telegram?.accounts && - Object.values(params.cfg.channels.telegram.accounts).some( - (a) => hasAccountStringKey(a, "botToken") || hasAccountStringKey(a, "tokenFile"), - ), - ) || - hasString(process.env.TELEGRAM_BOT_TOKEN); - - const slackConfigured = - hasString(params.cfg.channels?.slack?.botToken) || - hasString(params.cfg.channels?.slack?.appToken) || - Boolean( - params.cfg.channels?.slack?.accounts && - Object.values(params.cfg.channels.slack.accounts).some( - (a) => hasAccountStringKey(a, "botToken") || hasAccountStringKey(a, "appToken"), - ), - ) || - hasString(process.env.SLACK_BOT_TOKEN) || - hasString(process.env.SLACK_APP_TOKEN); - - const skillCommandsLikelyExposed = - (discordConfigured && - resolveNativeSkillsEnabled({ - providerId: "discord", - providerSetting: params.cfg.channels?.discord?.commands?.nativeSkills, - globalSetting: params.cfg.commands?.nativeSkills, - })) || - (telegramConfigured && - resolveNativeSkillsEnabled({ - providerId: "telegram", - providerSetting: params.cfg.channels?.telegram?.commands?.nativeSkills, - globalSetting: params.cfg.commands?.nativeSkills, - })) || - (slackConfigured && - resolveNativeSkillsEnabled({ - providerId: "slack", - providerSetting: params.cfg.channels?.slack?.commands?.nativeSkills, - globalSetting: params.cfg.commands?.nativeSkills, - })); - - findings.push({ - checkId: "plugins.extensions_no_allowlist", - severity: skillCommandsLikelyExposed ? "critical" : "warn", - title: "Extensions exist but plugins.allow is not set", - detail: - `Found ${pluginDirs.length} extension(s) under ${extensionsDir}. Without plugins.allow, any discovered plugin id may load (depending on config and plugin behavior).` + - (skillCommandsLikelyExposed - ? "\nNative skill commands are enabled on at least one configured chat surface; treat unpinned/unallowlisted extensions as high risk." - : ""), - remediation: "Set plugins.allow to an explicit list of plugin ids you trust.", + const enabledExtensionPluginIds = resolveEnabledExtensionPluginIds({ + cfg: params.cfg, + pluginDirs, }); + if (enabledExtensionPluginIds.length > 0) { + const enabledPluginSet = new Set(enabledExtensionPluginIds); + const contexts: Array<{ + label: string; + agentId?: string; + tools?: AgentToolsConfig; + }> = [{ label: "default" }]; + for (const entry of params.cfg.agents?.list ?? []) { + if (!entry || typeof entry !== "object" || typeof entry.id !== "string") { + continue; + } + contexts.push({ + label: `agents.list.${entry.id}`, + agentId: entry.id, + tools: entry.tools, + }); + } + + const permissiveContexts: string[] = []; + for (const context of contexts) { + const profile = context.tools?.profile ?? params.cfg.tools?.profile; + const restrictiveProfile = Boolean(resolveToolProfilePolicy(profile)); + const sandboxMode = resolveSandboxConfigForAgent(params.cfg, context.agentId).mode; + const policies = resolveToolPolicies({ + cfg: params.cfg, + agentTools: context.tools, + sandboxMode, + agentId: context.agentId, + }); + const broadPolicy = isToolAllowedByPolicies("__openclaw_plugin_probe__", policies); + const explicitPluginAllow = + !restrictiveProfile && + (hasExplicitPluginAllow({ + allowEntries: collectAllowEntries(params.cfg.tools), + enabledPluginIds: enabledPluginSet, + }) || + hasProviderPluginAllow({ + byProvider: params.cfg.tools?.byProvider, + enabledPluginIds: enabledPluginSet, + }) || + hasExplicitPluginAllow({ + allowEntries: collectAllowEntries(context.tools), + enabledPluginIds: enabledPluginSet, + }) || + hasProviderPluginAllow({ + byProvider: context.tools?.byProvider, + enabledPluginIds: enabledPluginSet, + })); + + if (broadPolicy || explicitPluginAllow) { + permissiveContexts.push(context.label); + } + } + + if (permissiveContexts.length > 0) { + findings.push({ + checkId: "plugins.tools_reachable_permissive_policy", + severity: "warn", + title: "Extension plugin tools may be reachable under permissive tool policy", + detail: + `Enabled extension plugins: ${enabledExtensionPluginIds.join(", ")}.\n` + + `Permissive tool policy contexts:\n${permissiveContexts.map((entry) => `- ${entry}`).join("\n")}`, + remediation: + "Use restrictive profiles (`minimal`/`coding`) or explicit tool allowlists that exclude plugin tools for agents handling untrusted input.", + }); + } + } } - const enabledExtensionPluginIds = resolveEnabledExtensionPluginIds({ - cfg: params.cfg, - pluginDirs, - }); - if (enabledExtensionPluginIds.length > 0) { - const enabledPluginSet = new Set(enabledExtensionPluginIds); - const contexts: Array<{ - label: string; - agentId?: string; - tools?: AgentToolsConfig; - }> = [{ label: "default" }]; - for (const entry of params.cfg.agents?.list ?? []) { - if (!entry || typeof entry !== "object" || typeof entry.id !== "string") { + const pluginInstalls = params.cfg.plugins?.installs ?? {}; + const npmPluginInstalls = Object.entries(pluginInstalls).filter( + ([, record]) => record?.source === "npm", + ); + if (npmPluginInstalls.length > 0) { + const unpinned = npmPluginInstalls + .filter(([, record]) => typeof record.spec === "string" && !isPinnedRegistrySpec(record.spec)) + .map(([pluginId, record]) => `${pluginId} (${record.spec})`); + if (unpinned.length > 0) { + findings.push({ + checkId: "plugins.installs_unpinned_npm_specs", + severity: "warn", + title: "Plugin installs include unpinned npm specs", + detail: `Unpinned plugin install records:\n${unpinned.map((entry) => `- ${entry}`).join("\n")}`, + remediation: + "Pin install specs to exact versions (for example, `@scope/pkg@1.2.3`) for higher supply-chain stability.", + }); + } + + const missingIntegrity = npmPluginInstalls + .filter( + ([, record]) => typeof record.integrity !== "string" || record.integrity.trim() === "", + ) + .map(([pluginId]) => pluginId); + if (missingIntegrity.length > 0) { + findings.push({ + checkId: "plugins.installs_missing_integrity", + severity: "warn", + title: "Plugin installs are missing integrity metadata", + detail: `Plugin install records missing integrity:\n${missingIntegrity.map((entry) => `- ${entry}`).join("\n")}`, + remediation: + "Reinstall or update plugins to refresh install metadata with resolved integrity hashes.", + }); + } + + const pluginVersionDrift: string[] = []; + for (const [pluginId, record] of npmPluginInstalls) { + const recordedVersion = record.resolvedVersion ?? record.version; + if (!recordedVersion) { + continue; + } + const installPath = record.installPath ?? path.join(params.stateDir, "extensions", pluginId); + // eslint-disable-next-line no-await-in-loop + const installedVersion = await readInstalledPackageVersion(installPath); + if (!installedVersion || installedVersion === recordedVersion) { continue; } - contexts.push({ - label: `agents.list.${entry.id}`, - agentId: entry.id, - tools: entry.tools, + pluginVersionDrift.push( + `${pluginId} (recorded ${recordedVersion}, installed ${installedVersion})`, + ); + } + if (pluginVersionDrift.length > 0) { + findings.push({ + checkId: "plugins.installs_version_drift", + severity: "warn", + title: "Plugin install records drift from installed package versions", + detail: `Detected plugin install metadata drift:\n${pluginVersionDrift.map((entry) => `- ${entry}`).join("\n")}`, + remediation: + "Run `openclaw plugins update --all` (or reinstall affected plugins) to refresh install metadata.", }); } + } - const permissiveContexts: string[] = []; - for (const context of contexts) { - const profile = context.tools?.profile ?? params.cfg.tools?.profile; - const restrictiveProfile = Boolean(resolveToolProfilePolicy(profile)); - const sandboxMode = resolveSandboxConfigForAgent(params.cfg, context.agentId).mode; - const policies = resolveToolPolicies({ - cfg: params.cfg, - agentTools: context.tools, - sandboxMode, - agentId: context.agentId, + const hookInstalls = params.cfg.hooks?.internal?.installs ?? {}; + const npmHookInstalls = Object.entries(hookInstalls).filter( + ([, record]) => record?.source === "npm", + ); + if (npmHookInstalls.length > 0) { + const unpinned = npmHookInstalls + .filter(([, record]) => typeof record.spec === "string" && !isPinnedRegistrySpec(record.spec)) + .map(([hookId, record]) => `${hookId} (${record.spec})`); + if (unpinned.length > 0) { + findings.push({ + checkId: "hooks.installs_unpinned_npm_specs", + severity: "warn", + title: "Hook installs include unpinned npm specs", + detail: `Unpinned hook install records:\n${unpinned.map((entry) => `- ${entry}`).join("\n")}`, + remediation: + "Pin hook install specs to exact versions (for example, `@scope/pkg@1.2.3`) for higher supply-chain stability.", }); - const broadPolicy = isToolAllowedByPolicies("__openclaw_plugin_probe__", policies); - const explicitPluginAllow = - !restrictiveProfile && - (hasExplicitPluginAllow({ - allowEntries: collectAllowEntries(params.cfg.tools), - enabledPluginIds: enabledPluginSet, - }) || - hasProviderPluginAllow({ - byProvider: params.cfg.tools?.byProvider, - enabledPluginIds: enabledPluginSet, - }) || - hasExplicitPluginAllow({ - allowEntries: collectAllowEntries(context.tools), - enabledPluginIds: enabledPluginSet, - }) || - hasProviderPluginAllow({ - byProvider: context.tools?.byProvider, - enabledPluginIds: enabledPluginSet, - })); + } - if (broadPolicy || explicitPluginAllow) { - permissiveContexts.push(context.label); - } + const missingIntegrity = npmHookInstalls + .filter( + ([, record]) => typeof record.integrity !== "string" || record.integrity.trim() === "", + ) + .map(([hookId]) => hookId); + if (missingIntegrity.length > 0) { + findings.push({ + checkId: "hooks.installs_missing_integrity", + severity: "warn", + title: "Hook installs are missing integrity metadata", + detail: `Hook install records missing integrity:\n${missingIntegrity.map((entry) => `- ${entry}`).join("\n")}`, + remediation: + "Reinstall or update hooks to refresh install metadata with resolved integrity hashes.", + }); } - if (permissiveContexts.length > 0) { + const hookVersionDrift: string[] = []; + for (const [hookId, record] of npmHookInstalls) { + const recordedVersion = record.resolvedVersion ?? record.version; + if (!recordedVersion) { + continue; + } + const installPath = record.installPath ?? path.join(params.stateDir, "hooks", hookId); + // eslint-disable-next-line no-await-in-loop + const installedVersion = await readInstalledPackageVersion(installPath); + if (!installedVersion || installedVersion === recordedVersion) { + continue; + } + hookVersionDrift.push( + `${hookId} (recorded ${recordedVersion}, installed ${installedVersion})`, + ); + } + if (hookVersionDrift.length > 0) { findings.push({ - checkId: "plugins.tools_reachable_permissive_policy", + checkId: "hooks.installs_version_drift", severity: "warn", - title: "Extension plugin tools may be reachable under permissive tool policy", - detail: - `Enabled extension plugins: ${enabledExtensionPluginIds.join(", ")}.\n` + - `Permissive tool policy contexts:\n${permissiveContexts.map((entry) => `- ${entry}`).join("\n")}`, + title: "Hook install records drift from installed package versions", + detail: `Detected hook install metadata drift:\n${hookVersionDrift.map((entry) => `- ${entry}`).join("\n")}`, remediation: - "Use restrictive profiles (`minimal`/`coding`) or explicit tool allowlists that exclude plugin tools for agents handling untrusted input.", + "Run `openclaw hooks update --all` (or reinstall affected hooks) to refresh install metadata.", }); } } diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index ba5490a18063c..d85a37fe8ae92 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -4,9 +4,9 @@ import path from "node:path"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { ChannelPlugin } from "../channels/plugins/types.js"; import type { OpenClawConfig } from "../config/config.js"; +import type { SecurityAuditOptions, SecurityAuditReport } from "./audit.js"; import { collectPluginsCodeSafetyFindings } from "./audit-extra.js"; import { runSecurityAudit } from "./audit.js"; -import type { SecurityAuditOptions, SecurityAuditReport } from "./audit.js"; import * as skillScanner from "./skill-scanner.js"; const isWindows = process.platform === "win32"; @@ -1502,6 +1502,143 @@ describe("security audit", () => { } }); + it("warns on unpinned npm install specs and missing integrity metadata", async () => { + const tmp = await makeTmpDir("install-metadata-warns"); + const stateDir = path.join(tmp, "state"); + await fs.mkdir(stateDir, { recursive: true }); + + const cfg: OpenClawConfig = { + plugins: { + installs: { + "voice-call": { + source: "npm", + spec: "@openclaw/voice-call", + }, + }, + }, + hooks: { + internal: { + installs: { + "test-hooks": { + source: "npm", + spec: "@openclaw/test-hooks", + }, + }, + }, + }, + }; + + const res = await runSecurityAudit({ + config: cfg, + includeFilesystem: true, + includeChannelSecurity: false, + stateDir, + configPath: path.join(stateDir, "openclaw.json"), + }); + + expect(hasFinding(res, "plugins.installs_unpinned_npm_specs", "warn")).toBe(true); + expect(hasFinding(res, "plugins.installs_missing_integrity", "warn")).toBe(true); + expect(hasFinding(res, "hooks.installs_unpinned_npm_specs", "warn")).toBe(true); + expect(hasFinding(res, "hooks.installs_missing_integrity", "warn")).toBe(true); + }); + + it("does not warn on pinned npm install specs with integrity metadata", async () => { + const tmp = await makeTmpDir("install-metadata-clean"); + const stateDir = path.join(tmp, "state"); + await fs.mkdir(stateDir, { recursive: true }); + + const cfg: OpenClawConfig = { + plugins: { + installs: { + "voice-call": { + source: "npm", + spec: "@openclaw/voice-call@1.2.3", + integrity: "sha512-plugin", + }, + }, + }, + hooks: { + internal: { + installs: { + "test-hooks": { + source: "npm", + spec: "@openclaw/test-hooks@1.2.3", + integrity: "sha512-hook", + }, + }, + }, + }, + }; + + const res = await runSecurityAudit({ + config: cfg, + includeFilesystem: true, + includeChannelSecurity: false, + stateDir, + configPath: path.join(stateDir, "openclaw.json"), + }); + + expect(hasFinding(res, "plugins.installs_unpinned_npm_specs")).toBe(false); + expect(hasFinding(res, "plugins.installs_missing_integrity")).toBe(false); + expect(hasFinding(res, "hooks.installs_unpinned_npm_specs")).toBe(false); + expect(hasFinding(res, "hooks.installs_missing_integrity")).toBe(false); + }); + + it("warns when install records drift from installed package versions", async () => { + const tmp = await makeTmpDir("install-version-drift"); + const stateDir = path.join(tmp, "state"); + const pluginDir = path.join(stateDir, "extensions", "voice-call"); + const hookDir = path.join(stateDir, "hooks", "test-hooks"); + await fs.mkdir(pluginDir, { recursive: true }); + await fs.mkdir(hookDir, { recursive: true }); + await fs.writeFile( + path.join(pluginDir, "package.json"), + JSON.stringify({ name: "@openclaw/voice-call", version: "9.9.9" }), + "utf-8", + ); + await fs.writeFile( + path.join(hookDir, "package.json"), + JSON.stringify({ name: "@openclaw/test-hooks", version: "8.8.8" }), + "utf-8", + ); + + const cfg: OpenClawConfig = { + plugins: { + installs: { + "voice-call": { + source: "npm", + spec: "@openclaw/voice-call@1.2.3", + integrity: "sha512-plugin", + resolvedVersion: "1.2.3", + }, + }, + }, + hooks: { + internal: { + installs: { + "test-hooks": { + source: "npm", + spec: "@openclaw/test-hooks@1.2.3", + integrity: "sha512-hook", + resolvedVersion: "1.2.3", + }, + }, + }, + }, + }; + + const res = await runSecurityAudit({ + config: cfg, + includeFilesystem: true, + includeChannelSecurity: false, + stateDir, + configPath: path.join(stateDir, "openclaw.json"), + }); + + expect(hasFinding(res, "plugins.installs_version_drift", "warn")).toBe(true); + expect(hasFinding(res, "hooks.installs_version_drift", "warn")).toBe(true); + }); + it("flags enabled extensions when tool policy can expose plugin tools", async () => { const tmp = await makeTmpDir("plugins-reachable"); const stateDir = path.join(tmp, "state"); diff --git a/src/test-utils/exec-assertions.ts b/src/test-utils/exec-assertions.ts index f26166bc231f9..50bf54f61e414 100644 --- a/src/test-utils/exec-assertions.ts +++ b/src/test-utils/exec-assertions.ts @@ -28,7 +28,7 @@ export function expectSingleNpmPackIgnoreScriptsCall(params: { throw new Error("expected npm pack call"); } const [argv, options] = packCall; - expect(argv).toEqual(["npm", "pack", params.expectedSpec, "--ignore-scripts"]); + expect(argv).toEqual(["npm", "pack", params.expectedSpec, "--ignore-scripts", "--json"]); const commandOptions = typeof options === "number" ? undefined : options; expect(commandOptions).toMatchObject({ env: { NPM_CONFIG_IGNORE_SCRIPTS: "true" } }); } From 3561442a9fce05444a76706039100a30ccec5068 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 15:13:34 +0100 Subject: [PATCH 0009/1006] fix(plugins): harden discovery trust checks --- CHANGELOG.md | 1 + docs/tools/plugin.md | 9 +++ src/plugins/discovery.test.ts | 74 ++++++++++++++++++ src/plugins/discovery.ts | 125 +++++++++++++++++++++++++++++- src/plugins/loader.test.ts | 72 ++++++++++++++++++ src/plugins/loader.ts | 139 ++++++++++++++++++++++++++++++++++ 6 files changed, 419 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index acf0e9083314d..aaf074a12c793 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai - Security/ACP: harden ACP bridge session management with duplicate-session refresh, idle-session reaping, oldest-idle soft-cap eviction, and burst rate limiting on session creation to reduce local DoS risk without disrupting normal IDE usage. - Security/Plugins/Hooks: add optional `--pin` for npm plugin/hook installs, persist resolved npm metadata (`name`, `version`, `spec`, integrity, shasum, timestamp), warn/confirm on integrity drift during updates, and extend `openclaw security audit` to flag unpinned specs, missing integrity metadata, and install-record version drift. +- Security/Plugins: harden plugin discovery by blocking unsafe candidates (root escapes, world-writable paths, suspicious ownership), add startup warnings when `plugins.allow` is empty with discoverable non-bundled plugins, and warn on loaded plugins without install/load-path provenance. - Security/Gateway: rate-limit control-plane write RPCs (`config.apply`, `config.patch`, `update.run`) to 3 requests per minute per `deviceId+clientIp`, add restart single-flight coalescing plus a 30-second restart cooldown, and log actor/device/ip with changed-path audit details for config/update-triggered restarts. - Commands/Doctor: skip embedding-provider warnings when `memory.backend` is `qmd`, because QMD manages embeddings internally and does not require `memorySearch` providers. (#17263) Thanks @miloudbelarebia. - Security/Webhooks: harden Feishu and Zalo webhook ingress with webhook-mode token preconditions, loopback-default Feishu bind host, JSON content-type enforcement, per-path rate limiting, replay dedupe for Zalo events, constant-time Zalo secret comparison, and anomaly status counters. diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index ab031c389b754..9433e9ea3877c 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -116,6 +116,15 @@ Bundled plugins must be enabled explicitly via `plugins.entries..enabled` or `openclaw plugins enable `. Installed plugins are enabled by default, but can be disabled the same way. +Hardening notes: + +- If `plugins.allow` is empty and non-bundled plugins are discoverable, OpenClaw logs a startup warning with plugin ids and sources. +- Candidate paths are safety-checked before discovery admission. OpenClaw blocks candidates when: + - extension entry resolves outside plugin root (including symlink/path traversal escapes), + - plugin root/source path is world-writable, + - path ownership is suspicious for non-bundled plugins (POSIX owner is neither current uid nor root). +- Loaded non-bundled plugins without install/load-path provenance emit a warning so you can pin trust (`plugins.allow`) or install tracking (`plugins.installs`). + Each plugin must include a `openclaw.plugin.json` file in its root. If a path points at a file, the plugin root is the file's directory and must contain the manifest. diff --git a/src/plugins/discovery.test.ts b/src/plugins/discovery.test.ts index 3d19b02b17a39..1a81a46024d61 100644 --- a/src/plugins/discovery.test.ts +++ b/src/plugins/discovery.test.ts @@ -149,4 +149,78 @@ describe("discoverOpenClawPlugins", () => { const ids = candidates.map((c) => c.idHint); expect(ids).toContain("demo-plugin-dir"); }); + + it("blocks extension entries that escape plugin root", async () => { + const stateDir = makeTempDir(); + const globalExt = path.join(stateDir, "extensions", "escape-pack"); + const outside = path.join(stateDir, "outside.js"); + fs.mkdirSync(globalExt, { recursive: true }); + + fs.writeFileSync( + path.join(globalExt, "package.json"), + JSON.stringify({ + name: "@openclaw/escape-pack", + openclaw: { extensions: ["../../outside.js"] }, + }), + "utf-8", + ); + fs.writeFileSync(outside, "export default function () {}", "utf-8"); + + const result = await withStateDir(stateDir, async () => { + return discoverOpenClawPlugins({}); + }); + + expect(result.candidates).toHaveLength(0); + expect( + result.diagnostics.some((diag) => diag.message.includes("source escapes plugin root")), + ).toBe(true); + }); + + it.runIf(process.platform !== "win32")("blocks world-writable plugin paths", async () => { + const stateDir = makeTempDir(); + const globalExt = path.join(stateDir, "extensions"); + fs.mkdirSync(globalExt, { recursive: true }); + const pluginPath = path.join(globalExt, "world-open.ts"); + fs.writeFileSync(pluginPath, "export default function () {}", "utf-8"); + fs.chmodSync(pluginPath, 0o777); + + const result = await withStateDir(stateDir, async () => { + return discoverOpenClawPlugins({}); + }); + + expect(result.candidates).toHaveLength(0); + expect(result.diagnostics.some((diag) => diag.message.includes("world-writable path"))).toBe( + true, + ); + }); + + it.runIf(process.platform !== "win32" && typeof process.getuid === "function")( + "blocks suspicious ownership when uid mismatch is detected", + async () => { + const stateDir = makeTempDir(); + const globalExt = path.join(stateDir, "extensions"); + fs.mkdirSync(globalExt, { recursive: true }); + fs.writeFileSync( + path.join(globalExt, "owner-mismatch.ts"), + "export default function () {}", + "utf-8", + ); + + const proc = process as NodeJS.Process & { getuid: () => number }; + const originalGetUid = proc.getuid; + const actualUid = originalGetUid(); + try { + proc.getuid = () => actualUid + 1; + const result = await withStateDir(stateDir, async () => { + return discoverOpenClawPlugins({}); + }); + expect(result.candidates).toHaveLength(0); + expect( + result.diagnostics.some((diag) => diag.message.includes("suspicious ownership")), + ).toBe(true); + } finally { + proc.getuid = originalGetUid; + } + }, + ); }); diff --git a/src/plugins/discovery.ts b/src/plugins/discovery.ts index 02b10ade64ea1..3625ab9a1c83a 100644 --- a/src/plugins/discovery.ts +++ b/src/plugins/discovery.ts @@ -29,6 +29,111 @@ export type PluginDiscoveryResult = { diagnostics: PluginDiagnostic[]; }; +function isPathInside(baseDir: string, targetPath: string): boolean { + const rel = path.relative(baseDir, targetPath); + if (!rel) { + return true; + } + return !rel.startsWith("..") && !path.isAbsolute(rel); +} + +function safeRealpathSync(targetPath: string): string | null { + try { + return fs.realpathSync(targetPath); + } catch { + return null; + } +} + +function safeStatSync(targetPath: string): fs.Stats | null { + try { + return fs.statSync(targetPath); + } catch { + return null; + } +} + +function formatMode(mode: number): string { + return (mode & 0o777).toString(8).padStart(3, "0"); +} + +function currentUid(): number | null { + if (process.platform === "win32") { + return null; + } + if (typeof process.getuid !== "function") { + return null; + } + return process.getuid(); +} + +function isUnsafePluginCandidate(params: { + source: string; + rootDir: string; + origin: PluginOrigin; + diagnostics: PluginDiagnostic[]; +}): boolean { + const sourceReal = safeRealpathSync(params.source); + const rootReal = safeRealpathSync(params.rootDir); + if (sourceReal && rootReal && !isPathInside(rootReal, sourceReal)) { + params.diagnostics.push({ + level: "warn", + source: params.source, + message: `blocked plugin candidate: source escapes plugin root (${params.source} -> ${sourceReal}; root=${rootReal})`, + }); + return true; + } + + if (process.platform === "win32") { + return false; + } + + const uid = currentUid(); + const pathsToCheck = [params.rootDir, params.source]; + const seen = new Set(); + for (const targetPath of pathsToCheck) { + const normalized = path.resolve(targetPath); + if (seen.has(normalized)) { + continue; + } + seen.add(normalized); + const stat = safeStatSync(targetPath); + if (!stat) { + params.diagnostics.push({ + level: "warn", + source: targetPath, + message: `blocked plugin candidate: cannot stat path (${targetPath})`, + }); + return true; + } + const modeBits = stat.mode & 0o777; + if ((modeBits & 0o002) !== 0) { + params.diagnostics.push({ + level: "warn", + source: targetPath, + message: `blocked plugin candidate: world-writable path (${targetPath}, mode=${formatMode(modeBits)})`, + }); + return true; + } + if ( + params.origin !== "bundled" && + uid !== null && + typeof stat.uid === "number" && + stat.uid !== uid && + stat.uid !== 0 + ) { + params.diagnostics.push({ + level: "warn", + source: targetPath, + message: `blocked plugin candidate: suspicious ownership (${targetPath}, uid=${stat.uid}, expected uid=${uid} or root)`, + }); + return true; + } + } + + return false; +} + function isExtensionFile(filePath: string): boolean { const ext = path.extname(filePath); if (!EXTENSION_EXTS.has(ext)) { @@ -83,6 +188,7 @@ function deriveIdHint(params: { function addCandidate(params: { candidates: PluginCandidate[]; + diagnostics: PluginDiagnostic[]; seen: Set; idHint: string; source: string; @@ -96,12 +202,23 @@ function addCandidate(params: { if (params.seen.has(resolved)) { return; } + const resolvedRoot = path.resolve(params.rootDir); + if ( + isUnsafePluginCandidate({ + source: resolved, + rootDir: resolvedRoot, + origin: params.origin, + diagnostics: params.diagnostics, + }) + ) { + return; + } params.seen.add(resolved); const manifest = params.manifest ?? null; params.candidates.push({ idHint: params.idHint, source: resolved, - rootDir: path.resolve(params.rootDir), + rootDir: resolvedRoot, origin: params.origin, workspaceDir: params.workspaceDir, packageName: manifest?.name?.trim() || undefined, @@ -143,6 +260,7 @@ function discoverInDirectory(params: { } addCandidate({ candidates: params.candidates, + diagnostics: params.diagnostics, seen: params.seen, idHint: path.basename(entry.name, path.extname(entry.name)), source: fullPath, @@ -163,6 +281,7 @@ function discoverInDirectory(params: { const resolved = path.resolve(fullPath, extPath); addCandidate({ candidates: params.candidates, + diagnostics: params.diagnostics, seen: params.seen, idHint: deriveIdHint({ filePath: resolved, @@ -187,6 +306,7 @@ function discoverInDirectory(params: { if (indexFile && isExtensionFile(indexFile)) { addCandidate({ candidates: params.candidates, + diagnostics: params.diagnostics, seen: params.seen, idHint: entry.name, source: indexFile, @@ -230,6 +350,7 @@ function discoverFromPath(params: { } addCandidate({ candidates: params.candidates, + diagnostics: params.diagnostics, seen: params.seen, idHint: path.basename(resolved, path.extname(resolved)), source: resolved, @@ -249,6 +370,7 @@ function discoverFromPath(params: { const source = path.resolve(resolved, extPath); addCandidate({ candidates: params.candidates, + diagnostics: params.diagnostics, seen: params.seen, idHint: deriveIdHint({ filePath: source, @@ -274,6 +396,7 @@ function discoverFromPath(params: { if (indexFile && isExtensionFile(indexFile)) { addCandidate({ candidates: params.candidates, + diagnostics: params.diagnostics, seen: params.seen, idHint: path.basename(resolved), source: indexFile, diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 7db185c8e87a8..0af06b0df9e22 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -489,4 +489,76 @@ describe("loadOpenClawPlugins", () => { expect(loaded?.origin).toBe("config"); expect(overridden?.origin).toBe("bundled"); }); + + it("warns when plugins.allow is empty and non-bundled plugins are discoverable", () => { + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins"; + const plugin = writePlugin({ + id: "warn-open-allow", + body: `export default { id: "warn-open-allow", register() {} };`, + }); + const warnings: string[] = []; + loadOpenClawPlugins({ + cache: false, + logger: { + info: () => {}, + warn: (msg) => warnings.push(msg), + error: () => {}, + }, + config: { + plugins: { + load: { paths: [plugin.file] }, + }, + }, + }); + expect( + warnings.some((msg) => msg.includes("plugins.allow is empty") && msg.includes(plugin.id)), + ).toBe(true); + }); + + it("warns when loaded non-bundled plugin has no install/load-path provenance", () => { + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins"; + const prevStateDir = process.env.OPENCLAW_STATE_DIR; + const stateDir = makeTempDir(); + process.env.OPENCLAW_STATE_DIR = stateDir; + try { + const globalDir = path.join(stateDir, "extensions", "rogue"); + fs.mkdirSync(globalDir, { recursive: true }); + writePlugin({ + id: "rogue", + body: `export default { id: "rogue", register() {} };`, + dir: globalDir, + filename: "index.js", + }); + + const warnings: string[] = []; + const registry = loadOpenClawPlugins({ + cache: false, + logger: { + info: () => {}, + warn: (msg) => warnings.push(msg), + error: () => {}, + }, + config: { + plugins: { + allow: ["rogue"], + }, + }, + }); + + const rogue = registry.plugins.find((entry) => entry.id === "rogue"); + expect(rogue?.status).toBe("loaded"); + expect( + warnings.some( + (msg) => + msg.includes("rogue") && msg.includes("loaded without install/load-path provenance"), + ), + ).toBe(true); + } finally { + if (prevStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = prevStateDir; + } + } + }); }); diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 3060b3daab8d7..ab688865f0830 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -177,6 +177,128 @@ function pushDiagnostics(diagnostics: PluginDiagnostic[], append: PluginDiagnost diagnostics.push(...append); } +function isPathInside(baseDir: string, targetPath: string): boolean { + const rel = path.relative(baseDir, targetPath); + if (!rel) { + return true; + } + return !rel.startsWith("..") && !path.isAbsolute(rel); +} + +function pathMatchesBaseOrFile(params: { baseOrFile: string; targetFile: string }): boolean { + const baseResolved = resolveUserPath(params.baseOrFile); + const targetResolved = resolveUserPath(params.targetFile); + if (baseResolved === targetResolved) { + return true; + } + try { + const stat = fs.statSync(baseResolved); + if (!stat.isDirectory()) { + return false; + } + } catch { + return false; + } + return isPathInside(baseResolved, targetResolved); +} + +function isTrackedByInstallRecord(params: { + pluginId: string; + source: string; + config: OpenClawConfig; +}): boolean { + const install = params.config.plugins?.installs?.[params.pluginId]; + if (!install) { + return false; + } + const trackedPaths = [install.installPath, install.sourcePath] + .map((entry) => (typeof entry === "string" ? entry.trim() : "")) + .filter(Boolean); + if (trackedPaths.length === 0) { + return true; + } + return trackedPaths.some((trackedPath) => + pathMatchesBaseOrFile({ + baseOrFile: trackedPath, + targetFile: params.source, + }), + ); +} + +function isTrackedByLoadPath(params: { source: string; loadPaths: string[] }): boolean { + return params.loadPaths.some((loadPath) => + pathMatchesBaseOrFile({ + baseOrFile: loadPath, + targetFile: params.source, + }), + ); +} + +function warnWhenAllowlistIsOpen(params: { + logger: PluginLogger; + pluginsEnabled: boolean; + allow: string[]; + discoverablePlugins: Array<{ id: string; source: string; origin: PluginRecord["origin"] }>; +}) { + if (!params.pluginsEnabled) { + return; + } + if (params.allow.length > 0) { + return; + } + const nonBundled = params.discoverablePlugins.filter((entry) => entry.origin !== "bundled"); + if (nonBundled.length === 0) { + return; + } + const preview = nonBundled + .slice(0, 6) + .map((entry) => `${entry.id} (${entry.source})`) + .join(", "); + const extra = nonBundled.length > 6 ? ` (+${nonBundled.length - 6} more)` : ""; + params.logger.warn( + `[plugins] plugins.allow is empty; discovered non-bundled plugins may auto-load: ${preview}${extra}. Set plugins.allow to explicit trusted ids.`, + ); +} + +function warnAboutUntrackedLoadedPlugins(params: { + registry: PluginRegistry; + config: OpenClawConfig; + normalizedLoadPaths: string[]; + logger: PluginLogger; +}) { + for (const plugin of params.registry.plugins) { + if (plugin.status !== "loaded" || plugin.origin === "bundled") { + continue; + } + if ( + isTrackedByInstallRecord({ + pluginId: plugin.id, + source: plugin.source, + config: params.config, + }) + ) { + continue; + } + if ( + isTrackedByLoadPath({ + source: plugin.source, + loadPaths: params.normalizedLoadPaths, + }) + ) { + continue; + } + const message = + "loaded without install/load-path provenance; treat as untracked local code and pin trust via plugins.allow or install records"; + params.registry.diagnostics.push({ + level: "warn", + pluginId: plugin.id, + source: plugin.source, + message, + }); + params.logger.warn(`[plugins] ${plugin.id}: ${message} (${plugin.source})`); + } +} + export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegistry { // Test env: default-disable plugins unless explicitly configured. // This keeps unit/gateway suites fast and avoids loading heavyweight plugin deps by accident. @@ -219,6 +341,16 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi diagnostics: discovery.diagnostics, }); pushDiagnostics(registry.diagnostics, manifestRegistry.diagnostics); + warnWhenAllowlistIsOpen({ + logger, + pluginsEnabled: normalized.enabled, + allow: normalized.allow, + discoverablePlugins: manifestRegistry.plugins.map((plugin) => ({ + id: plugin.id, + source: plugin.source, + origin: plugin.origin, + })), + }); // Lazy: avoid creating the Jiti loader when all plugins are disabled (common in unit tests). let jitiLoader: ReturnType | null = null; @@ -471,6 +603,13 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi }); } + warnAboutUntrackedLoadedPlugins({ + registry, + config: cfg, + normalizedLoadPaths: normalized.loadPaths, + logger, + }); + if (cacheEnabled) { registryCache.set(cacheKey, registry); } From baa335f2581b74561134169ac7f2b1faae791d68 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 15:14:22 +0100 Subject: [PATCH 0010/1006] fix(security): harden SSRF IPv4 literal parsing --- CHANGELOG.md | 1 + src/infra/net/fetch-guard.ssrf.test.ts | 11 +++++ src/infra/net/ssrf.pinning.test.ts | 11 +++++ src/infra/net/ssrf.test.ts | 19 +++++++ src/infra/net/ssrf.ts | 68 ++++++++++++++++++++++++-- 5 files changed, 106 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aaf074a12c793..722fbb24201a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Security/Net: harden SSRF IPv4 literal parsing to block octal/hex/short/packed legacy forms (for example `0177.0.0.1`, `127.1`, `2130706433`) in pre-DNS guard checks. - Security/ACP: harden ACP bridge session management with duplicate-session refresh, idle-session reaping, oldest-idle soft-cap eviction, and burst rate limiting on session creation to reduce local DoS risk without disrupting normal IDE usage. - Security/Plugins/Hooks: add optional `--pin` for npm plugin/hook installs, persist resolved npm metadata (`name`, `version`, `spec`, integrity, shasum, timestamp), warn/confirm on integrity drift during updates, and extend `openclaw security audit` to flag unpinned specs, missing integrity metadata, and install-record version drift. - Security/Plugins: harden plugin discovery by blocking unsafe candidates (root escapes, world-writable paths, suspicious ownership), add startup warnings when `plugins.allow` is empty with discoverable non-bundled plugins, and warn on loaded plugins without install/load-path provenance. diff --git a/src/infra/net/fetch-guard.ssrf.test.ts b/src/infra/net/fetch-guard.ssrf.test.ts index a4722d2b26d94..fe6e60a59df2c 100644 --- a/src/infra/net/fetch-guard.ssrf.test.ts +++ b/src/infra/net/fetch-guard.ssrf.test.ts @@ -22,6 +22,17 @@ describe("fetchWithSsrFGuard hardening", () => { expect(fetchImpl).not.toHaveBeenCalled(); }); + it("blocks legacy loopback literal URLs before fetch", async () => { + const fetchImpl = vi.fn(); + await expect( + fetchWithSsrFGuard({ + url: "http://0177.0.0.1:8080/internal", + fetchImpl, + }), + ).rejects.toThrow(/private|internal|blocked/i); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + it("blocks redirect chains that hop to private hosts", async () => { const lookupFn = vi.fn(async () => [ { address: "93.184.216.34", family: 4 }, diff --git a/src/infra/net/ssrf.pinning.test.ts b/src/infra/net/ssrf.pinning.test.ts index 5196514566bac..14ee88bc49e17 100644 --- a/src/infra/net/ssrf.pinning.test.ts +++ b/src/infra/net/ssrf.pinning.test.ts @@ -122,6 +122,17 @@ describe("ssrf pinning", () => { expect(lookup).not.toHaveBeenCalled(); }); + it("blocks legacy loopback IPv4 literals before DNS lookup", async () => { + const lookup = vi.fn(async () => [ + { address: "93.184.216.34", family: 4 }, + ]) as unknown as LookupFn; + + await expect( + resolvePinnedHostnameWithPolicy("0177.0.0.1", { lookupFn: lookup }), + ).rejects.toThrow(SsrFBlockedError); + expect(lookup).not.toHaveBeenCalled(); + }); + it("allows ISATAP embedded private IPv4 when private network is explicitly enabled", async () => { const lookup = vi.fn(async () => [ { address: "2001:db8:1234::5efe:127.0.0.1", family: 6 }, diff --git a/src/infra/net/ssrf.test.ts b/src/infra/net/ssrf.test.ts index 521e1f42a6ef2..f2f571c3708a0 100644 --- a/src/infra/net/ssrf.test.ts +++ b/src/infra/net/ssrf.test.ts @@ -26,6 +26,12 @@ const privateIpCases = [ "fec0::1", "2001:db8:1234::5efe:127.0.0.1", "2001:db8:1234:1:200:5efe:7f00:1", + "0177.0.0.1", + "0x7f.0.0.1", + "127.1", + "2130706433", + "0x7f000001", + "017700000001", ]; const publicIpCases = [ @@ -38,9 +44,12 @@ const publicIpCases = [ "2001:0000:0:0:0:0:f7f7:f7f7", "2001:db8:1234::5efe:8.8.8.8", "2001:db8:1234:1:1111:5efe:7f00:1", + "8.8.2056", + "0x08080808", ]; const malformedIpv6Cases = ["::::", "2001:db8::gggg"]; +const malformedIpv4Cases = ["08.0.0.1", "0x7g.0.0.1", "127.0.0.1.", "127..0.1"]; describe("ssrf ip classification", () => { it.each(privateIpCases)("classifies %s as private", (address) => { @@ -54,6 +63,10 @@ describe("ssrf ip classification", () => { it.each(malformedIpv6Cases)("fails closed for malformed IPv6 %s", (address) => { expect(isPrivateIpAddress(address)).toBe(true); }); + + it.each(malformedIpv4Cases)("treats malformed IPv4 literal %s as non-IP", (address) => { + expect(isPrivateIpAddress(address)).toBe(false); + }); }); describe("normalizeFingerprint", () => { @@ -74,4 +87,10 @@ describe("isBlockedHostnameOrIp", () => { expect(isBlockedHostnameOrIp("2001:db8:1234::5efe:127.0.0.1")).toBe(true); expect(isBlockedHostnameOrIp("2001:db8::1")).toBe(false); }); + + it("blocks legacy IPv4 literal representations", () => { + expect(isBlockedHostnameOrIp("0177.0.0.1")).toBe(true); + expect(isBlockedHostnameOrIp("127.1")).toBe(true); + expect(isBlockedHostnameOrIp("2130706433")).toBe(true); + }); }); diff --git a/src/infra/net/ssrf.ts b/src/infra/net/ssrf.ts index 90ed62cf12edb..d3d621b2542aa 100644 --- a/src/infra/net/ssrf.ts +++ b/src/infra/net/ssrf.ts @@ -70,14 +70,74 @@ function matchesHostnameAllowlist(hostname: string, allowlist: string[]): boolea function parseIpv4(address: string): number[] | null { const parts = address.split("."); - if (parts.length !== 4) { + if (parts.length < 1 || parts.length > 4) { return null; } - const numbers = parts.map((part) => Number.parseInt(part, 10)); - if (numbers.some((value) => Number.isNaN(value) || value < 0 || value > 255)) { + + const numbers: number[] = []; + for (const part of parts) { + if (!part) { + return null; + } + const lower = part.toLowerCase(); + let value: number; + if (lower.startsWith("0x")) { + const hex = lower.slice(2); + if (!hex || !/^[0-9a-f]+$/i.test(hex)) { + return null; + } + value = Number.parseInt(hex, 16); + } else if (part.length > 1 && part.startsWith("0")) { + const octal = part.slice(1); + if (!/^[0-7]+$/.test(octal)) { + return null; + } + value = Number.parseInt(octal, 8); + } else { + if (!/^[0-9]+$/.test(part)) { + return null; + } + value = Number.parseInt(part, 10); + } + if (!Number.isFinite(value) || value < 0) { + return null; + } + numbers.push(value); + } + + let ipv4Number: number; + if (numbers.length === 1) { + if (numbers[0] > 0xffffffff) { + return null; + } + ipv4Number = numbers[0]; + } else if (numbers.length === 2) { + if (numbers[0] > 0xff || numbers[1] > 0xffffff) { + return null; + } + ipv4Number = numbers[0] * 0x1000000 + numbers[1]; + } else if (numbers.length === 3) { + if (numbers[0] > 0xff || numbers[1] > 0xff || numbers[2] > 0xffff) { + return null; + } + ipv4Number = numbers[0] * 0x1000000 + numbers[1] * 0x10000 + numbers[2]; + } else { + if (numbers.some((value) => value > 0xff)) { + return null; + } + ipv4Number = numbers[0] * 0x1000000 + numbers[1] * 0x10000 + numbers[2] * 0x100 + numbers[3]; + } + + if (!Number.isSafeInteger(ipv4Number) || ipv4Number < 0 || ipv4Number > 0xffffffff) { return null; } - return numbers; + + return [ + Math.floor(ipv4Number / 0x1000000) & 0xff, + Math.floor(ipv4Number / 0x10000) & 0xff, + Math.floor(ipv4Number / 0x100) & 0xff, + ipv4Number & 0xff, + ]; } function stripIpv6ZoneId(address: string): string { From 775816035ecc6bb243843f8000c9a58ff609e32d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 15:18:00 +0100 Subject: [PATCH 0011/1006] fix(security): enforce trusted sender auth for discord moderation --- CHANGELOG.md | 1 + src/agents/openclaw-tools.ts | 9 +- src/agents/pi-tools.ts | 7 +- .../discord-actions-moderation.authz.test.ts | 157 ++++++++++++++++++ .../tools/discord-actions-moderation.ts | 48 +++++- src/agents/tools/message-tool.e2e.test.ts | 18 ++ src/agents/tools/message-tool.ts | 6 +- src/channels/plugins/actions/actions.test.ts | 41 +++++ .../discord/handle-action.guild-admin.ts | 13 +- .../plugins/actions/discord/handle-action.ts | 7 +- src/channels/plugins/types.core.ts | 5 + src/discord/send.permissions.authz.test.ts | 128 ++++++++++++++ src/discord/send.permissions.ts | 58 ++++++- src/discord/send.ts | 4 + src/infra/outbound/message-action-runner.ts | 18 +- 15 files changed, 498 insertions(+), 22 deletions(-) create mode 100644 src/agents/tools/discord-actions-moderation.authz.test.ts create mode 100644 src/discord/send.permissions.authz.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 722fbb24201a2..36950bbdf08f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Security/Net: harden SSRF IPv4 literal parsing to block octal/hex/short/packed legacy forms (for example `0177.0.0.1`, `127.1`, `2130706433`) in pre-DNS guard checks. +- Security/Discord: enforce trusted-sender guild permission checks for moderation actions (`timeout`, `kick`, `ban`) and ignore untrusted `senderUserId` params to prevent privilege escalation in tool-driven flows. Thanks @aether-ai-agent for reporting. - Security/ACP: harden ACP bridge session management with duplicate-session refresh, idle-session reaping, oldest-idle soft-cap eviction, and burst rate limiting on session creation to reduce local DoS risk without disrupting normal IDE usage. - Security/Plugins/Hooks: add optional `--pin` for npm plugin/hook installs, persist resolved npm metadata (`name`, `version`, `spec`, integrity, shasum, timestamp), warn/confirm on integrity drift during updates, and extend `openclaw security audit` to flag unpinned specs, missing integrity metadata, and install-record version drift. - Security/Plugins: harden plugin discovery by blocking unsafe candidates (root escapes, world-writable paths, suspicious ownership), add startup warnings when `plugins.allow` is empty with discoverable non-bundled plugins, and warn on loaded plugins without install/load-path provenance. diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index eb2bb369dc7bf..fbdab56352afb 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -1,12 +1,12 @@ import type { OpenClawConfig } from "../config/config.js"; -import { resolvePluginTools } from "../plugins/tools.js"; import type { GatewayMessageChannel } from "../utils/message-channel.js"; -import { resolveSessionAgentId } from "./agent-scope.js"; import type { SandboxFsBridge } from "./sandbox/fs-bridge.js"; +import type { AnyAgentTool } from "./tools/common.js"; +import { resolvePluginTools } from "../plugins/tools.js"; +import { resolveSessionAgentId } from "./agent-scope.js"; import { createAgentsListTool } from "./tools/agents-list-tool.js"; import { createBrowserTool } from "./tools/browser-tool.js"; import { createCanvasTool } from "./tools/canvas-tool.js"; -import type { AnyAgentTool } from "./tools/common.js"; import { createCronTool } from "./tools/cron-tool.js"; import { createGatewayTool } from "./tools/gateway-tool.js"; import { createImageTool } from "./tools/image-tool.js"; @@ -61,6 +61,8 @@ export function createOpenClawTools(options?: { requireExplicitMessageTarget?: boolean; /** If true, omit the message tool from the tool list. */ disableMessageTool?: boolean; + /** Trusted sender id from inbound context (not tool args). */ + requesterSenderId?: string | null; /** Whether the requesting sender is an owner. */ senderIsOwner?: boolean; }): AnyAgentTool[] { @@ -98,6 +100,7 @@ export function createOpenClawTools(options?: { hasRepliedRef: options?.hasRepliedRef, sandboxRoot: options?.sandboxRoot, requireExplicitTarget: options?.requireExplicitMessageTarget, + requesterSenderId: options?.requesterSenderId ?? undefined, }); const tools: AnyAgentTool[] = [ createBrowserTool({ diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index 340b3427707d7..a5de24a34f5c4 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -7,6 +7,9 @@ import { } from "@mariozechner/pi-coding-agent"; import type { OpenClawConfig } from "../config/config.js"; import type { ToolLoopDetectionConfig } from "../config/types.tools.js"; +import type { ModelAuthMode } from "./model-auth.js"; +import type { AnyAgentTool } from "./pi-tools.types.js"; +import type { SandboxContext } from "./sandbox.js"; import { logWarn } from "../logger.js"; import { getPluginToolMeta } from "../plugins/tools.js"; import { isSubagentSessionKey } from "../routing/session-key.js"; @@ -21,7 +24,6 @@ import { } from "./bash-tools.js"; import { listChannelAgentTools } from "./channel-tools.js"; import { resolveImageSanitizationLimits } from "./image-sanitization.js"; -import type { ModelAuthMode } from "./model-auth.js"; import { createOpenClawTools } from "./openclaw-tools.js"; import { wrapToolWithAbortSignal } from "./pi-tools.abort.js"; import { wrapToolWithBeforeToolCallHook } from "./pi-tools.before-tool-call.js"; @@ -44,8 +46,6 @@ import { wrapToolParamNormalization, } from "./pi-tools.read.js"; import { cleanToolSchemaForGemini, normalizeToolParameters } from "./pi-tools.schema.js"; -import type { AnyAgentTool } from "./pi-tools.types.js"; -import type { SandboxContext } from "./sandbox.js"; import { getSubagentDepthFromSessionStore } from "./subagent-depth.js"; import { applyToolPolicyPipeline, @@ -455,6 +455,7 @@ export function createOpenClawCodingTools(options?: { requireExplicitMessageTarget: options?.requireExplicitMessageTarget, disableMessageTool: options?.disableMessageTool, requesterAgentIdOverride: agentId, + requesterSenderId: options?.senderId, senderIsOwner: options?.senderIsOwner, }), ]; diff --git a/src/agents/tools/discord-actions-moderation.authz.test.ts b/src/agents/tools/discord-actions-moderation.authz.test.ts new file mode 100644 index 0000000000000..5d6a579989636 --- /dev/null +++ b/src/agents/tools/discord-actions-moderation.authz.test.ts @@ -0,0 +1,157 @@ +import { PermissionFlagsBits } from "discord-api-types/v10"; +import { describe, expect, it, vi } from "vitest"; +import type { DiscordActionConfig } from "../../config/config.js"; +import { handleDiscordModerationAction } from "./discord-actions-moderation.js"; + +const discordSendMocks = vi.hoisted(() => ({ + banMemberDiscord: vi.fn(async () => ({ ok: true })), + kickMemberDiscord: vi.fn(async () => ({ ok: true })), + timeoutMemberDiscord: vi.fn(async () => ({ id: "user-1" })), + hasGuildPermissionDiscord: vi.fn(async () => false), +})); + +const { banMemberDiscord, kickMemberDiscord, timeoutMemberDiscord, hasGuildPermissionDiscord } = + discordSendMocks; + +vi.mock("../../discord/send.js", () => ({ + ...discordSendMocks, +})); + +const enableAllActions = (_key: keyof DiscordActionConfig, _defaultValue = true) => true; + +describe("discord moderation sender authorization", () => { + it("rejects ban when sender lacks BAN_MEMBERS", async () => { + hasGuildPermissionDiscord.mockResolvedValueOnce(false); + + await expect( + handleDiscordModerationAction( + "ban", + { + guildId: "guild-1", + userId: "user-1", + senderUserId: "sender-1", + }, + enableAllActions, + ), + ).rejects.toThrow("required permissions"); + + expect(hasGuildPermissionDiscord).toHaveBeenCalledWith( + "guild-1", + "sender-1", + [PermissionFlagsBits.BanMembers], + undefined, + ); + expect(banMemberDiscord).not.toHaveBeenCalled(); + }); + + it("rejects kick when sender lacks KICK_MEMBERS", async () => { + hasGuildPermissionDiscord.mockResolvedValueOnce(false); + + await expect( + handleDiscordModerationAction( + "kick", + { + guildId: "guild-1", + userId: "user-1", + senderUserId: "sender-1", + }, + enableAllActions, + ), + ).rejects.toThrow("required permissions"); + + expect(hasGuildPermissionDiscord).toHaveBeenCalledWith( + "guild-1", + "sender-1", + [PermissionFlagsBits.KickMembers], + undefined, + ); + expect(kickMemberDiscord).not.toHaveBeenCalled(); + }); + + it("rejects timeout when sender lacks MODERATE_MEMBERS", async () => { + hasGuildPermissionDiscord.mockResolvedValueOnce(false); + + await expect( + handleDiscordModerationAction( + "timeout", + { + guildId: "guild-1", + userId: "user-1", + senderUserId: "sender-1", + durationMinutes: 60, + }, + enableAllActions, + ), + ).rejects.toThrow("required permissions"); + + expect(hasGuildPermissionDiscord).toHaveBeenCalledWith( + "guild-1", + "sender-1", + [PermissionFlagsBits.ModerateMembers], + undefined, + ); + expect(timeoutMemberDiscord).not.toHaveBeenCalled(); + }); + + it("executes moderation action when sender has required permission", async () => { + hasGuildPermissionDiscord.mockResolvedValueOnce(true); + kickMemberDiscord.mockResolvedValueOnce({ ok: true }); + + await handleDiscordModerationAction( + "kick", + { + guildId: "guild-1", + userId: "user-1", + senderUserId: "sender-1", + reason: "rule violation", + }, + enableAllActions, + ); + + expect(hasGuildPermissionDiscord).toHaveBeenCalledWith( + "guild-1", + "sender-1", + [PermissionFlagsBits.KickMembers], + undefined, + ); + expect(kickMemberDiscord).toHaveBeenCalledWith({ + guildId: "guild-1", + userId: "user-1", + reason: "rule violation", + }); + }); + + it("forwards accountId into permission check and moderation execution", async () => { + hasGuildPermissionDiscord.mockResolvedValueOnce(true); + timeoutMemberDiscord.mockResolvedValueOnce({ id: "user-1" }); + + await handleDiscordModerationAction( + "timeout", + { + guildId: "guild-1", + userId: "user-1", + senderUserId: "sender-1", + accountId: "ops", + durationMinutes: 5, + }, + enableAllActions, + ); + + expect(hasGuildPermissionDiscord).toHaveBeenCalledWith( + "guild-1", + "sender-1", + [PermissionFlagsBits.ModerateMembers], + { accountId: "ops" }, + ); + expect(timeoutMemberDiscord).toHaveBeenCalledWith( + { + guildId: "guild-1", + userId: "user-1", + durationMinutes: 5, + until: undefined, + reason: undefined, + }, + { accountId: "ops" }, + ); + }); +}); diff --git a/src/agents/tools/discord-actions-moderation.ts b/src/agents/tools/discord-actions-moderation.ts index bd3a1e4b317b1..69960f5dce6f4 100644 --- a/src/agents/tools/discord-actions-moderation.ts +++ b/src/agents/tools/discord-actions-moderation.ts @@ -1,14 +1,42 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; +import { PermissionFlagsBits } from "discord-api-types/v10"; import type { DiscordActionConfig } from "../../config/config.js"; -import { banMemberDiscord, kickMemberDiscord, timeoutMemberDiscord } from "../../discord/send.js"; +import { + banMemberDiscord, + hasGuildPermissionDiscord, + kickMemberDiscord, + timeoutMemberDiscord, +} from "../../discord/send.js"; import { type ActionGate, jsonResult, readStringParam } from "./common.js"; +async function verifySenderModerationPermission(params: { + guildId: string; + senderUserId?: string; + requiredPermissions: bigint[]; + accountId?: string; +}) { + // CLI/manual flows may not have sender context; enforce only when present. + if (!params.senderUserId) { + return; + } + const hasPermission = await hasGuildPermissionDiscord( + params.guildId, + params.senderUserId, + params.requiredPermissions, + params.accountId ? { accountId: params.accountId } : undefined, + ); + if (!hasPermission) { + throw new Error("Sender does not have required permissions for this moderation action."); + } +} + export async function handleDiscordModerationAction( action: string, params: Record, isActionEnabled: ActionGate, ): Promise> { const accountId = readStringParam(params, "accountId"); + const senderUserId = readStringParam(params, "senderUserId"); switch (action) { case "timeout": { if (!isActionEnabled("moderation", false)) { @@ -26,6 +54,12 @@ export async function handleDiscordModerationAction( : undefined; const until = readStringParam(params, "until"); const reason = readStringParam(params, "reason"); + await verifySenderModerationPermission({ + guildId, + senderUserId, + requiredPermissions: [PermissionFlagsBits.ModerateMembers], + accountId, + }); const member = accountId ? await timeoutMemberDiscord( { @@ -57,6 +91,12 @@ export async function handleDiscordModerationAction( required: true, }); const reason = readStringParam(params, "reason"); + await verifySenderModerationPermission({ + guildId, + senderUserId, + requiredPermissions: [PermissionFlagsBits.KickMembers], + accountId, + }); if (accountId) { await kickMemberDiscord({ guildId, userId, reason }, { accountId }); } else { @@ -79,6 +119,12 @@ export async function handleDiscordModerationAction( typeof params.deleteMessageDays === "number" && Number.isFinite(params.deleteMessageDays) ? params.deleteMessageDays : undefined; + await verifySenderModerationPermission({ + guildId, + senderUserId, + requiredPermissions: [PermissionFlagsBits.BanMembers], + accountId, + }); if (accountId) { await banMemberDiscord( { diff --git a/src/agents/tools/message-tool.e2e.test.ts b/src/agents/tools/message-tool.e2e.test.ts index f75eedf7c3026..77d4441ae1e27 100644 --- a/src/agents/tools/message-tool.e2e.test.ts +++ b/src/agents/tools/message-tool.e2e.test.ts @@ -329,4 +329,22 @@ describe("message tool sandbox passthrough", () => { const call = mocks.runMessageAction.mock.calls[0]?.[0]; expect(call?.sandboxRoot).toBeUndefined(); }); + + it("forwards trusted requesterSenderId to runMessageAction", async () => { + mockSendResult({ to: "discord:123" }); + + const tool = createMessageTool({ + config: {} as never, + requesterSenderId: "1234567890", + }); + + await tool.execute("1", { + action: "send", + target: "discord:123", + message: "hi", + }); + + const call = mocks.runMessageAction.mock.calls[0]?.[0]; + expect(call?.requesterSenderId).toBe("1234567890"); + }); }); diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index 48f1b8013a05b..f2d2616badbad 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -1,4 +1,6 @@ import { Type } from "@sinclair/typebox"; +import type { OpenClawConfig } from "../../config/config.js"; +import type { AnyAgentTool } from "./common.js"; import { BLUEBUBBLES_GROUP_ACTIONS } from "../../channels/plugins/bluebubbles-actions.js"; import { listChannelMessageActions, @@ -11,7 +13,6 @@ import { CHANNEL_MESSAGE_ACTION_NAMES, type ChannelMessageActionName, } from "../../channels/plugins/types.js"; -import type { OpenClawConfig } from "../../config/config.js"; import { loadConfig } from "../../config/config.js"; import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../../gateway/protocol/client-info.js"; import { getToolResult, runMessageAction } from "../../infra/outbound/message-action-runner.js"; @@ -22,7 +23,6 @@ import { normalizeMessageChannel } from "../../utils/message-channel.js"; import { resolveSessionAgentId } from "../agent-scope.js"; import { listChannelSupportedActions } from "../channel-tools.js"; import { channelTargetSchema, channelTargetsSchema, stringEnum } from "../schema/typebox.js"; -import type { AnyAgentTool } from "./common.js"; import { jsonResult, readNumberParam, readStringParam } from "./common.js"; import { resolveGatewayOptions } from "./gateway.js"; @@ -429,6 +429,7 @@ type MessageToolOptions = { hasRepliedRef?: { value: boolean }; sandboxRoot?: string; requireExplicitTarget?: boolean; + requesterSenderId?: string; }; function resolveMessageToolSchemaActions(params: { @@ -656,6 +657,7 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool { action, params, defaultAccountId: accountId ?? undefined, + requesterSenderId: options?.requesterSenderId, gateway, toolContext, sessionKey: options?.agentSessionKey, diff --git a/src/channels/plugins/actions/actions.test.ts b/src/channels/plugins/actions/actions.test.ts index 71d4fe090eaf2..40e10836e31e8 100644 --- a/src/channels/plugins/actions/actions.test.ts +++ b/src/channels/plugins/actions/actions.test.ts @@ -353,6 +353,47 @@ describe("handleDiscordMessageAction", () => { expect.any(Object), ); }); + + it("uses trusted requesterSenderId for moderation and ignores params senderUserId", async () => { + await handleDiscordMessageAction({ + action: "timeout", + params: { + guildId: "guild-1", + userId: "user-2", + durationMin: 5, + senderUserId: "spoofed-admin-id", + }, + cfg: {} as OpenClawConfig, + requesterSenderId: "trusted-sender-id", + toolContext: { currentChannelProvider: "discord" }, + }); + + expect(handleDiscordAction).toHaveBeenCalledWith( + expect.objectContaining({ + action: "timeout", + guildId: "guild-1", + userId: "user-2", + durationMinutes: 5, + senderUserId: "trusted-sender-id", + }), + expect.any(Object), + ); + }); + + it("rejects moderation when trusted sender id is missing in Discord tool context", async () => { + await expect( + handleDiscordMessageAction({ + action: "kick", + params: { + guildId: "guild-1", + userId: "user-2", + }, + cfg: {} as OpenClawConfig, + toolContext: { currentChannelProvider: "discord" }, + }), + ).rejects.toThrow("Sender user ID required for Discord moderation actions."); + expect(handleDiscordAction).not.toHaveBeenCalled(); + }); }); describe("telegramMessageActions", () => { diff --git a/src/channels/plugins/actions/discord/handle-action.guild-admin.ts b/src/channels/plugins/actions/discord/handle-action.guild-admin.ts index a52ab21efd7b7..e350fa54a54c3 100644 --- a/src/channels/plugins/actions/discord/handle-action.guild-admin.ts +++ b/src/channels/plugins/actions/discord/handle-action.guild-admin.ts @@ -1,13 +1,16 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; +import type { ChannelMessageActionContext } from "../../types.js"; import { readNumberParam, readStringArrayParam, readStringParam, } from "../../../../agents/tools/common.js"; import { handleDiscordAction } from "../../../../agents/tools/discord-actions.js"; -import type { ChannelMessageActionContext } from "../../types.js"; -type Ctx = Pick; +type Ctx = Pick< + ChannelMessageActionContext, + "action" | "params" | "cfg" | "accountId" | "requesterSenderId" | "toolContext" +>; export async function tryHandleDiscordMessageActionGuildAdmin(params: { ctx: Ctx; @@ -355,6 +358,11 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: { const deleteMessageDays = readNumberParam(actionParams, "deleteDays", { integer: true, }); + const senderUserId = ctx.requesterSenderId?.trim() || undefined; + // In channel/tool flows, require trusted sender identity for moderation authorization. + if (ctx.toolContext?.currentChannelProvider === "discord" && !senderUserId) { + throw new Error("Sender user ID required for Discord moderation actions."); + } const discordAction = action; return await handleDiscordAction( { @@ -366,6 +374,7 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: { until, reason, deleteMessageDays, + senderUserId, }, cfg, ); diff --git a/src/channels/plugins/actions/discord/handle-action.ts b/src/channels/plugins/actions/discord/handle-action.ts index 91d0206328da4..af737223f1d10 100644 --- a/src/channels/plugins/actions/discord/handle-action.ts +++ b/src/channels/plugins/actions/discord/handle-action.ts @@ -1,4 +1,5 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; +import type { ChannelMessageActionContext } from "../../types.js"; import { readNumberParam, readStringArrayParam, @@ -6,7 +7,6 @@ import { } from "../../../../agents/tools/common.js"; import { handleDiscordAction } from "../../../../agents/tools/discord-actions.js"; import { resolveDiscordChannelId } from "../../../../discord/targets.js"; -import type { ChannelMessageActionContext } from "../../types.js"; import { tryHandleDiscordMessageActionGuildAdmin } from "./handle-action.guild-admin.js"; const providerId = "discord"; @@ -22,7 +22,10 @@ function readParentIdParam(params: Record): string | null | und } export async function handleDiscordMessageAction( - ctx: Pick, + ctx: Pick< + ChannelMessageActionContext, + "action" | "params" | "cfg" | "accountId" | "requesterSenderId" | "toolContext" + >, ): Promise> { const { action, params, cfg } = ctx; const accountId = ctx.accountId ?? readStringParam(params, "accountId"); diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index 2178acd5eeeeb..63fde936deb3a 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -304,6 +304,11 @@ export type ChannelMessageActionContext = { cfg: OpenClawConfig; params: Record; accountId?: string | null; + /** + * Trusted sender id from inbound context. This is server-injected and must + * never be sourced from tool/model-controlled params. + */ + requesterSenderId?: string | null; gateway?: { url?: string; token?: string; diff --git a/src/discord/send.permissions.authz.test.ts b/src/discord/send.permissions.authz.test.ts new file mode 100644 index 0000000000000..ed62d22dfd6b0 --- /dev/null +++ b/src/discord/send.permissions.authz.test.ts @@ -0,0 +1,128 @@ +import type { RequestClient } from "@buape/carbon"; +import { PermissionFlagsBits, Routes } from "discord-api-types/v10"; +import { describe, expect, it, vi } from "vitest"; +import { + fetchMemberGuildPermissionsDiscord, + hasGuildPermissionDiscord, +} from "./send.permissions.js"; + +const mockRest = vi.hoisted(() => ({ + get: vi.fn(), +})); + +vi.mock("./client.js", () => ({ + resolveDiscordRest: () => mockRest as unknown as RequestClient, +})); + +describe("discord guild permission authorization", () => { + describe("fetchMemberGuildPermissionsDiscord", () => { + it("returns null when user is not a guild member", async () => { + mockRest.get.mockRejectedValueOnce(new Error("404 Member not found")); + + const result = await fetchMemberGuildPermissionsDiscord("guild-1", "user-1"); + expect(result).toBeNull(); + }); + + it("includes @everyone and member roles in computed permissions", async () => { + mockRest.get.mockImplementation(async (route: string) => { + if (route === Routes.guild("guild-1")) { + return { + id: "guild-1", + roles: [ + { id: "guild-1", permissions: PermissionFlagsBits.ViewChannel.toString() }, + { id: "role-mod", permissions: PermissionFlagsBits.KickMembers.toString() }, + ], + }; + } + if (route === Routes.guildMember("guild-1", "user-1")) { + return { + id: "user-1", + roles: ["role-mod"], + }; + } + throw new Error(`Unexpected route: ${route}`); + }); + + const result = await fetchMemberGuildPermissionsDiscord("guild-1", "user-1"); + expect(result).not.toBeNull(); + expect((result! & PermissionFlagsBits.ViewChannel) === PermissionFlagsBits.ViewChannel).toBe( + true, + ); + expect((result! & PermissionFlagsBits.KickMembers) === PermissionFlagsBits.KickMembers).toBe( + true, + ); + }); + }); + + describe("hasGuildPermissionDiscord", () => { + it("returns true when user has required permission", async () => { + mockRest.get.mockImplementation(async (route: string) => { + if (route === Routes.guild("guild-1")) { + return { + id: "guild-1", + roles: [ + { id: "guild-1", permissions: "0" }, + { id: "role-mod", permissions: PermissionFlagsBits.KickMembers.toString() }, + ], + }; + } + if (route === Routes.guildMember("guild-1", "user-1")) { + return { id: "user-1", roles: ["role-mod"] }; + } + throw new Error(`Unexpected route: ${route}`); + }); + + const result = await hasGuildPermissionDiscord("guild-1", "user-1", [ + PermissionFlagsBits.KickMembers, + ]); + expect(result).toBe(true); + }); + + it("returns true when user has ADMINISTRATOR", async () => { + mockRest.get.mockImplementation(async (route: string) => { + if (route === Routes.guild("guild-1")) { + return { + id: "guild-1", + roles: [ + { id: "guild-1", permissions: "0" }, + { + id: "role-admin", + permissions: PermissionFlagsBits.Administrator.toString(), + }, + ], + }; + } + if (route === Routes.guildMember("guild-1", "user-1")) { + return { id: "user-1", roles: ["role-admin"] }; + } + throw new Error(`Unexpected route: ${route}`); + }); + + const result = await hasGuildPermissionDiscord("guild-1", "user-1", [ + PermissionFlagsBits.KickMembers, + ]); + expect(result).toBe(true); + }); + + it("returns false when user lacks all required permissions", async () => { + mockRest.get.mockImplementation(async (route: string) => { + if (route === Routes.guild("guild-1")) { + return { + id: "guild-1", + roles: [{ id: "guild-1", permissions: PermissionFlagsBits.ViewChannel.toString() }], + }; + } + if (route === Routes.guildMember("guild-1", "user-1")) { + return { id: "user-1", roles: [] }; + } + throw new Error(`Unexpected route: ${route}`); + }); + + const result = await hasGuildPermissionDiscord("guild-1", "user-1", [ + PermissionFlagsBits.BanMembers, + PermissionFlagsBits.KickMembers, + ]); + expect(result).toBe(false); + }); + }); +}); diff --git a/src/discord/send.permissions.ts b/src/discord/send.permissions.ts index c24b6a65bdc9a..38d2e7a2182cc 100644 --- a/src/discord/send.permissions.ts +++ b/src/discord/send.permissions.ts @@ -1,8 +1,8 @@ import type { RequestClient } from "@buape/carbon"; import type { APIChannel, APIGuild, APIGuildMember, APIRole } from "discord-api-types/v10"; import { ChannelType, PermissionFlagsBits, Routes } from "discord-api-types/v10"; -import { resolveDiscordRest } from "./client.js"; import type { DiscordPermissionsSummary, DiscordReactOpts } from "./send.types.js"; +import { resolveDiscordRest } from "./client.js"; const PERMISSION_ENTRIES = Object.entries(PermissionFlagsBits).filter( ([, value]) => typeof value === "bigint", @@ -34,6 +34,10 @@ function hasAdministrator(bitfield: bigint) { return (bitfield & ADMINISTRATOR_BIT) === ADMINISTRATOR_BIT; } +function hasPermissionBit(bitfield: bigint, permission: bigint) { + return (bitfield & permission) === permission; +} + export function isThreadChannelType(channelType?: number) { return ( channelType === ChannelType.GuildNewsThread || @@ -50,6 +54,58 @@ async function fetchBotUserId(rest: RequestClient) { return me.id; } +/** + * Fetch guild-level permissions for a user. This does not include channel-specific overwrites. + */ +export async function fetchMemberGuildPermissionsDiscord( + guildId: string, + userId: string, + opts: DiscordReactOpts = {}, +): Promise { + const rest = resolveDiscordRest(opts); + try { + const [guild, member] = await Promise.all([ + rest.get(Routes.guild(guildId)) as Promise, + rest.get(Routes.guildMember(guildId, userId)) as Promise, + ]); + const rolesById = new Map((guild.roles ?? []).map((role) => [role.id, role])); + const everyoneRole = rolesById.get(guildId); + let permissions = 0n; + if (everyoneRole?.permissions) { + permissions = addPermissionBits(permissions, everyoneRole.permissions); + } + for (const roleId of member.roles ?? []) { + const role = rolesById.get(roleId); + if (role?.permissions) { + permissions = addPermissionBits(permissions, role.permissions); + } + } + return permissions; + } catch { + // Not a guild member, guild not found, or API failure. + return null; + } +} + +/** + * Returns true when the user has ADMINISTRATOR or any required permission bit. + */ +export async function hasGuildPermissionDiscord( + guildId: string, + userId: string, + requiredPermissions: bigint[], + opts: DiscordReactOpts = {}, +): Promise { + const permissions = await fetchMemberGuildPermissionsDiscord(guildId, userId, opts); + if (permissions === null) { + return false; + } + if (hasAdministrator(permissions)) { + return true; + } + return requiredPermissions.some((permission) => hasPermissionBit(permissions, permission)); +} + export async function fetchChannelPermissionsDiscord( channelId: string, opts: DiscordReactOpts = {}, diff --git a/src/discord/send.ts b/src/discord/send.ts index ee247ab6a29df..fb1c258bfc9fc 100644 --- a/src/discord/send.ts +++ b/src/discord/send.ts @@ -46,6 +46,10 @@ export { export { sendDiscordComponentMessage } from "./send.components.js"; export { fetchChannelPermissionsDiscord, + fetchMemberGuildPermissionsDiscord, + hasGuildPermissionDiscord, +} from "./send.permissions.js"; +export { fetchReactionsDiscord, reactMessageDiscord, removeOwnReactionsDiscord, diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index b48a36ff0bae0..20cdad8ec44a7 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -1,4 +1,12 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; +import type { + ChannelId, + ChannelMessageActionName, + ChannelThreadingToolContext, +} from "../../channels/plugins/types.js"; +import type { OpenClawConfig } from "../../config/config.js"; +import type { OutboundSendDeps } from "./deliver.js"; +import type { MessagePollResult, MessageSendResult } from "./message.js"; import { resolveSessionAgentId } from "../../agents/agent-scope.js"; import { readNumberParam, @@ -7,12 +15,6 @@ import { } from "../../agents/tools/common.js"; import { parseReplyDirectives } from "../../auto-reply/reply/reply-directives.js"; import { dispatchChannelMessageAction } from "../../channels/plugins/message-actions.js"; -import type { - ChannelId, - ChannelMessageActionName, - ChannelThreadingToolContext, -} from "../../channels/plugins/types.js"; -import type { OpenClawConfig } from "../../config/config.js"; import { isDeliverableMessageChannel, normalizeMessageChannel, @@ -25,7 +27,6 @@ import { resolveMessageChannelSelection, } from "./channel-selection.js"; import { applyTargetToParams } from "./channel-target.js"; -import type { OutboundSendDeps } from "./deliver.js"; import { hydrateSendAttachmentParams, hydrateSetGroupIconParams, @@ -39,7 +40,6 @@ import { resolveTelegramAutoThreadId, } from "./message-action-params.js"; import { actionHasTarget, actionRequiresTarget } from "./message-action-spec.js"; -import type { MessagePollResult, MessageSendResult } from "./message.js"; import { applyCrossContextDecoration, buildCrossContextDecoration, @@ -93,6 +93,7 @@ export type RunMessageActionParams = { action: ChannelMessageActionName; params: Record; defaultAccountId?: string; + requesterSenderId?: string | null; toolContext?: ChannelThreadingToolContext; gateway?: MessageActionRunnerGateway; deps?: OutboundSendDeps; @@ -668,6 +669,7 @@ async function handlePluginAction(ctx: ResolvedActionContext): Promise Date: Thu, 19 Feb 2026 15:24:02 +0100 Subject: [PATCH 0012/1006] refactor(plugins): extract safety and provenance helpers --- CHANGELOG.md | 1 + src/plugins/discovery.test.ts | 23 ++-- src/plugins/discovery.ts | 203 +++++++++++++++++++++---------- src/plugins/loader.ts | 146 +++++++++++++--------- src/plugins/manifest-registry.ts | 15 +-- src/plugins/path-safety.ts | 36 ++++++ 6 files changed, 277 insertions(+), 147 deletions(-) create mode 100644 src/plugins/path-safety.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 36950bbdf08f1..ee4cdc59c20ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ Docs: https://docs.openclaw.ai - Security/ACP: harden ACP bridge session management with duplicate-session refresh, idle-session reaping, oldest-idle soft-cap eviction, and burst rate limiting on session creation to reduce local DoS risk without disrupting normal IDE usage. - Security/Plugins/Hooks: add optional `--pin` for npm plugin/hook installs, persist resolved npm metadata (`name`, `version`, `spec`, integrity, shasum, timestamp), warn/confirm on integrity drift during updates, and extend `openclaw security audit` to flag unpinned specs, missing integrity metadata, and install-record version drift. - Security/Plugins: harden plugin discovery by blocking unsafe candidates (root escapes, world-writable paths, suspicious ownership), add startup warnings when `plugins.allow` is empty with discoverable non-bundled plugins, and warn on loaded plugins without install/load-path provenance. +- Refactor/Plugins: extract shared plugin path-safety utilities, split discovery safety checks into typed reasoned guards, precompute provenance matchers during plugin load, and switch ownership tests to injected uid inputs. - Security/Gateway: rate-limit control-plane write RPCs (`config.apply`, `config.patch`, `update.run`) to 3 requests per minute per `deviceId+clientIp`, add restart single-flight coalescing plus a 30-second restart cooldown, and log actor/device/ip with changed-path audit details for config/update-triggered restarts. - Commands/Doctor: skip embedding-provider warnings when `memory.backend` is `qmd`, because QMD manages embeddings internally and does not require `memorySearch` providers. (#17263) Thanks @miloudbelarebia. - Security/Webhooks: harden Feishu and Zalo webhook ingress with webhook-mode token preconditions, loopback-default Feishu bind host, JSON content-type enforcement, per-path rate limiting, replay dedupe for Zalo events, constant-time Zalo secret comparison, and anomaly status counters. diff --git a/src/plugins/discovery.test.ts b/src/plugins/discovery.test.ts index 1a81a46024d61..11a5716461de4 100644 --- a/src/plugins/discovery.test.ts +++ b/src/plugins/discovery.test.ts @@ -206,21 +206,14 @@ describe("discoverOpenClawPlugins", () => { "utf-8", ); - const proc = process as NodeJS.Process & { getuid: () => number }; - const originalGetUid = proc.getuid; - const actualUid = originalGetUid(); - try { - proc.getuid = () => actualUid + 1; - const result = await withStateDir(stateDir, async () => { - return discoverOpenClawPlugins({}); - }); - expect(result.candidates).toHaveLength(0); - expect( - result.diagnostics.some((diag) => diag.message.includes("suspicious ownership")), - ).toBe(true); - } finally { - proc.getuid = originalGetUid; - } + const actualUid = (process as NodeJS.Process & { getuid: () => number }).getuid(); + const result = await withStateDir(stateDir, async () => { + return discoverOpenClawPlugins({ ownershipUid: actualUid + 1 }); + }); + expect(result.candidates).toHaveLength(0); + expect(result.diagnostics.some((diag) => diag.message.includes("suspicious ownership"))).toBe( + true, + ); }, ); }); diff --git a/src/plugins/discovery.ts b/src/plugins/discovery.ts index 3625ab9a1c83a..747508142ab5d 100644 --- a/src/plugins/discovery.ts +++ b/src/plugins/discovery.ts @@ -7,6 +7,7 @@ import { type OpenClawPackageManifest, type PackageManifest, } from "./manifest.js"; +import { formatPosixMode, isPathInside, safeRealpathSync, safeStatSync } from "./path-safety.js"; import type { PluginDiagnostic, PluginOrigin } from "./types.js"; const EXTENSION_EXTS = new Set([".ts", ".js", ".mts", ".cts", ".mjs", ".cjs"]); @@ -29,66 +30,68 @@ export type PluginDiscoveryResult = { diagnostics: PluginDiagnostic[]; }; -function isPathInside(baseDir: string, targetPath: string): boolean { - const rel = path.relative(baseDir, targetPath); - if (!rel) { - return true; +function currentUid(overrideUid?: number | null): number | null { + if (overrideUid !== undefined) { + return overrideUid; } - return !rel.startsWith("..") && !path.isAbsolute(rel); -} - -function safeRealpathSync(targetPath: string): string | null { - try { - return fs.realpathSync(targetPath); - } catch { + if (process.platform === "win32") { return null; } -} - -function safeStatSync(targetPath: string): fs.Stats | null { - try { - return fs.statSync(targetPath); - } catch { + if (typeof process.getuid !== "function") { return null; } + return process.getuid(); } -function formatMode(mode: number): string { - return (mode & 0o777).toString(8).padStart(3, "0"); -} +export type CandidateBlockReason = + | "source_escapes_root" + | "path_stat_failed" + | "path_world_writable" + | "path_suspicious_ownership"; + +type CandidateBlockIssue = { + reason: CandidateBlockReason; + sourcePath: string; + rootPath: string; + targetPath: string; + sourceRealPath?: string; + rootRealPath?: string; + modeBits?: number; + foundUid?: number; + expectedUid?: number; +}; -function currentUid(): number | null { - if (process.platform === "win32") { +function checkSourceEscapesRoot(params: { + source: string; + rootDir: string; +}): CandidateBlockIssue | null { + const sourceRealPath = safeRealpathSync(params.source); + const rootRealPath = safeRealpathSync(params.rootDir); + if (!sourceRealPath || !rootRealPath) { return null; } - if (typeof process.getuid !== "function") { + if (isPathInside(rootRealPath, sourceRealPath)) { return null; } - return process.getuid(); + return { + reason: "source_escapes_root", + sourcePath: params.source, + rootPath: params.rootDir, + targetPath: params.source, + sourceRealPath, + rootRealPath, + }; } -function isUnsafePluginCandidate(params: { +function checkPathStatAndPermissions(params: { source: string; rootDir: string; origin: PluginOrigin; - diagnostics: PluginDiagnostic[]; -}): boolean { - const sourceReal = safeRealpathSync(params.source); - const rootReal = safeRealpathSync(params.rootDir); - if (sourceReal && rootReal && !isPathInside(rootReal, sourceReal)) { - params.diagnostics.push({ - level: "warn", - source: params.source, - message: `blocked plugin candidate: source escapes plugin root (${params.source} -> ${sourceReal}; root=${rootReal})`, - }); - return true; - } - + uid: number | null; +}): CandidateBlockIssue | null { if (process.platform === "win32") { - return false; + return null; } - - const uid = currentUid(); const pathsToCheck = [params.rootDir, params.source]; const seen = new Set(); for (const targetPath of pathsToCheck) { @@ -99,39 +102,99 @@ function isUnsafePluginCandidate(params: { seen.add(normalized); const stat = safeStatSync(targetPath); if (!stat) { - params.diagnostics.push({ - level: "warn", - source: targetPath, - message: `blocked plugin candidate: cannot stat path (${targetPath})`, - }); - return true; + return { + reason: "path_stat_failed", + sourcePath: params.source, + rootPath: params.rootDir, + targetPath, + }; } const modeBits = stat.mode & 0o777; if ((modeBits & 0o002) !== 0) { - params.diagnostics.push({ - level: "warn", - source: targetPath, - message: `blocked plugin candidate: world-writable path (${targetPath}, mode=${formatMode(modeBits)})`, - }); - return true; + return { + reason: "path_world_writable", + sourcePath: params.source, + rootPath: params.rootDir, + targetPath, + modeBits, + }; } if ( params.origin !== "bundled" && - uid !== null && + params.uid !== null && typeof stat.uid === "number" && - stat.uid !== uid && + stat.uid !== params.uid && stat.uid !== 0 ) { - params.diagnostics.push({ - level: "warn", - source: targetPath, - message: `blocked plugin candidate: suspicious ownership (${targetPath}, uid=${stat.uid}, expected uid=${uid} or root)`, - }); - return true; + return { + reason: "path_suspicious_ownership", + sourcePath: params.source, + rootPath: params.rootDir, + targetPath, + foundUid: stat.uid, + expectedUid: params.uid, + }; } } + return null; +} + +function findCandidateBlockIssue(params: { + source: string; + rootDir: string; + origin: PluginOrigin; + ownershipUid?: number | null; +}): CandidateBlockIssue | null { + const escaped = checkSourceEscapesRoot({ + source: params.source, + rootDir: params.rootDir, + }); + if (escaped) { + return escaped; + } + return checkPathStatAndPermissions({ + source: params.source, + rootDir: params.rootDir, + origin: params.origin, + uid: currentUid(params.ownershipUid), + }); +} + +function formatCandidateBlockMessage(issue: CandidateBlockIssue): string { + if (issue.reason === "source_escapes_root") { + return `blocked plugin candidate: source escapes plugin root (${issue.sourcePath} -> ${issue.sourceRealPath}; root=${issue.rootRealPath})`; + } + if (issue.reason === "path_stat_failed") { + return `blocked plugin candidate: cannot stat path (${issue.targetPath})`; + } + if (issue.reason === "path_world_writable") { + return `blocked plugin candidate: world-writable path (${issue.targetPath}, mode=${formatPosixMode(issue.modeBits ?? 0)})`; + } + return `blocked plugin candidate: suspicious ownership (${issue.targetPath}, uid=${issue.foundUid}, expected uid=${issue.expectedUid} or root)`; +} - return false; +function isUnsafePluginCandidate(params: { + source: string; + rootDir: string; + origin: PluginOrigin; + diagnostics: PluginDiagnostic[]; + ownershipUid?: number | null; +}): boolean { + const issue = findCandidateBlockIssue({ + source: params.source, + rootDir: params.rootDir, + origin: params.origin, + ownershipUid: params.ownershipUid, + }); + if (!issue) { + return false; + } + params.diagnostics.push({ + level: "warn", + source: issue.targetPath, + message: formatCandidateBlockMessage(issue), + }); + return true; } function isExtensionFile(filePath: string): boolean { @@ -194,6 +257,7 @@ function addCandidate(params: { source: string; rootDir: string; origin: PluginOrigin; + ownershipUid?: number | null; workspaceDir?: string; manifest?: PackageManifest | null; packageDir?: string; @@ -209,6 +273,7 @@ function addCandidate(params: { rootDir: resolvedRoot, origin: params.origin, diagnostics: params.diagnostics, + ownershipUid: params.ownershipUid, }) ) { return; @@ -232,6 +297,7 @@ function addCandidate(params: { function discoverInDirectory(params: { dir: string; origin: PluginOrigin; + ownershipUid?: number | null; workspaceDir?: string; candidates: PluginCandidate[]; diagnostics: PluginDiagnostic[]; @@ -266,6 +332,7 @@ function discoverInDirectory(params: { source: fullPath, rootDir: path.dirname(fullPath), origin: params.origin, + ownershipUid: params.ownershipUid, workspaceDir: params.workspaceDir, }); } @@ -291,6 +358,7 @@ function discoverInDirectory(params: { source: resolved, rootDir: fullPath, origin: params.origin, + ownershipUid: params.ownershipUid, workspaceDir: params.workspaceDir, manifest, packageDir: fullPath, @@ -312,6 +380,7 @@ function discoverInDirectory(params: { source: indexFile, rootDir: fullPath, origin: params.origin, + ownershipUid: params.ownershipUid, workspaceDir: params.workspaceDir, manifest, packageDir: fullPath, @@ -323,6 +392,7 @@ function discoverInDirectory(params: { function discoverFromPath(params: { rawPath: string; origin: PluginOrigin; + ownershipUid?: number | null; workspaceDir?: string; candidates: PluginCandidate[]; diagnostics: PluginDiagnostic[]; @@ -356,6 +426,7 @@ function discoverFromPath(params: { source: resolved, rootDir: path.dirname(resolved), origin: params.origin, + ownershipUid: params.ownershipUid, workspaceDir: params.workspaceDir, }); return; @@ -380,6 +451,7 @@ function discoverFromPath(params: { source, rootDir: resolved, origin: params.origin, + ownershipUid: params.ownershipUid, workspaceDir: params.workspaceDir, manifest, packageDir: resolved, @@ -402,6 +474,7 @@ function discoverFromPath(params: { source: indexFile, rootDir: resolved, origin: params.origin, + ownershipUid: params.ownershipUid, workspaceDir: params.workspaceDir, manifest, packageDir: resolved, @@ -412,6 +485,7 @@ function discoverFromPath(params: { discoverInDirectory({ dir: resolved, origin: params.origin, + ownershipUid: params.ownershipUid, workspaceDir: params.workspaceDir, candidates: params.candidates, diagnostics: params.diagnostics, @@ -424,6 +498,7 @@ function discoverFromPath(params: { export function discoverOpenClawPlugins(params: { workspaceDir?: string; extraPaths?: string[]; + ownershipUid?: number | null; }): PluginDiscoveryResult { const candidates: PluginCandidate[] = []; const diagnostics: PluginDiagnostic[] = []; @@ -442,6 +517,7 @@ export function discoverOpenClawPlugins(params: { discoverFromPath({ rawPath: trimmed, origin: "config", + ownershipUid: params.ownershipUid, workspaceDir: workspaceDir?.trim() || undefined, candidates, diagnostics, @@ -455,6 +531,7 @@ export function discoverOpenClawPlugins(params: { discoverInDirectory({ dir, origin: "workspace", + ownershipUid: params.ownershipUid, workspaceDir: workspaceRoot, candidates, diagnostics, @@ -467,6 +544,7 @@ export function discoverOpenClawPlugins(params: { discoverInDirectory({ dir: globalDir, origin: "global", + ownershipUid: params.ownershipUid, candidates, diagnostics, seen, @@ -477,6 +555,7 @@ export function discoverOpenClawPlugins(params: { discoverInDirectory({ dir: bundledDir, origin: "bundled", + ownershipUid: params.ownershipUid, candidates, diagnostics, seen, diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index ab688865f0830..c88483e86827a 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -17,6 +17,7 @@ import { import { discoverOpenClawPlugins } from "./discovery.js"; import { initializeGlobalHookRunner } from "./hook-runner-global.js"; import { loadPluginManifestRegistry } from "./manifest-registry.js"; +import { isPathInside, safeStatSync } from "./path-safety.js"; import { createPluginRegistry, type PluginRecord, type PluginRegistry } from "./registry.js"; import { setActivePluginRegistry } from "./runtime.js"; import { createPluginRuntime } from "./runtime/index.js"; @@ -177,61 +178,100 @@ function pushDiagnostics(diagnostics: PluginDiagnostic[], append: PluginDiagnost diagnostics.push(...append); } -function isPathInside(baseDir: string, targetPath: string): boolean { - const rel = path.relative(baseDir, targetPath); - if (!rel) { - return true; +type PathMatcher = { + exact: Set; + dirs: string[]; +}; + +type InstallTrackingRule = { + trackedWithoutPaths: boolean; + matcher: PathMatcher; +}; + +type PluginProvenanceIndex = { + loadPathMatcher: PathMatcher; + installRules: Map; +}; + +function createPathMatcher(): PathMatcher { + return { exact: new Set(), dirs: [] }; +} + +function addPathToMatcher(matcher: PathMatcher, rawPath: string): void { + const trimmed = rawPath.trim(); + if (!trimmed) { + return; } - return !rel.startsWith("..") && !path.isAbsolute(rel); + const resolved = resolveUserPath(trimmed); + if (!resolved) { + return; + } + if (matcher.exact.has(resolved) || matcher.dirs.includes(resolved)) { + return; + } + const stat = safeStatSync(resolved); + if (stat?.isDirectory()) { + matcher.dirs.push(resolved); + return; + } + matcher.exact.add(resolved); } -function pathMatchesBaseOrFile(params: { baseOrFile: string; targetFile: string }): boolean { - const baseResolved = resolveUserPath(params.baseOrFile); - const targetResolved = resolveUserPath(params.targetFile); - if (baseResolved === targetResolved) { +function matchesPathMatcher(matcher: PathMatcher, sourcePath: string): boolean { + if (matcher.exact.has(sourcePath)) { return true; } - try { - const stat = fs.statSync(baseResolved); - if (!stat.isDirectory()) { - return false; + return matcher.dirs.some((dirPath) => isPathInside(dirPath, sourcePath)); +} + +function buildProvenanceIndex(params: { + config: OpenClawConfig; + normalizedLoadPaths: string[]; +}): PluginProvenanceIndex { + const loadPathMatcher = createPathMatcher(); + for (const loadPath of params.normalizedLoadPaths) { + addPathToMatcher(loadPathMatcher, loadPath); + } + + const installRules = new Map(); + const installs = params.config.plugins?.installs ?? {}; + for (const [pluginId, install] of Object.entries(installs)) { + const rule: InstallTrackingRule = { + trackedWithoutPaths: false, + matcher: createPathMatcher(), + }; + const trackedPaths = [install.installPath, install.sourcePath] + .map((entry) => (typeof entry === "string" ? entry.trim() : "")) + .filter(Boolean); + if (trackedPaths.length === 0) { + rule.trackedWithoutPaths = true; + } else { + for (const trackedPath of trackedPaths) { + addPathToMatcher(rule.matcher, trackedPath); + } } - } catch { - return false; + installRules.set(pluginId, rule); } - return isPathInside(baseResolved, targetResolved); + + return { loadPathMatcher, installRules }; } -function isTrackedByInstallRecord(params: { +function isTrackedByProvenance(params: { pluginId: string; source: string; - config: OpenClawConfig; + index: PluginProvenanceIndex; }): boolean { - const install = params.config.plugins?.installs?.[params.pluginId]; - if (!install) { - return false; - } - const trackedPaths = [install.installPath, install.sourcePath] - .map((entry) => (typeof entry === "string" ? entry.trim() : "")) - .filter(Boolean); - if (trackedPaths.length === 0) { - return true; + const sourcePath = resolveUserPath(params.source); + const installRule = params.index.installRules.get(params.pluginId); + if (installRule) { + if (installRule.trackedWithoutPaths) { + return true; + } + if (matchesPathMatcher(installRule.matcher, sourcePath)) { + return true; + } } - return trackedPaths.some((trackedPath) => - pathMatchesBaseOrFile({ - baseOrFile: trackedPath, - targetFile: params.source, - }), - ); -} - -function isTrackedByLoadPath(params: { source: string; loadPaths: string[] }): boolean { - return params.loadPaths.some((loadPath) => - pathMatchesBaseOrFile({ - baseOrFile: loadPath, - targetFile: params.source, - }), - ); + return matchesPathMatcher(params.index.loadPathMatcher, sourcePath); } function warnWhenAllowlistIsOpen(params: { @@ -262,8 +302,7 @@ function warnWhenAllowlistIsOpen(params: { function warnAboutUntrackedLoadedPlugins(params: { registry: PluginRegistry; - config: OpenClawConfig; - normalizedLoadPaths: string[]; + provenance: PluginProvenanceIndex; logger: PluginLogger; }) { for (const plugin of params.registry.plugins) { @@ -271,18 +310,10 @@ function warnAboutUntrackedLoadedPlugins(params: { continue; } if ( - isTrackedByInstallRecord({ + isTrackedByProvenance({ pluginId: plugin.id, source: plugin.source, - config: params.config, - }) - ) { - continue; - } - if ( - isTrackedByLoadPath({ - source: plugin.source, - loadPaths: params.normalizedLoadPaths, + index: params.provenance, }) ) { continue; @@ -351,6 +382,10 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi origin: plugin.origin, })), }); + const provenance = buildProvenanceIndex({ + config: cfg, + normalizedLoadPaths: normalized.loadPaths, + }); // Lazy: avoid creating the Jiti loader when all plugins are disabled (common in unit tests). let jitiLoader: ReturnType | null = null; @@ -605,8 +640,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi warnAboutUntrackedLoadedPlugins({ registry, - config: cfg, - normalizedLoadPaths: normalized.loadPaths, + provenance, logger, }); diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index 8929a664f4eba..80313e99fd6ff 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -4,6 +4,7 @@ import { resolveUserPath } from "../utils.js"; import { normalizePluginsConfig, type NormalizedPluginsConfig } from "./config-state.js"; import { discoverOpenClawPlugins, type PluginCandidate } from "./discovery.js"; import { loadPluginManifest, type PluginManifest } from "./manifest.js"; +import { safeRealpathSync } from "./path-safety.js"; import type { PluginConfigUiHint, PluginDiagnostic, PluginKind, PluginOrigin } from "./types.js"; type SeenIdEntry = { @@ -19,20 +20,6 @@ const PLUGIN_ORIGIN_RANK: Readonly> = { bundled: 3, }; -function safeRealpathSync(rootDir: string, cache: Map): string | null { - const cached = cache.get(rootDir); - if (cached) { - return cached; - } - try { - const resolved = fs.realpathSync(rootDir); - cache.set(rootDir, resolved); - return resolved; - } catch { - return null; - } -} - export type PluginManifestRecord = { id: string; name?: string; diff --git a/src/plugins/path-safety.ts b/src/plugins/path-safety.ts new file mode 100644 index 0000000000000..48c2da8e6fa31 --- /dev/null +++ b/src/plugins/path-safety.ts @@ -0,0 +1,36 @@ +import fs from "node:fs"; +import path from "node:path"; + +export function isPathInside(baseDir: string, targetPath: string): boolean { + const rel = path.relative(baseDir, targetPath); + if (!rel) { + return true; + } + return !rel.startsWith("..") && !path.isAbsolute(rel); +} + +export function safeRealpathSync(targetPath: string, cache?: Map): string | null { + const cached = cache?.get(targetPath); + if (cached) { + return cached; + } + try { + const resolved = fs.realpathSync(targetPath); + cache?.set(targetPath, resolved); + return resolved; + } catch { + return null; + } +} + +export function safeStatSync(targetPath: string): fs.Stats | null { + try { + return fs.statSync(targetPath); + } catch { + return null; + } +} + +export function formatPosixMode(mode: number): string { + return (mode & 0o777).toString(8).padStart(3, "0"); +} From 26c9b37f5b6ead671948bad985dab0980bb1120f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 15:24:03 +0100 Subject: [PATCH 0013/1006] fix(security): enforce strict IPv4 SSRF literal handling --- CHANGELOG.md | 2 +- src/infra/net/fetch-guard.ssrf.test.ts | 11 +++ src/infra/net/ssrf.pinning.test.ts | 11 +++ src/infra/net/ssrf.test.ts | 35 +++++-- src/infra/net/ssrf.ts | 125 +++++++++++++------------ src/shared/net/ipv4.ts | 7 +- 6 files changed, 119 insertions(+), 72 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee4cdc59c20ab..d5f8382dd5bfe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ Docs: https://docs.openclaw.ai ### Fixes -- Security/Net: harden SSRF IPv4 literal parsing to block octal/hex/short/packed legacy forms (for example `0177.0.0.1`, `127.1`, `2130706433`) in pre-DNS guard checks. +- Security/Net: enforce strict dotted-decimal IPv4 literals in SSRF checks and fail closed on unsupported legacy forms (octal/hex/short/packed, for example `0177.0.0.1`, `127.1`, `2130706433`) before DNS lookup. - Security/Discord: enforce trusted-sender guild permission checks for moderation actions (`timeout`, `kick`, `ban`) and ignore untrusted `senderUserId` params to prevent privilege escalation in tool-driven flows. Thanks @aether-ai-agent for reporting. - Security/ACP: harden ACP bridge session management with duplicate-session refresh, idle-session reaping, oldest-idle soft-cap eviction, and burst rate limiting on session creation to reduce local DoS risk without disrupting normal IDE usage. - Security/Plugins/Hooks: add optional `--pin` for npm plugin/hook installs, persist resolved npm metadata (`name`, `version`, `spec`, integrity, shasum, timestamp), warn/confirm on integrity drift during updates, and extend `openclaw security audit` to flag unpinned specs, missing integrity metadata, and install-record version drift. diff --git a/src/infra/net/fetch-guard.ssrf.test.ts b/src/infra/net/fetch-guard.ssrf.test.ts index fe6e60a59df2c..9971f07b98154 100644 --- a/src/infra/net/fetch-guard.ssrf.test.ts +++ b/src/infra/net/fetch-guard.ssrf.test.ts @@ -33,6 +33,17 @@ describe("fetchWithSsrFGuard hardening", () => { expect(fetchImpl).not.toHaveBeenCalled(); }); + it("blocks unsupported packed-hex loopback literal URLs before fetch", async () => { + const fetchImpl = vi.fn(); + await expect( + fetchWithSsrFGuard({ + url: "http://0x7f000001/internal", + fetchImpl, + }), + ).rejects.toThrow(/private|internal|blocked/i); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + it("blocks redirect chains that hop to private hosts", async () => { const lookupFn = vi.fn(async () => [ { address: "93.184.216.34", family: 4 }, diff --git a/src/infra/net/ssrf.pinning.test.ts b/src/infra/net/ssrf.pinning.test.ts index 14ee88bc49e17..b9e04df3c8a96 100644 --- a/src/infra/net/ssrf.pinning.test.ts +++ b/src/infra/net/ssrf.pinning.test.ts @@ -133,6 +133,17 @@ describe("ssrf pinning", () => { expect(lookup).not.toHaveBeenCalled(); }); + it("blocks unsupported short-form IPv4 literals before DNS lookup", async () => { + const lookup = vi.fn(async () => [ + { address: "93.184.216.34", family: 4 }, + ]) as unknown as LookupFn; + + await expect(resolvePinnedHostnameWithPolicy("8.8.2056", { lookupFn: lookup })).rejects.toThrow( + SsrFBlockedError, + ); + expect(lookup).not.toHaveBeenCalled(); + }); + it("allows ISATAP embedded private IPv4 when private network is explicitly enabled", async () => { const lookup = vi.fn(async () => [ { address: "2001:db8:1234::5efe:127.0.0.1", family: 6 }, diff --git a/src/infra/net/ssrf.test.ts b/src/infra/net/ssrf.test.ts index f2f571c3708a0..716cc21ebca27 100644 --- a/src/infra/net/ssrf.test.ts +++ b/src/infra/net/ssrf.test.ts @@ -26,12 +26,6 @@ const privateIpCases = [ "fec0::1", "2001:db8:1234::5efe:127.0.0.1", "2001:db8:1234:1:200:5efe:7f00:1", - "0177.0.0.1", - "0x7f.0.0.1", - "127.1", - "2130706433", - "0x7f000001", - "017700000001", ]; const publicIpCases = [ @@ -44,12 +38,25 @@ const publicIpCases = [ "2001:0000:0:0:0:0:f7f7:f7f7", "2001:db8:1234::5efe:8.8.8.8", "2001:db8:1234:1:1111:5efe:7f00:1", +]; + +const malformedIpv6Cases = ["::::", "2001:db8::gggg"]; +const unsupportedLegacyIpv4Cases = [ + "0177.0.0.1", + "0x7f.0.0.1", + "127.1", + "2130706433", + "0x7f000001", + "017700000001", "8.8.2056", "0x08080808", + "08.0.0.1", + "0x7g.0.0.1", + "127..0.1", + "999.1.1.1", ]; -const malformedIpv6Cases = ["::::", "2001:db8::gggg"]; -const malformedIpv4Cases = ["08.0.0.1", "0x7g.0.0.1", "127.0.0.1.", "127..0.1"]; +const nonIpHostnameCases = ["example.com", "abc.123.example", "1password.com", "0x.example.com"]; describe("ssrf ip classification", () => { it.each(privateIpCases)("classifies %s as private", (address) => { @@ -64,8 +71,15 @@ describe("ssrf ip classification", () => { expect(isPrivateIpAddress(address)).toBe(true); }); - it.each(malformedIpv4Cases)("treats malformed IPv4 literal %s as non-IP", (address) => { - expect(isPrivateIpAddress(address)).toBe(false); + it.each(unsupportedLegacyIpv4Cases)( + "fails closed for unsupported legacy IPv4 literal %s", + (address) => { + expect(isPrivateIpAddress(address)).toBe(true); + }, + ); + + it.each(nonIpHostnameCases)("does not treat hostname %s as an IP literal", (hostname) => { + expect(isPrivateIpAddress(hostname)).toBe(false); }); }); @@ -90,6 +104,7 @@ describe("isBlockedHostnameOrIp", () => { it("blocks legacy IPv4 literal representations", () => { expect(isBlockedHostnameOrIp("0177.0.0.1")).toBe(true); + expect(isBlockedHostnameOrIp("8.8.2056")).toBe(true); expect(isBlockedHostnameOrIp("127.1")).toBe(true); expect(isBlockedHostnameOrIp("2130706433")).toBe(true); }); diff --git a/src/infra/net/ssrf.ts b/src/infra/net/ssrf.ts index d3d621b2542aa..2cd7790883fe8 100644 --- a/src/infra/net/ssrf.ts +++ b/src/infra/net/ssrf.ts @@ -68,76 +68,77 @@ function matchesHostnameAllowlist(hostname: string, allowlist: string[]): boolea return allowlist.some((pattern) => isHostnameAllowedByPattern(hostname, pattern)); } +function parseStrictIpv4Octet(part: string): number | null { + if (!/^[0-9]+$/.test(part)) { + return null; + } + const value = Number.parseInt(part, 10); + if (Number.isNaN(value) || value < 0 || value > 255) { + return null; + } + // Accept only canonical decimal octets (no leading zeros, no alternate radices). + if (part !== String(value)) { + return null; + } + return value; +} + function parseIpv4(address: string): number[] | null { const parts = address.split("."); - if (parts.length < 1 || parts.length > 4) { + if (parts.length !== 4) { return null; } - - const numbers: number[] = []; for (const part of parts) { - if (!part) { - return null; - } - const lower = part.toLowerCase(); - let value: number; - if (lower.startsWith("0x")) { - const hex = lower.slice(2); - if (!hex || !/^[0-9a-f]+$/i.test(hex)) { - return null; - } - value = Number.parseInt(hex, 16); - } else if (part.length > 1 && part.startsWith("0")) { - const octal = part.slice(1); - if (!/^[0-7]+$/.test(octal)) { - return null; - } - value = Number.parseInt(octal, 8); - } else { - if (!/^[0-9]+$/.test(part)) { - return null; - } - value = Number.parseInt(part, 10); - } - if (!Number.isFinite(value) || value < 0) { + if (parseStrictIpv4Octet(part) === null) { return null; } - numbers.push(value); } + return parts.map((part) => Number.parseInt(part, 10)); +} - let ipv4Number: number; - if (numbers.length === 1) { - if (numbers[0] > 0xffffffff) { - return null; - } - ipv4Number = numbers[0]; - } else if (numbers.length === 2) { - if (numbers[0] > 0xff || numbers[1] > 0xffffff) { - return null; - } - ipv4Number = numbers[0] * 0x1000000 + numbers[1]; - } else if (numbers.length === 3) { - if (numbers[0] > 0xff || numbers[1] > 0xff || numbers[2] > 0xffff) { - return null; - } - ipv4Number = numbers[0] * 0x1000000 + numbers[1] * 0x10000 + numbers[2]; - } else { - if (numbers.some((value) => value > 0xff)) { - return null; - } - ipv4Number = numbers[0] * 0x1000000 + numbers[1] * 0x10000 + numbers[2] * 0x100 + numbers[3]; +function classifyIpv4Part(part: string): "decimal" | "hex" | "invalid-hex" | "non-numeric" { + if (/^0x[0-9a-f]+$/i.test(part)) { + return "hex"; + } + if (/^0x/i.test(part)) { + return "invalid-hex"; } + if (/^[0-9]+$/.test(part)) { + return "decimal"; + } + return "non-numeric"; +} - if (!Number.isSafeInteger(ipv4Number) || ipv4Number < 0 || ipv4Number > 0xffffffff) { - return null; +function isUnsupportedLegacyIpv4Literal(address: string): boolean { + const parts = address.split("."); + if (parts.length === 0 || parts.length > 4) { + return false; + } + if (parts.some((part) => part.length === 0)) { + return true; } - return [ - Math.floor(ipv4Number / 0x1000000) & 0xff, - Math.floor(ipv4Number / 0x10000) & 0xff, - Math.floor(ipv4Number / 0x100) & 0xff, - ipv4Number & 0xff, - ]; + const partKinds = parts.map(classifyIpv4Part); + if (partKinds.some((kind) => kind === "non-numeric")) { + return false; + } + if (partKinds.some((kind) => kind === "invalid-hex")) { + return true; + } + + if (parts.length !== 4) { + return true; + } + for (const part of parts) { + if (/^0x/i.test(part)) { + return true; + } + const value = Number.parseInt(part, 10); + if (Number.isNaN(value) || value > 255 || part !== String(value)) { + return true; + } + } + return false; } function stripIpv6ZoneId(address: string): string { @@ -365,10 +366,14 @@ export function isPrivateIpAddress(address: string): boolean { } const ipv4 = parseIpv4(normalized); - if (!ipv4) { - return false; + if (ipv4) { + return isPrivateIpv4(ipv4); } - return isPrivateIpv4(ipv4); + // Reject non-canonical IPv4 literal forms (octal/hex/short/packed) by default. + if (isUnsupportedLegacyIpv4Literal(normalized)) { + return true; + } + return false; } export function isBlockedHostname(hostname: string): boolean { diff --git a/src/shared/net/ipv4.ts b/src/shared/net/ipv4.ts index 3c511823c775b..0019a006791f8 100644 --- a/src/shared/net/ipv4.ts +++ b/src/shared/net/ipv4.ts @@ -1,4 +1,4 @@ -export function validateIPv4AddressInput(value: string | undefined): string | undefined { +export function validateDottedDecimalIPv4Input(value: string | undefined): string | undefined { if (!value) { return "IP address is required for custom bind mode"; } @@ -17,3 +17,8 @@ export function validateIPv4AddressInput(value: string | undefined): string | un } return "Invalid IPv4 address (each octet must be 0-255)"; } + +// Backward-compatible alias for callers using the old helper name. +export function validateIPv4AddressInput(value: string | undefined): string | undefined { + return validateDottedDecimalIPv4Input(value); +} From cb6b835a493aae053f0fa9801c11bb51208b6e60 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 13:59:27 +0000 Subject: [PATCH 0014/1006] test: dedupe heartbeat and action-runner fixtures --- .../heartbeat-runner.ghost-reminder.test.ts | 22 ++--- ...espects-ackmaxchars-heartbeat-acks.test.ts | 97 +++++++++---------- ...ner.sender-prefers-delivery-target.test.ts | 22 ++--- src/infra/heartbeat-visibility.test.ts | 46 ++++----- .../outbound/message-action-runner.test.ts | 20 ++-- 5 files changed, 93 insertions(+), 114 deletions(-) diff --git a/src/infra/heartbeat-runner.ghost-reminder.test.ts b/src/infra/heartbeat-runner.ghost-reminder.test.ts index e0e66dd310536..0fa7280a37fa0 100644 --- a/src/infra/heartbeat-runner.ghost-reminder.test.ts +++ b/src/infra/heartbeat-runner.ghost-reminder.test.ts @@ -11,6 +11,7 @@ import { setActivePluginRegistry } from "../plugins/runtime.js"; import { createPluginRuntime } from "../plugins/runtime/index.js"; import { createTestRegistry } from "../test-utils/channel-plugins.js"; import { runHeartbeatOnce } from "./heartbeat-runner.js"; +import { seedSessionStore } from "./heartbeat-runner.test-utils.js"; import { enqueueSystemEvent, resetSystemEventsForTest } from "./system-events.js"; // Avoid pulling optional runtime deps during isolated runs. @@ -50,22 +51,11 @@ describe("Ghost reminder bug (issue #13317)", () => { }; const sessionKey = resolveMainSessionKey(cfg); - await fs.writeFile( - storePath, - JSON.stringify( - { - [sessionKey]: { - sessionId: "sid", - updatedAt: Date.now(), - lastChannel: "telegram", - lastProvider: "telegram", - lastTo: "155462274", - }, - }, - null, - 2, - ), - ); + await seedSessionStore(storePath, sessionKey, { + lastChannel: "telegram", + lastProvider: "telegram", + lastTo: "155462274", + }); return { cfg, sessionKey }; }; diff --git a/src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts b/src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts index b2bdaf79bebed..022e1b4b4282f 100644 --- a/src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts +++ b/src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts @@ -140,17 +140,50 @@ describe("resolveHeartbeatIntervalMs", () => { return sendTelegram; } + function createWhatsAppHeartbeatConfig(params: { + tmpDir: string; + storePath: string; + heartbeat?: Record; + visibility?: Record; + }): OpenClawConfig { + return createHeartbeatConfig({ + tmpDir: params.tmpDir, + storePath: params.storePath, + heartbeat: { + every: "5m", + target: "whatsapp", + ...params.heartbeat, + }, + channels: { + whatsapp: { + allowFrom: ["*"], + ...(params.visibility ? { heartbeat: params.visibility } : {}), + }, + }, + }); + } + + async function createSeededWhatsAppHeartbeatConfig(params: { + tmpDir: string; + storePath: string; + heartbeat?: Record; + visibility?: Record; + }): Promise { + const cfg = createWhatsAppHeartbeatConfig(params); + await seedMainSession(params.storePath, cfg, { + lastChannel: "whatsapp", + lastProvider: "whatsapp", + lastTo: "+1555", + }); + return cfg; + } + it("respects ackMaxChars for heartbeat acks", async () => { await withTempHeartbeatSandbox(async ({ tmpDir, storePath, replySpy }) => { - const cfg = createHeartbeatConfig({ + const cfg = createWhatsAppHeartbeatConfig({ tmpDir, storePath, - heartbeat: { - every: "5m", - target: "whatsapp", - ackMaxChars: 0, - }, - channels: { whatsapp: { allowFrom: ["*"] } }, + heartbeat: { ackMaxChars: 0 }, }); await seedMainSession(storePath, cfg, { @@ -173,14 +206,10 @@ describe("resolveHeartbeatIntervalMs", () => { it("sends HEARTBEAT_OK when visibility.showOk is true", async () => { await withTempHeartbeatSandbox(async ({ tmpDir, storePath, replySpy }) => { - const cfg = createHeartbeatConfig({ + const cfg = createWhatsAppHeartbeatConfig({ tmpDir, storePath, - heartbeat: { - every: "5m", - target: "whatsapp", - }, - channels: { whatsapp: { allowFrom: ["*"], heartbeat: { showOk: true } } }, + visibility: { showOk: true }, }); await seedMainSession(storePath, cfg, { @@ -250,19 +279,10 @@ describe("resolveHeartbeatIntervalMs", () => { it("skips heartbeat LLM calls when visibility disables all output", async () => { await withTempHeartbeatSandbox(async ({ tmpDir, storePath, replySpy }) => { - const cfg = createHeartbeatConfig({ + const cfg = createWhatsAppHeartbeatConfig({ tmpDir, storePath, - heartbeat: { - every: "5m", - target: "whatsapp", - }, - channels: { - whatsapp: { - allowFrom: ["*"], - heartbeat: { showOk: false, showAlerts: false, useIndicator: false }, - }, - }, + visibility: { showOk: false, showAlerts: false, useIndicator: false }, }); await seedMainSession(storePath, cfg, { @@ -286,20 +306,9 @@ describe("resolveHeartbeatIntervalMs", () => { it("skips delivery for markup-wrapped HEARTBEAT_OK", async () => { await withTempHeartbeatSandbox(async ({ tmpDir, storePath, replySpy }) => { - const cfg = createHeartbeatConfig({ + const cfg = await createSeededWhatsAppHeartbeatConfig({ tmpDir, storePath, - heartbeat: { - every: "5m", - target: "whatsapp", - }, - channels: { whatsapp: { allowFrom: ["*"] } }, - }); - - await seedMainSession(storePath, cfg, { - lastChannel: "whatsapp", - lastProvider: "whatsapp", - lastTo: "+1555", }); replySpy.mockResolvedValue({ text: "HEARTBEAT_OK" }); @@ -318,14 +327,9 @@ describe("resolveHeartbeatIntervalMs", () => { await withTempHeartbeatSandbox(async ({ tmpDir, storePath, replySpy }) => { const originalUpdatedAt = 1000; const bumpedUpdatedAt = 2000; - const cfg = createHeartbeatConfig({ + const cfg = createWhatsAppHeartbeatConfig({ tmpDir, storePath, - heartbeat: { - every: "5m", - target: "whatsapp", - }, - channels: { whatsapp: { allowFrom: ["*"] } }, }); const sessionKey = await seedMainSession(storePath, cfg, { @@ -363,16 +367,9 @@ describe("resolveHeartbeatIntervalMs", () => { it("skips WhatsApp delivery when not linked or running", async () => { await withTempHeartbeatSandbox(async ({ tmpDir, storePath, replySpy }) => { - const cfg = createHeartbeatConfig({ + const cfg = await createSeededWhatsAppHeartbeatConfig({ tmpDir, storePath, - heartbeat: { every: "5m", target: "whatsapp" }, - channels: { whatsapp: { allowFrom: ["*"] } }, - }); - await seedMainSession(storePath, cfg, { - lastChannel: "whatsapp", - lastProvider: "whatsapp", - lastTo: "+1555", }); replySpy.mockResolvedValue({ text: "Heartbeat alert" }); diff --git a/src/infra/heartbeat-runner.sender-prefers-delivery-target.test.ts b/src/infra/heartbeat-runner.sender-prefers-delivery-target.test.ts index 6c13476cd3be7..625d11e01d9fd 100644 --- a/src/infra/heartbeat-runner.sender-prefers-delivery-target.test.ts +++ b/src/infra/heartbeat-runner.sender-prefers-delivery-target.test.ts @@ -7,6 +7,7 @@ import type { OpenClawConfig } from "../config/config.js"; import { resolveMainSessionKey } from "../config/sessions.js"; import { runHeartbeatOnce } from "./heartbeat-runner.js"; import { installHeartbeatRunnerTestRuntime } from "./heartbeat-runner.test-harness.js"; +import { seedSessionStore } from "./heartbeat-runner.test-utils.js"; // Avoid pulling optional runtime deps during isolated runs. vi.mock("jiti", () => ({ createJiti: () => () => ({}) })); @@ -35,22 +36,11 @@ describe("runHeartbeatOnce", () => { }; const sessionKey = resolveMainSessionKey(cfg); - await fs.writeFile( - storePath, - JSON.stringify( - { - [sessionKey]: { - sessionId: "sid", - updatedAt: Date.now(), - lastChannel: "telegram", - lastProvider: "telegram", - lastTo: "1644620762", - }, - }, - null, - 2, - ), - ); + await seedSessionStore(storePath, sessionKey, { + lastChannel: "telegram", + lastProvider: "telegram", + lastTo: "1644620762", + }); replySpy.mockImplementation(async (ctx) => { expect(ctx.To).toBe("C0A9P2N8QHY"); diff --git a/src/infra/heartbeat-visibility.test.ts b/src/infra/heartbeat-visibility.test.ts index f48e37ad68ce3..7350ffea9fe08 100644 --- a/src/infra/heartbeat-visibility.test.ts +++ b/src/infra/heartbeat-visibility.test.ts @@ -3,6 +3,20 @@ import type { OpenClawConfig } from "../config/config.js"; import { resolveHeartbeatVisibility } from "./heartbeat-visibility.js"; describe("resolveHeartbeatVisibility", () => { + function createChannelDefaultsHeartbeatConfig(heartbeat: { + showOk?: boolean; + showAlerts?: boolean; + useIndicator?: boolean; + }): OpenClawConfig { + return { + channels: { + defaults: { + heartbeat, + }, + }, + } as OpenClawConfig; + } + function createTelegramAccountHeartbeatConfig(): OpenClawConfig { return { channels: { @@ -34,17 +48,11 @@ describe("resolveHeartbeatVisibility", () => { }); it("uses channel defaults when provided", () => { - const cfg = { - channels: { - defaults: { - heartbeat: { - showOk: true, - showAlerts: false, - useIndicator: false, - }, - }, - }, - } as OpenClawConfig; + const cfg = createChannelDefaultsHeartbeatConfig({ + showOk: true, + showAlerts: false, + useIndicator: false, + }); const result = resolveHeartbeatVisibility({ cfg, channel: "telegram" }); @@ -236,17 +244,11 @@ describe("resolveHeartbeatVisibility", () => { }); it("webchat uses channel defaults only (no per-channel config)", () => { - const cfg = { - channels: { - defaults: { - heartbeat: { - showOk: true, - showAlerts: false, - useIndicator: false, - }, - }, - }, - } as OpenClawConfig; + const cfg = createChannelDefaultsHeartbeatConfig({ + showOk: true, + showAlerts: false, + useIndicator: false, + }); const result = resolveHeartbeatVisibility({ cfg, channel: "webchat" }); diff --git a/src/infra/outbound/message-action-runner.test.ts b/src/infra/outbound/message-action-runner.test.ts index af62420d44a03..4c219d42499fb 100644 --- a/src/infra/outbound/message-action-runner.test.ts +++ b/src/infra/outbound/message-action-runner.test.ts @@ -78,6 +78,14 @@ const runDrySend = (params: { action: "send", }); +function createAlwaysConfiguredPluginConfig(account: Record = { enabled: true }) { + return { + listAccountIds: () => ["default"], + resolveAccount: () => account, + isConfigured: () => true, + }; +} + describe("runMessageAction context isolation", () => { beforeEach(async () => { const { createPluginRuntime } = await import("../../plugins/runtime/index.js"); @@ -680,11 +688,7 @@ describe("runMessageAction card-only send behavior", () => { blurb: "Card-only send test plugin.", }, capabilities: { chatTypes: ["direct"] }, - config: { - listAccountIds: () => ["default"], - resolveAccount: () => ({ enabled: true }), - isConfigured: () => true, - }, + config: createAlwaysConfiguredPluginConfig(), actions: { listActions: () => ["send"], supportsAction: ({ action }) => action === "send", @@ -764,11 +768,7 @@ describe("runMessageAction components parsing", () => { blurb: "Discord components send test plugin.", }, capabilities: { chatTypes: ["direct"] }, - config: { - listAccountIds: () => ["default"], - resolveAccount: () => ({}), - isConfigured: () => true, - }, + config: createAlwaysConfiguredPluginConfig({}), actions: { listActions: () => ["send"], supportsAction: ({ action }) => action === "send", From 672b1c5084d74d95644993fcdb127071f3194ba9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 14:02:55 +0000 Subject: [PATCH 0015/1006] refactor: dedupe slack monitor mrkdwn and modal event base --- src/slack/monitor/events/interactions.ts | 173 ++++++++++++----------- src/slack/monitor/mrkdwn.test.ts | 12 ++ src/slack/monitor/mrkdwn.ts | 8 ++ src/slack/monitor/slash.ts | 10 +- 4 files changed, 109 insertions(+), 94 deletions(-) create mode 100644 src/slack/monitor/mrkdwn.test.ts create mode 100644 src/slack/monitor/mrkdwn.ts diff --git a/src/slack/monitor/events/interactions.ts b/src/slack/monitor/events/interactions.ts index 958d6b3f5d59d..06af384be70c6 100644 --- a/src/slack/monitor/events/interactions.ts +++ b/src/slack/monitor/events/interactions.ts @@ -3,6 +3,7 @@ import type { Block, KnownBlock } from "@slack/web-api"; import { enqueueSystemEvent } from "../../../infra/system-events.js"; import { parseSlackModalPrivateMetadata } from "../../modal-metadata.js"; import type { SlackMonitorContext } from "../context.js"; +import { escapeSlackMrkdwn } from "../mrkdwn.js"; // Prefix for OpenClaw-generated action IDs to scope our handler const OPENCLAW_ACTION_PREFIX = "openclaw:"; @@ -58,6 +59,45 @@ type ModalInputSummary = InteractionSelectionFields & { actionId: string; }; +type SlackModalBody = { + user?: { id?: string }; + team?: { id?: string }; + view?: { + id?: string; + callback_id?: string; + private_metadata?: string; + root_view_id?: string; + previous_view_id?: string; + external_id?: string; + hash?: string; + state?: { values?: unknown }; + }; + is_cleared?: boolean; +}; + +type SlackModalEventBase = { + callbackId: string; + userId: string; + viewId?: string; + sessionRouting: ReturnType; + payload: { + actionId: string; + callbackId: string; + viewId?: string; + userId: string; + teamId?: string; + rootViewId?: string; + previousViewId?: string; + externalId?: string; + viewHash?: string; + isStackedView?: boolean; + privateMetadata?: string; + routedChannelId?: string; + routedChannelType?: string; + inputs: ModalInputSummary[]; + }; +}; + function readOptionValues(options: unknown): string[] | undefined { if (!Array.isArray(options)) { return undefined; @@ -97,15 +137,6 @@ function uniqueNonEmptyStrings(values: string[]): string[] { return unique; } -function escapeSlackMrkdwn(value: string): string { - return value - .replaceAll("\\", "\\\\") - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replace(/([*_`~])/g, "\\$1"); -} - function collectRichTextFragments(value: unknown, out: string[]): void { if (!value || typeof value !== "object") { return; @@ -374,6 +405,43 @@ function summarizeSlackViewLifecycleContext(view: { }; } +function resolveSlackModalEventBase(params: { + ctx: SlackMonitorContext; + body: SlackModalBody; +}): SlackModalEventBase { + const callbackId = params.body.view?.callback_id ?? "unknown"; + const userId = params.body.user?.id ?? "unknown"; + const viewId = params.body.view?.id; + const inputs = summarizeViewState(params.body.view?.state?.values); + const sessionRouting = resolveModalSessionRouting({ + ctx: params.ctx, + privateMetadata: params.body.view?.private_metadata, + }); + return { + callbackId, + userId, + viewId, + sessionRouting, + payload: { + actionId: `view:${callbackId}`, + callbackId, + viewId, + userId, + teamId: params.body.team?.id, + ...summarizeSlackViewLifecycleContext({ + root_view_id: params.body.view?.root_view_id, + previous_view_id: params.body.view?.previous_view_id, + external_id: params.body.view?.external_id, + hash: params.body.view?.hash, + }), + privateMetadata: params.body.view?.private_metadata, + routedChannelId: sessionRouting.channelId, + routedChannelType: sessionRouting.channelType, + inputs, + }, + }; +} + export function registerSlackInteractionEvents(params: { ctx: SlackMonitorContext }) { const { ctx } = params; if (typeof ctx.app.action !== "function") { @@ -544,50 +612,18 @@ export function registerSlackInteractionEvents(params: { ctx: SlackMonitorContex async ({ ack, body }: { ack: () => Promise; body: unknown }) => { await ack(); - const typedBody = body as { - user?: { id?: string }; - team?: { id?: string }; - view?: { - id?: string; - callback_id?: string; - private_metadata?: string; - root_view_id?: string; - previous_view_id?: string; - external_id?: string; - hash?: string; - state?: { values?: unknown }; - }; - }; - - const callbackId = typedBody.view?.callback_id ?? "unknown"; - const userId = typedBody.user?.id ?? "unknown"; - const viewId = typedBody.view?.id; - const inputs = summarizeViewState(typedBody.view?.state?.values); - const sessionRouting = resolveModalSessionRouting({ + const modalBody = body as SlackModalBody; + const { callbackId, userId, viewId, sessionRouting, payload } = resolveSlackModalEventBase({ ctx, - privateMetadata: typedBody.view?.private_metadata, + body: modalBody, }); const eventPayload = { interactionType: "view_submission", - actionId: `view:${callbackId}`, - callbackId, - viewId, - userId, - teamId: typedBody.team?.id, - ...summarizeSlackViewLifecycleContext({ - root_view_id: typedBody.view?.root_view_id, - previous_view_id: typedBody.view?.previous_view_id, - external_id: typedBody.view?.external_id, - hash: typedBody.view?.hash, - }), - privateMetadata: typedBody.view?.private_metadata, - routedChannelId: sessionRouting.channelId, - routedChannelType: sessionRouting.channelType, - inputs, + ...payload, }; ctx.runtime.log?.( - `slack:interaction view_submission callback=${callbackId} user=${userId} inputs=${inputs.length}`, + `slack:interaction view_submission callback=${callbackId} user=${userId} inputs=${payload.inputs.length}`, ); enqueueSystemEvent(`Slack interaction: ${JSON.stringify(eventPayload)}`, { @@ -617,53 +653,20 @@ export function registerSlackInteractionEvents(params: { ctx: SlackMonitorContex async ({ ack, body }: { ack: () => Promise; body: unknown }) => { await ack(); - const typedBody = body as { - user?: { id?: string }; - team?: { id?: string }; - view?: { - id?: string; - callback_id?: string; - private_metadata?: string; - root_view_id?: string; - previous_view_id?: string; - external_id?: string; - hash?: string; - state?: { values?: unknown }; - }; - is_cleared?: boolean; - }; - - const callbackId = typedBody.view?.callback_id ?? "unknown"; - const userId = typedBody.user?.id ?? "unknown"; - const viewId = typedBody.view?.id; - const inputs = summarizeViewState(typedBody.view?.state?.values); - const sessionRouting = resolveModalSessionRouting({ + const modalBody = body as SlackModalBody; + const { callbackId, userId, viewId, sessionRouting, payload } = resolveSlackModalEventBase({ ctx, - privateMetadata: typedBody.view?.private_metadata, + body: modalBody, }); const eventPayload = { interactionType: "view_closed", - actionId: `view:${callbackId}`, - callbackId, - viewId, - userId, - teamId: typedBody.team?.id, - ...summarizeSlackViewLifecycleContext({ - root_view_id: typedBody.view?.root_view_id, - previous_view_id: typedBody.view?.previous_view_id, - external_id: typedBody.view?.external_id, - hash: typedBody.view?.hash, - }), - isCleared: typedBody.is_cleared === true, - privateMetadata: typedBody.view?.private_metadata, - routedChannelId: sessionRouting.channelId, - routedChannelType: sessionRouting.channelType, - inputs, + ...payload, + isCleared: modalBody.is_cleared === true, }; ctx.runtime.log?.( `slack:interaction view_closed callback=${callbackId} user=${userId} cleared=${ - typedBody.is_cleared === true + modalBody.is_cleared === true }`, ); diff --git a/src/slack/monitor/mrkdwn.test.ts b/src/slack/monitor/mrkdwn.test.ts new file mode 100644 index 0000000000000..5efba875a5751 --- /dev/null +++ b/src/slack/monitor/mrkdwn.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; +import { escapeSlackMrkdwn } from "./mrkdwn.js"; + +describe("escapeSlackMrkdwn", () => { + it("returns plain text unchanged", () => { + expect(escapeSlackMrkdwn("heartbeat status ok")).toBe("heartbeat status ok"); + }); + + it("escapes slack and mrkdwn control characters", () => { + expect(escapeSlackMrkdwn("mode_*`~<&>\\")).toBe("mode\\_\\*\\`\\~<&>\\\\"); + }); +}); diff --git a/src/slack/monitor/mrkdwn.ts b/src/slack/monitor/mrkdwn.ts new file mode 100644 index 0000000000000..aea752da7091d --- /dev/null +++ b/src/slack/monitor/mrkdwn.ts @@ -0,0 +1,8 @@ +export function escapeSlackMrkdwn(value: string): string { + return value + .replaceAll("\\", "\\\\") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replace(/([*_`~])/g, "\\$1"); +} diff --git a/src/slack/monitor/slash.ts b/src/slack/monitor/slash.ts index d67eb68f9c46d..a0651941bf565 100644 --- a/src/slack/monitor/slash.ts +++ b/src/slack/monitor/slash.ts @@ -22,6 +22,7 @@ import { resolveSlackChannelConfig, type SlackChannelConfigResolved } from "./ch import { buildSlackSlashCommandMatcher, resolveSlackSlashCommandConfig } from "./commands.js"; import type { SlackMonitorContext } from "./context.js"; import { normalizeSlackChannelType } from "./context.js"; +import { escapeSlackMrkdwn } from "./mrkdwn.js"; import { isSlackChannelAllowedByPolicy } from "./policy.js"; import { resolveSlackRoomContextHints } from "./room-context.js"; @@ -55,15 +56,6 @@ function truncatePlainText(value: string, max: number): string { return `${trimmed.slice(0, max - 1)}…`; } -function escapeSlackMrkdwn(value: string): string { - return value - .replaceAll("\\", "\\\\") - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replace(/([*_`~])/g, "\\$1"); -} - function buildSlackArgMenuConfirm(params: { command: string; arg: string }) { const command = escapeSlackMrkdwn(params.command); const arg = escapeSlackMrkdwn(params.arg); From a99fd8f2dde86841f55206a2e453bc2caa8b9b98 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 14:03:47 +0000 Subject: [PATCH 0016/1006] refactor: reuse daemon action response type in lifecycle core --- src/cli/daemon-cli/lifecycle-core.ts | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/src/cli/daemon-cli/lifecycle-core.ts b/src/cli/daemon-cli/lifecycle-core.ts index 062a2d4df379a..5e935bb8db1e7 100644 --- a/src/cli/daemon-cli/lifecycle-core.ts +++ b/src/cli/daemon-cli/lifecycle-core.ts @@ -10,6 +10,7 @@ import { buildDaemonServiceSnapshot, createNullWriter, type DaemonAction, + type DaemonActionResponse, emitDaemonActionJson, } from "./response.js"; @@ -30,20 +31,7 @@ async function maybeAugmentSystemdHints(hints: string[]): Promise { function createActionIO(params: { action: DaemonAction; json: boolean }) { const stdout = params.json ? createNullWriter() : process.stdout; - const emit = (payload: { - ok: boolean; - result?: string; - message?: string; - error?: string; - hints?: string[]; - warnings?: string[]; - service?: { - label: string; - loaded: boolean; - loadedText: string; - notLoadedText: string; - }; - }) => { + const emit = (payload: Omit) => { if (!params.json) { return; } From 397f243dedef0983cb1f29f3c69d56fc604c9b13 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 14:08:50 +0000 Subject: [PATCH 0017/1006] refactor: dedupe gateway session guards and agent test fixtures --- src/gateway/server-methods/agent.test.ts | 94 +++++++++++++------ .../server-methods/agents-mutate.test.ts | 23 +++-- src/gateway/server-methods/sessions.ts | 42 +++++---- 3 files changed, 100 insertions(+), 59 deletions(-) diff --git a/src/gateway/server-methods/agent.test.ts b/src/gateway/server-methods/agent.test.ts index 95293724afd15..eead292da04df 100644 --- a/src/gateway/server-methods/agent.test.ts +++ b/src/gateway/server-methods/agent.test.ts @@ -113,6 +113,18 @@ function captureUpdatedMainEntry() { return () => capturedEntry; } +function primeMainAgentRun(params?: { sessionId?: string; cfg?: Record }) { + mockMainSessionEntry( + { sessionId: params?.sessionId ?? "existing-session-id" }, + params?.cfg ?? {}, + ); + mocks.updateSessionStore.mockResolvedValue(undefined); + mocks.agentCommand.mockResolvedValue({ + payloads: [{ text: "ok" }], + meta: { durationMs: 100 }, + }); +} + async function runMainAgent(message: string, idempotencyKey: string) { const respond = vi.fn(); await invokeAgent( @@ -210,20 +222,7 @@ describe("gateway agent handler", () => { }, }; - mocks.loadSessionEntry.mockReturnValue({ - cfg: mocks.loadConfigReturn, - storePath: "/tmp/sessions.json", - entry: { - sessionId: "existing-session-id", - updatedAt: Date.now(), - }, - canonicalKey: "agent:main:main", - }); - mocks.updateSessionStore.mockResolvedValue(undefined); - mocks.agentCommand.mockResolvedValue({ - payloads: [{ text: "ok" }], - meta: { durationMs: 100 }, - }); + primeMainAgentRun({ cfg: mocks.loadConfigReturn }); await invokeAgent( { @@ -326,20 +325,7 @@ describe("gateway agent handler", () => { }, ); - mocks.loadSessionEntry.mockReturnValue({ - cfg: {}, - storePath: "/tmp/sessions.json", - entry: { - sessionId: "reset-session-id", - updatedAt: Date.now(), - }, - canonicalKey: "agent:main:main", - }); - mocks.updateSessionStore.mockResolvedValue(undefined); - mocks.agentCommand.mockResolvedValue({ - payloads: [{ text: "ok" }], - meta: { durationMs: 100 }, - }); + primeMainAgentRun({ sessionId: "reset-session-id" }); await invokeAgent( { @@ -359,6 +345,58 @@ describe("gateway agent handler", () => { expect(call?.sessionId).toBe("reset-session-id"); }); + it("uses /reset suffix as the post-reset message and still injects timestamp", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-29T01:30:00.000Z")); // Wed Jan 28, 8:30 PM EST + mocks.agentCommand.mockReset(); + mocks.loadConfigReturn = { + agents: { + defaults: { + userTimezone: "America/New_York", + }, + }, + }; + mocks.sessionsResetHandler.mockImplementation( + async (opts: { + params: { key: string; reason: string }; + respond: (ok: boolean, payload?: unknown) => void; + }) => { + expect(opts.params.key).toBe("agent:main:main"); + expect(opts.params.reason).toBe("reset"); + opts.respond(true, { + ok: true, + key: "agent:main:main", + entry: { sessionId: "reset-session-id" }, + }); + }, + ); + mocks.sessionsResetHandler.mockClear(); + primeMainAgentRun({ + sessionId: "reset-session-id", + cfg: mocks.loadConfigReturn, + }); + + await invokeAgent( + { + message: "/reset check status", + sessionKey: "agent:main:main", + idempotencyKey: "test-idem-reset-suffix", + }, + { reqId: "4b" }, + ); + + await vi.waitFor(() => expect(mocks.agentCommand).toHaveBeenCalled()); + expect(mocks.sessionsResetHandler).toHaveBeenCalledTimes(1); + const call = mocks.agentCommand.mock.calls.at(-1)?.[0] as + | { message?: string; sessionId?: string } + | undefined; + expect(call?.message).toBe("[Wed 2026-01-28 20:30 EST] check status"); + expect(call?.sessionId).toBe("reset-session-id"); + + mocks.loadConfigReturn = {}; + vi.useRealTimers(); + }); + it("rejects malformed agent session keys early in agent handler", async () => { mocks.agentCommand.mockClear(); const respond = await invokeAgent( diff --git a/src/gateway/server-methods/agents-mutate.test.ts b/src/gateway/server-methods/agents-mutate.test.ts index 4e59b524e4d7d..54c285203f361 100644 --- a/src/gateway/server-methods/agents-mutate.test.ts +++ b/src/gateway/server-methods/agents-mutate.test.ts @@ -156,6 +156,15 @@ async function listAgentFileNames(agentId = "main") { return files.map((file) => file.name); } +function expectNotFoundResponseAndNoWrite(respond: ReturnType) { + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ message: expect.stringContaining("not found") }), + ); + expect(mocks.writeConfigFile).not.toHaveBeenCalled(); +} + beforeEach(() => { mocks.fsReadFile.mockImplementation(async () => { throw createEnoentError(); @@ -319,12 +328,7 @@ describe("agents.update", () => { }); await promise; - expect(respond).toHaveBeenCalledWith( - false, - undefined, - expect.objectContaining({ message: expect.stringContaining("not found") }), - ); - expect(mocks.writeConfigFile).not.toHaveBeenCalled(); + expectNotFoundResponseAndNoWrite(respond); }); it("ensures workspace when workspace changes", async () => { @@ -408,12 +412,7 @@ describe("agents.delete", () => { }); await promise; - expect(respond).toHaveBeenCalledWith( - false, - undefined, - expect.objectContaining({ message: expect.stringContaining("not found") }), - ); - expect(mocks.writeConfigFile).not.toHaveBeenCalled(); + expectNotFoundResponseAndNoWrite(respond); }); it("rejects invalid params (missing agentId)", async () => { diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index 1643ae54ce933..18c9754f79b6b 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -42,7 +42,7 @@ import { } from "../session-utils.js"; import { applySessionsPatchToStore } from "../sessions-patch.js"; import { resolveSessionKeyFromResolveParams } from "../sessions-resolve.js"; -import type { GatewayRequestHandlers, RespondFn } from "./types.js"; +import type { GatewayClient, GatewayRequestHandlers, RespondFn } from "./types.js"; import { assertValidParams } from "./validation.js"; function requireSessionKey(key: unknown, respond: RespondFn): string | null { @@ -68,6 +68,26 @@ function resolveGatewaySessionTargetFromKey(key: string) { return { cfg, target, storePath: target.storePath }; } +function rejectWebchatSessionMutation(params: { + action: "patch" | "delete"; + client: GatewayClient | null; + isWebchatConnect: (params: GatewayClient["connect"] | null | undefined) => boolean; + respond: RespondFn; +}): boolean { + if (!params.client?.connect || !params.isWebchatConnect(params.client.connect)) { + return false; + } + params.respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `webchat clients cannot ${params.action} sessions; use chat.send for session-scoped updates`, + ), + ); + return true; +} + function migrateAndPruneSessionStoreKey(params: { cfg: ReturnType; key: string; @@ -240,15 +260,7 @@ export const sessionsHandlers: GatewayRequestHandlers = { if (!key) { return; } - if (client?.connect && isWebchatConnect(client.connect)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - "webchat clients cannot patch sessions; use chat.send for session-scoped updates", - ), - ); + if (rejectWebchatSessionMutation({ action: "patch", client, isWebchatConnect, respond })) { return; } @@ -366,15 +378,7 @@ export const sessionsHandlers: GatewayRequestHandlers = { if (!key) { return; } - if (client?.connect && isWebchatConnect(client.connect)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - "webchat clients cannot delete sessions; use chat.send for session-scoped updates", - ), - ); + if (rejectWebchatSessionMutation({ action: "delete", client, isWebchatConnect, respond })) { return; } From ba538c98c72a5ff65bddee829d3f8227e50eb781 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 14:10:58 +0000 Subject: [PATCH 0018/1006] refactor: share plain object guard across config and utils --- src/config/env-preserve.ts | 11 ++--------- src/infra/plain-object.test.ts | 18 ++++++++++++++++++ src/infra/plain-object.ts | 11 +++++++++++ src/utils.ts | 14 ++------------ 4 files changed, 33 insertions(+), 21 deletions(-) create mode 100644 src/infra/plain-object.test.ts create mode 100644 src/infra/plain-object.ts diff --git a/src/config/env-preserve.ts b/src/config/env-preserve.ts index a882357b9ec34..0259781eeefa1 100644 --- a/src/config/env-preserve.ts +++ b/src/config/env-preserve.ts @@ -1,3 +1,5 @@ +import { isPlainObject } from "../infra/plain-object.js"; + /** * Preserves `${VAR}` environment variable references during config write-back. * @@ -16,15 +18,6 @@ const ENV_VAR_PATTERN = /\$\{[A-Z_][A-Z0-9_]*\}/; -function isPlainObject(value: unknown): value is Record { - return ( - typeof value === "object" && - value !== null && - !Array.isArray(value) && - Object.prototype.toString.call(value) === "[object Object]" - ); -} - /** * Check if a string contains any `${VAR}` env var references. */ diff --git a/src/infra/plain-object.test.ts b/src/infra/plain-object.test.ts new file mode 100644 index 0000000000000..b87e555b21ab4 --- /dev/null +++ b/src/infra/plain-object.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { isPlainObject } from "./plain-object.js"; + +describe("isPlainObject", () => { + it("accepts plain objects", () => { + expect(isPlainObject({})).toBe(true); + expect(isPlainObject({ a: 1 })).toBe(true); + }); + + it("rejects non-plain values", () => { + expect(isPlainObject(null)).toBe(false); + expect(isPlainObject([])).toBe(false); + expect(isPlainObject(new Date())).toBe(false); + expect(isPlainObject(/re/)).toBe(false); + expect(isPlainObject("x")).toBe(false); + expect(isPlainObject(42)).toBe(false); + }); +}); diff --git a/src/infra/plain-object.ts b/src/infra/plain-object.ts new file mode 100644 index 0000000000000..2b611ca33c1c6 --- /dev/null +++ b/src/infra/plain-object.ts @@ -0,0 +1,11 @@ +/** + * Strict plain-object guard (excludes arrays and host objects). + */ +export function isPlainObject(value: unknown): value is Record { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + Object.prototype.toString.call(value) === "[object Object]" + ); +} diff --git a/src/utils.ts b/src/utils.ts index 66ed063cfa939..49e14e0d04897 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -8,6 +8,7 @@ import { resolveEffectiveHomeDir, resolveRequiredHomeDir, } from "./infra/home-dir.js"; +import { isPlainObject } from "./infra/plain-object.js"; export async function ensureDir(dir: string) { await fs.promises.mkdir(dir, { recursive: true }); @@ -54,18 +55,7 @@ export function safeParseJson(raw: string): T | null { } } -/** - * Type guard for plain objects (not arrays, null, Date, RegExp, etc.). - * Uses Object.prototype.toString for maximum safety. - */ -export function isPlainObject(value: unknown): value is Record { - return ( - typeof value === "object" && - value !== null && - !Array.isArray(value) && - Object.prototype.toString.call(value) === "[object Object]" - ); -} +export { isPlainObject }; /** * Type guard for Record (less strict than isPlainObject). From ffd4e85873e2443f30f146ac3609eb7122abe8ae Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 14:14:02 +0000 Subject: [PATCH 0019/1006] refactor: share allow-from merge and sender-id checks --- src/channels/allow-from.test.ts | 69 +++++++++++++++++++++++++++++++++ src/channels/allow-from.ts | 34 ++++++++++++++++ src/line/bot-access.ts | 29 +++----------- src/telegram/bot-access.ts | 30 +++----------- 4 files changed, 114 insertions(+), 48 deletions(-) create mode 100644 src/channels/allow-from.test.ts create mode 100644 src/channels/allow-from.ts diff --git a/src/channels/allow-from.test.ts b/src/channels/allow-from.test.ts new file mode 100644 index 0000000000000..a802349a1a229 --- /dev/null +++ b/src/channels/allow-from.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { firstDefined, isSenderIdAllowed, mergeAllowFromSources } from "./allow-from.js"; + +describe("mergeAllowFromSources", () => { + it("merges, trims, and filters empty values", () => { + expect( + mergeAllowFromSources({ + allowFrom: [" line:user:abc ", "", 123], + storeAllowFrom: [" ", "telegram:456"], + }), + ).toEqual(["line:user:abc", "123", "telegram:456"]); + }); +}); + +describe("firstDefined", () => { + it("returns the first non-undefined value", () => { + expect(firstDefined(undefined, undefined, "x", "y")).toBe("x"); + expect(firstDefined(undefined, 0, 1)).toBe(0); + }); +}); + +describe("isSenderIdAllowed", () => { + it("supports per-channel empty-list defaults and wildcard/id matches", () => { + expect( + isSenderIdAllowed( + { + entries: [], + hasEntries: false, + hasWildcard: false, + }, + "123", + true, + ), + ).toBe(true); + expect( + isSenderIdAllowed( + { + entries: [], + hasEntries: false, + hasWildcard: false, + }, + "123", + false, + ), + ).toBe(false); + expect( + isSenderIdAllowed( + { + entries: ["111", "222"], + hasEntries: true, + hasWildcard: true, + }, + undefined, + false, + ), + ).toBe(true); + expect( + isSenderIdAllowed( + { + entries: ["111", "222"], + hasEntries: true, + hasWildcard: false, + }, + "222", + false, + ), + ).toBe(true); + }); +}); diff --git a/src/channels/allow-from.ts b/src/channels/allow-from.ts new file mode 100644 index 0000000000000..8ab2f65c11b59 --- /dev/null +++ b/src/channels/allow-from.ts @@ -0,0 +1,34 @@ +export function mergeAllowFromSources(params: { + allowFrom?: Array; + storeAllowFrom?: string[]; +}): string[] { + return [...(params.allowFrom ?? []), ...(params.storeAllowFrom ?? [])] + .map((value) => String(value).trim()) + .filter(Boolean); +} + +export function firstDefined(...values: Array) { + for (const value of values) { + if (typeof value !== "undefined") { + return value; + } + } + return undefined; +} + +export function isSenderIdAllowed( + allow: { entries: string[]; hasWildcard: boolean; hasEntries: boolean }, + senderId: string | undefined, + allowWhenEmpty: boolean, +): boolean { + if (!allow.hasEntries) { + return allowWhenEmpty; + } + if (allow.hasWildcard) { + return true; + } + if (!senderId) { + return false; + } + return allow.entries.includes(senderId); +} diff --git a/src/line/bot-access.ts b/src/line/bot-access.ts index 4498826611ac2..2c4094406fc72 100644 --- a/src/line/bot-access.ts +++ b/src/line/bot-access.ts @@ -1,3 +1,5 @@ +import { firstDefined, isSenderIdAllowed, mergeAllowFromSources } from "../channels/allow-from.js"; + export type NormalizedAllowFrom = { entries: string[]; hasWildcard: boolean; @@ -28,33 +30,14 @@ export const normalizeAllowFrom = (list?: Array): NormalizedAll export const normalizeAllowFromWithStore = (params: { allowFrom?: Array; storeAllowFrom?: string[]; -}): NormalizedAllowFrom => { - const combined = [...(params.allowFrom ?? []), ...(params.storeAllowFrom ?? [])]; - return normalizeAllowFrom(combined); -}; - -export const firstDefined = (...values: Array) => { - for (const value of values) { - if (typeof value !== "undefined") { - return value; - } - } - return undefined; -}; +}): NormalizedAllowFrom => normalizeAllowFrom(mergeAllowFromSources(params)); export const isSenderAllowed = (params: { allow: NormalizedAllowFrom; senderId?: string; }): boolean => { const { allow, senderId } = params; - if (!allow.hasEntries) { - return false; - } - if (allow.hasWildcard) { - return true; - } - if (!senderId) { - return false; - } - return allow.entries.includes(senderId); + return isSenderIdAllowed(allow, senderId, false); }; + +export { firstDefined }; diff --git a/src/telegram/bot-access.ts b/src/telegram/bot-access.ts index 05a2034c7d65d..338788a2452b6 100644 --- a/src/telegram/bot-access.ts +++ b/src/telegram/bot-access.ts @@ -1,3 +1,4 @@ +import { firstDefined, isSenderIdAllowed, mergeAllowFromSources } from "../channels/allow-from.js"; import type { AllowlistMatch } from "../channels/allowlist-match.js"; export type NormalizedAllowFrom = { @@ -53,21 +54,7 @@ export const normalizeAllowFrom = (list?: Array): NormalizedAll export const normalizeAllowFromWithStore = (params: { allowFrom?: Array; storeAllowFrom?: string[]; -}): NormalizedAllowFrom => { - const combined = [...(params.allowFrom ?? []), ...(params.storeAllowFrom ?? [])] - .map((value) => String(value).trim()) - .filter(Boolean); - return normalizeAllowFrom(combined); -}; - -export const firstDefined = (...values: Array) => { - for (const value of values) { - if (typeof value !== "undefined") { - return value; - } - } - return undefined; -}; +}): NormalizedAllowFrom => normalizeAllowFrom(mergeAllowFromSources(params)); export const isSenderAllowed = (params: { allow: NormalizedAllowFrom; @@ -75,18 +62,11 @@ export const isSenderAllowed = (params: { senderUsername?: string; }) => { const { allow, senderId } = params; - if (!allow.hasEntries) { - return true; - } - if (allow.hasWildcard) { - return true; - } - if (senderId && allow.entries.includes(senderId)) { - return true; - } - return false; + return isSenderIdAllowed(allow, senderId, true); }; +export { firstDefined }; + export const resolveSenderAllowMatch = (params: { allow: NormalizedAllowFrom; senderId?: string; From 3179097a1fcf644084082e535843e0f152c76e26 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 14:18:28 +0000 Subject: [PATCH 0020/1006] refactor: dedupe redact snapshot restore prelude --- src/config/redact-snapshot.test.ts | 31 +++++++++++++++++++++ src/config/redact-snapshot.ts | 44 +++++++++++++++++++----------- 2 files changed, 59 insertions(+), 16 deletions(-) diff --git a/src/config/redact-snapshot.test.ts b/src/config/redact-snapshot.test.ts index e26c19edbce84..e8cf2644625e9 100644 --- a/src/config/redact-snapshot.test.ts +++ b/src/config/redact-snapshot.test.ts @@ -798,6 +798,37 @@ describe("restoreRedactedValues", () => { expect(restoreRedactedValues_orig(undefined, { token: "x" }).ok).toBe(false); }); + it("rejects non-object inputs", () => { + expect(restoreRedactedValues_orig("token-value", { token: "x" })).toEqual({ + ok: false, + error: "input not an object", + }); + }); + + it("returns a human-readable error when sentinel cannot be restored", () => { + const incoming = { + channels: { newChannel: { token: REDACTED_SENTINEL } }, + }; + const result = restoreRedactedValues_orig(incoming, {}); + expect(result.ok).toBe(false); + expect(result.humanReadableMessage).toContain(REDACTED_SENTINEL); + expect(result.humanReadableMessage).toContain("channels.newChannel.token"); + }); + + it("keeps unmatched wildcard array entries unchanged outside extension paths", () => { + const hints: ConfigUiHints = { + "custom.*": { sensitive: true }, + }; + const incoming = { + custom: { items: [REDACTED_SENTINEL] }, + }; + const original = { + custom: { items: ["original-secret-value"] }, + }; + const result = restoreRedactedValues(incoming, original, hints) as typeof incoming; + expect(result.custom.items[0]).toBe(REDACTED_SENTINEL); + }); + it("round-trips config through redact → restore", () => { const originalConfig = { gateway: { auth: { token: "gateway-auth-secret-token-value" }, port: 18789 }, diff --git a/src/config/redact-snapshot.ts b/src/config/redact-snapshot.ts index 243dcc3c29500..d377e961d53f5 100644 --- a/src/config/redact-snapshot.ts +++ b/src/config/redact-snapshot.ts @@ -404,6 +404,20 @@ function toObjectRecord(value: unknown): Record { return {}; } +function shouldPassThroughRestoreValue(incoming: unknown): boolean { + return incoming === null || incoming === undefined || typeof incoming !== "object"; +} + +function toRestoreArrayContext( + incoming: unknown, + prefix: string, +): { incoming: unknown[]; path: string } | null { + if (!Array.isArray(incoming)) { + return null; + } + return { incoming, path: `${prefix}[]` }; +} + function restoreArrayItemWithLookup(params: { item: unknown; index: number; @@ -478,26 +492,25 @@ function restoreRedactedValuesWithLookup( prefix: string, hints: ConfigUiHints, ): unknown { - if (incoming === null || incoming === undefined) { + if (shouldPassThroughRestoreValue(incoming)) { return incoming; } - if (typeof incoming !== "object") { - return incoming; - } - if (Array.isArray(incoming)) { + + const arrayContext = toRestoreArrayContext(incoming, prefix); + if (arrayContext) { // Note: If the user removed an item in the middle of the array, // we have no way of knowing which one. In this case, the last // element(s) get(s) chopped off. Not good, so please don't put // sensitive string array in the config... - const path = `${prefix}[]`; + const { incoming: incomingArray, path } = arrayContext; if (!lookup.has(path)) { if (!isExtensionPath(prefix)) { - return incoming; + return incomingArray; } - return restoreRedactedValuesGuessing(incoming, original, prefix, hints); + return restoreRedactedValuesGuessing(incomingArray, original, prefix, hints); } return mapRedactedArray({ - incoming, + incoming: incomingArray, original, path, mapItem: (item, index, originalArray) => @@ -551,19 +564,18 @@ function restoreRedactedValuesGuessing( prefix: string, hints?: ConfigUiHints, ): unknown { - if (incoming === null || incoming === undefined) { + if (shouldPassThroughRestoreValue(incoming)) { return incoming; } - if (typeof incoming !== "object") { - return incoming; - } - if (Array.isArray(incoming)) { + + const arrayContext = toRestoreArrayContext(incoming, prefix); + if (arrayContext) { // Note: If the user removed an item in the middle of the array, // we have no way of knowing which one. In this case, the last // element(s) get(s) chopped off. Not good, so please don't put // sensitive string array in the config... - const path = `${prefix}[]`; - return restoreGuessingArray(incoming, original, path, hints); + const { incoming: incomingArray, path } = arrayContext; + return restoreGuessingArray(incomingArray, original, path, hints); } const orig = toObjectRecord(original); const result: Record = {}; From 2581b67cdbc4a2e772657dd5b8b8b8de0419c7ff Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 14:23:21 +0000 Subject: [PATCH 0021/1006] refactor: share exec approval request helper --- .../bash-tools.exec-approval-request.test.ts | 81 +++++++++++++++++++ .../bash-tools.exec-approval-request.ts | 44 ++++++++++ src/agents/bash-tools.exec-host-gateway.ts | 35 +++----- src/agents/bash-tools.exec-host-node.ts | 33 +++----- 4 files changed, 148 insertions(+), 45 deletions(-) create mode 100644 src/agents/bash-tools.exec-approval-request.test.ts create mode 100644 src/agents/bash-tools.exec-approval-request.ts diff --git a/src/agents/bash-tools.exec-approval-request.test.ts b/src/agents/bash-tools.exec-approval-request.test.ts new file mode 100644 index 0000000000000..349663abaa132 --- /dev/null +++ b/src/agents/bash-tools.exec-approval-request.test.ts @@ -0,0 +1,81 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + DEFAULT_APPROVAL_REQUEST_TIMEOUT_MS, + DEFAULT_APPROVAL_TIMEOUT_MS, +} from "./bash-tools.exec-runtime.js"; + +vi.mock("./tools/gateway.js", () => ({ + callGatewayTool: vi.fn(), +})); + +describe("requestExecApprovalDecision", () => { + beforeEach(async () => { + const { callGatewayTool } = await import("./tools/gateway.js"); + vi.mocked(callGatewayTool).mockReset(); + }); + + it("returns string decisions", async () => { + const { requestExecApprovalDecision } = await import("./bash-tools.exec-approval-request.js"); + const { callGatewayTool } = await import("./tools/gateway.js"); + vi.mocked(callGatewayTool).mockResolvedValue({ decision: "allow-once" }); + + const result = await requestExecApprovalDecision({ + id: "approval-id", + command: "echo hi", + cwd: "/tmp", + host: "gateway", + security: "allowlist", + ask: "always", + agentId: "main", + resolvedPath: "/usr/bin/echo", + sessionKey: "session", + }); + + expect(result).toBe("allow-once"); + expect(callGatewayTool).toHaveBeenCalledWith( + "exec.approval.request", + { timeoutMs: DEFAULT_APPROVAL_REQUEST_TIMEOUT_MS }, + { + id: "approval-id", + command: "echo hi", + cwd: "/tmp", + host: "gateway", + security: "allowlist", + ask: "always", + agentId: "main", + resolvedPath: "/usr/bin/echo", + sessionKey: "session", + timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS, + }, + ); + }); + + it("returns null for missing or non-string decisions", async () => { + const { requestExecApprovalDecision } = await import("./bash-tools.exec-approval-request.js"); + const { callGatewayTool } = await import("./tools/gateway.js"); + + vi.mocked(callGatewayTool).mockResolvedValueOnce({}); + await expect( + requestExecApprovalDecision({ + id: "approval-id", + command: "echo hi", + cwd: "/tmp", + host: "node", + security: "allowlist", + ask: "on-miss", + }), + ).resolves.toBeNull(); + + vi.mocked(callGatewayTool).mockResolvedValueOnce({ decision: 123 }); + await expect( + requestExecApprovalDecision({ + id: "approval-id-2", + command: "echo hi", + cwd: "/tmp", + host: "node", + security: "allowlist", + ask: "on-miss", + }), + ).resolves.toBeNull(); + }); +}); diff --git a/src/agents/bash-tools.exec-approval-request.ts b/src/agents/bash-tools.exec-approval-request.ts new file mode 100644 index 0000000000000..b68aa37d3980f --- /dev/null +++ b/src/agents/bash-tools.exec-approval-request.ts @@ -0,0 +1,44 @@ +import type { ExecAsk, ExecSecurity } from "../infra/exec-approvals.js"; +import { + DEFAULT_APPROVAL_REQUEST_TIMEOUT_MS, + DEFAULT_APPROVAL_TIMEOUT_MS, +} from "./bash-tools.exec-runtime.js"; +import { callGatewayTool } from "./tools/gateway.js"; + +export type RequestExecApprovalDecisionParams = { + id: string; + command: string; + cwd: string; + host: "gateway" | "node"; + security: ExecSecurity; + ask: ExecAsk; + agentId?: string; + resolvedPath?: string; + sessionKey?: string; +}; + +export async function requestExecApprovalDecision( + params: RequestExecApprovalDecisionParams, +): Promise { + const decisionResult = await callGatewayTool<{ decision: string }>( + "exec.approval.request", + { timeoutMs: DEFAULT_APPROVAL_REQUEST_TIMEOUT_MS }, + { + id: params.id, + command: params.command, + cwd: params.cwd, + host: params.host, + security: params.security, + ask: params.ask, + agentId: params.agentId, + resolvedPath: params.resolvedPath, + sessionKey: params.sessionKey, + timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS, + }, + ); + const decisionValue = + decisionResult && typeof decisionResult === "object" + ? (decisionResult as { decision?: unknown }).decision + : undefined; + return typeof decisionValue === "string" ? decisionValue : null; +} diff --git a/src/agents/bash-tools.exec-host-gateway.ts b/src/agents/bash-tools.exec-host-gateway.ts index 9fd591458a953..3a804abc9eb4c 100644 --- a/src/agents/bash-tools.exec-host-gateway.ts +++ b/src/agents/bash-tools.exec-host-gateway.ts @@ -14,8 +14,8 @@ import { resolveExecApprovals, } from "../infra/exec-approvals.js"; import { markBackgrounded, tail } from "./bash-process-registry.js"; +import { requestExecApprovalDecision } from "./bash-tools.exec-approval-request.js"; import { - DEFAULT_APPROVAL_REQUEST_TIMEOUT_MS, DEFAULT_APPROVAL_TIMEOUT_MS, DEFAULT_NOTIFY_TAIL_CHARS, createApprovalSlug, @@ -24,7 +24,6 @@ import { runExecProcess, } from "./bash-tools.exec-runtime.js"; import type { ExecToolDetails } from "./bash-tools.exec-types.js"; -import { callGatewayTool } from "./tools/gateway.js"; export type ProcessGatewayAllowlistParams = { command: string; @@ -99,27 +98,17 @@ export async function processGatewayAllowlist( void (async () => { let decision: string | null = null; try { - const decisionResult = await callGatewayTool<{ decision: string }>( - "exec.approval.request", - { timeoutMs: DEFAULT_APPROVAL_REQUEST_TIMEOUT_MS }, - { - id: approvalId, - command: params.command, - cwd: params.workdir, - host: "gateway", - security: hostSecurity, - ask: hostAsk, - agentId: params.agentId, - resolvedPath, - sessionKey: params.sessionKey, - timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS, - }, - ); - const decisionValue = - decisionResult && typeof decisionResult === "object" - ? (decisionResult as { decision?: unknown }).decision - : undefined; - decision = typeof decisionValue === "string" ? decisionValue : null; + decision = await requestExecApprovalDecision({ + id: approvalId, + command: params.command, + cwd: params.workdir, + host: "gateway", + security: hostSecurity, + ask: hostAsk, + agentId: params.agentId, + resolvedPath, + sessionKey: params.sessionKey, + }); } catch { emitExecSystemEvent( `Exec denied (gateway id=${approvalId}, approval-request-failed): ${params.command}`, diff --git a/src/agents/bash-tools.exec-host-node.ts b/src/agents/bash-tools.exec-host-node.ts index 7023473cdb8ad..3cca1bc121ab5 100644 --- a/src/agents/bash-tools.exec-host-node.ts +++ b/src/agents/bash-tools.exec-host-node.ts @@ -12,8 +12,8 @@ import { resolveExecApprovalsFromFile, } from "../infra/exec-approvals.js"; import { buildNodeShellCommand } from "../infra/node-shell.js"; +import { requestExecApprovalDecision } from "./bash-tools.exec-approval-request.js"; import { - DEFAULT_APPROVAL_REQUEST_TIMEOUT_MS, DEFAULT_APPROVAL_TIMEOUT_MS, createApprovalSlug, emitExecSystemEvent, @@ -178,27 +178,16 @@ export async function executeNodeHostCommand( void (async () => { let decision: string | null = null; try { - const decisionResult = await callGatewayTool<{ decision: string }>( - "exec.approval.request", - { timeoutMs: DEFAULT_APPROVAL_REQUEST_TIMEOUT_MS }, - { - id: approvalId, - command: params.command, - cwd: params.workdir, - host: "node", - security: hostSecurity, - ask: hostAsk, - agentId: params.agentId, - resolvedPath: undefined, - sessionKey: params.sessionKey, - timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS, - }, - ); - const decisionValue = - decisionResult && typeof decisionResult === "object" - ? (decisionResult as { decision?: unknown }).decision - : undefined; - decision = typeof decisionValue === "string" ? decisionValue : null; + decision = await requestExecApprovalDecision({ + id: approvalId, + command: params.command, + cwd: params.workdir, + host: "node", + security: hostSecurity, + ask: hostAsk, + agentId: params.agentId, + sessionKey: params.sessionKey, + }); } catch { emitExecSystemEvent( `Exec denied (node=${nodeId} id=${approvalId}, approval-request-failed): ${params.command}`, From eb9861b20a4f5efdc4600486013ced97bfa20f30 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 14:25:08 +0000 Subject: [PATCH 0022/1006] test: share memory manager bootstrap helper --- src/memory/manager.async-search.test.ts | 10 +++------- src/memory/manager.vector-dedupe.test.ts | 10 +++------- src/memory/test-manager.ts | 13 +++++++++++++ 3 files changed, 19 insertions(+), 14 deletions(-) create mode 100644 src/memory/test-manager.ts diff --git a/src/memory/manager.async-search.test.ts b/src/memory/manager.async-search.test.ts index 2f25db4b23fd2..fdf5a9780900a 100644 --- a/src/memory/manager.async-search.test.ts +++ b/src/memory/manager.async-search.test.ts @@ -3,8 +3,9 @@ import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; -import { getMemorySearchManager, type MemoryIndexManager } from "./index.js"; +import type { MemoryIndexManager } from "./index.js"; import { createOpenAIEmbeddingProviderMock } from "./test-embeddings-mock.js"; +import { createMemoryManagerOrThrow } from "./test-manager.js"; const embedBatch = vi.fn(async () => []); const embedQuery = vi.fn(async () => [0.2, 0.2, 0.2]); @@ -56,12 +57,7 @@ describe("memory search async sync", () => { }, } as OpenClawConfig; - const result = await getMemorySearchManager({ cfg, agentId: "main" }); - expect(result.manager).not.toBeNull(); - if (!result.manager) { - throw new Error("manager missing"); - } - manager = result.manager as unknown as MemoryIndexManager; + manager = await createMemoryManagerOrThrow(cfg); const pending = new Promise(() => {}); const syncMock = vi.fn(async () => pending); diff --git a/src/memory/manager.vector-dedupe.test.ts b/src/memory/manager.vector-dedupe.test.ts index dcaf1dffaf202..14f2e2f8f3f75 100644 --- a/src/memory/manager.vector-dedupe.test.ts +++ b/src/memory/manager.vector-dedupe.test.ts @@ -3,8 +3,9 @@ import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; -import { getMemorySearchManager, type MemoryIndexManager } from "./index.js"; +import type { MemoryIndexManager } from "./index.js"; import { buildFileEntry } from "./internal.js"; +import { createMemoryManagerOrThrow } from "./test-manager.js"; vi.mock("./embeddings.js", () => { return { @@ -57,12 +58,7 @@ describe("memory vector dedupe", () => { }, } as OpenClawConfig; - const result = await getMemorySearchManager({ cfg, agentId: "main" }); - expect(result.manager).not.toBeNull(); - if (!result.manager) { - throw new Error("manager missing"); - } - manager = result.manager as unknown as MemoryIndexManager; + manager = await createMemoryManagerOrThrow(cfg); const db = ( manager as unknown as { diff --git a/src/memory/test-manager.ts b/src/memory/test-manager.ts new file mode 100644 index 0000000000000..67d317bd0e5f2 --- /dev/null +++ b/src/memory/test-manager.ts @@ -0,0 +1,13 @@ +import type { OpenClawConfig } from "../config/config.js"; +import { getMemorySearchManager, type MemoryIndexManager } from "./index.js"; + +export async function createMemoryManagerOrThrow( + cfg: OpenClawConfig, + agentId = "main", +): Promise { + const result = await getMemorySearchManager({ cfg, agentId }); + if (!result.manager) { + throw new Error("manager missing"); + } + return result.manager as unknown as MemoryIndexManager; +} From efca61e3ac0b905ad420794efdb1dc09ab00882a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 14:26:59 +0000 Subject: [PATCH 0023/1006] test: share cron tool mock harness --- src/agents/tools/cron-tool.e2e.test.ts | 13 ++----------- src/agents/tools/cron-tool.flat-params.test.ts | 13 ++----------- src/agents/tools/cron-tool.test-harness.ts | 11 +++++++++++ 3 files changed, 15 insertions(+), 22 deletions(-) create mode 100644 src/agents/tools/cron-tool.test-harness.ts diff --git a/src/agents/tools/cron-tool.e2e.test.ts b/src/agents/tools/cron-tool.e2e.test.ts index 9c030280f606a..fd9039d5dbaf5 100644 --- a/src/agents/tools/cron-tool.e2e.test.ts +++ b/src/agents/tools/cron-tool.e2e.test.ts @@ -1,15 +1,6 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const callGatewayMock = vi.fn(); -vi.mock("../../gateway/call.js", () => ({ - callGateway: (opts: unknown) => callGatewayMock(opts), -})); - -vi.mock("../agent-scope.js", () => ({ - resolveSessionAgentId: () => "agent-123", -})); - +import { beforeEach, describe, expect, it } from "vitest"; import { createCronTool } from "./cron-tool.js"; +import { callGatewayMock } from "./cron-tool.test-harness.js"; describe("cron tool", () => { async function executeAddAndReadDelivery(params: { diff --git a/src/agents/tools/cron-tool.flat-params.test.ts b/src/agents/tools/cron-tool.flat-params.test.ts index 2a96b451073e1..5d88bda6e8dfc 100644 --- a/src/agents/tools/cron-tool.flat-params.test.ts +++ b/src/agents/tools/cron-tool.flat-params.test.ts @@ -1,15 +1,6 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const callGatewayMock = vi.fn(); -vi.mock("../../gateway/call.js", () => ({ - callGateway: (opts: unknown) => callGatewayMock(opts), -})); - -vi.mock("../agent-scope.js", () => ({ - resolveSessionAgentId: () => "agent-123", -})); - +import { beforeEach, describe, expect, it } from "vitest"; import { createCronTool } from "./cron-tool.js"; +import { callGatewayMock } from "./cron-tool.test-harness.js"; describe("cron tool flat-params", () => { beforeEach(() => { diff --git a/src/agents/tools/cron-tool.test-harness.ts b/src/agents/tools/cron-tool.test-harness.ts new file mode 100644 index 0000000000000..cf5c84564d1f1 --- /dev/null +++ b/src/agents/tools/cron-tool.test-harness.ts @@ -0,0 +1,11 @@ +import { vi } from "vitest"; + +export const callGatewayMock = vi.fn(); + +vi.mock("../../gateway/call.js", () => ({ + callGateway: (opts: unknown) => callGatewayMock(opts), +})); + +vi.mock("../agent-scope.js", () => ({ + resolveSessionAgentId: () => "agent-123", +})); From 9130fd2b06d64e5453bc70516eaaa0453baa7914 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 15:27:41 +0100 Subject: [PATCH 0024/1006] ci: harden workflow action input handling --- .github/actions/setup-node-env/action.yml | 27 +++++-- .../actions/setup-pnpm-store-cache/action.yml | 8 +- .github/workflows/ci.yml | 2 +- .github/workflows/install-smoke.yml | 2 +- .github/workflows/workflow-sanity.yml | 21 +++++ ...ck-composite-action-input-interpolation.py | 81 +++++++++++++++++++ 6 files changed, 132 insertions(+), 9 deletions(-) create mode 100644 scripts/check-composite-action-input-interpolation.py diff --git a/.github/actions/setup-node-env/action.yml b/.github/actions/setup-node-env/action.yml index a722982004b91..334cd3c24fb94 100644 --- a/.github/actions/setup-node-env/action.yml +++ b/.github/actions/setup-node-env/action.yml @@ -37,7 +37,7 @@ runs: exit 1 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: ${{ inputs.node-version }} check-latest: true @@ -70,14 +70,29 @@ runs: shell: bash env: CI: "true" + FROZEN_LOCKFILE: ${{ inputs.frozen-lockfile }} run: | + set -euo pipefail export PATH="$NODE_BIN:$PATH" which node node -v pnpm -v - LOCKFILE_FLAG="" - if [ "${{ inputs.frozen-lockfile }}" = "true" ]; then - LOCKFILE_FLAG="--frozen-lockfile" + case "$FROZEN_LOCKFILE" in + true) LOCKFILE_FLAG="--frozen-lockfile" ;; + false) LOCKFILE_FLAG="" ;; + *) + echo "::error::Invalid frozen-lockfile input: '$FROZEN_LOCKFILE' (expected true or false)" + exit 2 + ;; + esac + + install_args=( + install + --ignore-scripts=false + --config.engine-strict=false + --config.enable-pre-post-scripts=true + ) + if [ -n "$LOCKFILE_FLAG" ]; then + install_args+=("$LOCKFILE_FLAG") fi - pnpm install $LOCKFILE_FLAG --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || \ - pnpm install $LOCKFILE_FLAG --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true + pnpm "${install_args[@]}" || pnpm "${install_args[@]}" diff --git a/.github/actions/setup-pnpm-store-cache/action.yml b/.github/actions/setup-pnpm-store-cache/action.yml index c866393ee430b..8e25492ac9229 100644 --- a/.github/actions/setup-pnpm-store-cache/action.yml +++ b/.github/actions/setup-pnpm-store-cache/action.yml @@ -14,11 +14,17 @@ runs: steps: - name: Setup pnpm (corepack retry) shell: bash + env: + PNPM_VERSION: ${{ inputs.pnpm-version }} run: | set -euo pipefail + if [[ ! "$PNPM_VERSION" =~ ^[0-9]+(\.[0-9]+){1,2}([.-][0-9A-Za-z.-]+)?$ ]]; then + echo "::error::Invalid pnpm-version input: '$PNPM_VERSION'" + exit 2 + fi corepack enable for attempt in 1 2 3; do - if corepack prepare "pnpm@${{ inputs.pnpm-version }}" --activate; then + if corepack prepare "pnpm@$PNPM_VERSION" --activate; then pnpm -v exit 0 fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index efaf8039a3761..cb67c4423103c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -368,7 +368,7 @@ jobs: test -s dist/plugin-sdk/index.js - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 22.x check-latest: true diff --git a/.github/workflows/install-smoke.yml b/.github/workflows/install-smoke.yml index 45154a5fab4f8..6a59074b25c38 100644 --- a/.github/workflows/install-smoke.yml +++ b/.github/workflows/install-smoke.yml @@ -34,7 +34,7 @@ jobs: uses: actions/checkout@v4 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 22.x check-latest: true diff --git a/.github/workflows/workflow-sanity.yml b/.github/workflows/workflow-sanity.yml index 438a71162da82..4629db63f83f0 100644 --- a/.github/workflows/workflow-sanity.yml +++ b/.github/workflows/workflow-sanity.yml @@ -40,3 +40,24 @@ jobs: print(f"- {path}") sys.exit(1) PY + + actionlint: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install actionlint + shell: bash + run: | + set -euo pipefail + ACTIONLINT_VERSION="1.7.11" + archive="actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" + curl -sSfL "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/${archive}" | tar -xz actionlint + sudo mv actionlint /usr/local/bin/actionlint + + - name: Lint workflows + run: actionlint + + - name: Disallow direct inputs interpolation in composite run blocks + run: python3 scripts/check-composite-action-input-interpolation.py diff --git a/scripts/check-composite-action-input-interpolation.py b/scripts/check-composite-action-input-interpolation.py new file mode 100644 index 0000000000000..18c52501b85ed --- /dev/null +++ b/scripts/check-composite-action-input-interpolation.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import pathlib +import re +import sys + + +INPUT_INTERPOLATION_RE = re.compile(r"\$\{\{\s*inputs\.") +RUN_LINE_RE = re.compile(r"^(\s*)run:\s*(.*)$") +USING_COMPOSITE_RE = re.compile(r"^\s*using:\s*composite\s*$", re.MULTILINE) + + +def indentation(line: str) -> int: + return len(line) - len(line.lstrip(" ")) + + +def scan_file(path: pathlib.Path) -> list[tuple[int, str]]: + text = path.read_text(encoding="utf-8") + if not USING_COMPOSITE_RE.search(text): + return [] + + lines = text.splitlines() + violations: list[tuple[int, str]] = [] + line_count = len(lines) + index = 0 + + while index < line_count: + line = lines[index] + match = RUN_LINE_RE.match(line) + if not match: + index += 1 + continue + + run_indent = len(match.group(1)) + run_value = match.group(2).strip() + line_no = index + 1 + + if run_value and run_value[0] not in ("|", ">"): + if INPUT_INTERPOLATION_RE.search(run_value): + violations.append((line_no, line.strip())) + index += 1 + continue + + index += 1 + while index < line_count: + script_line = lines[index] + if script_line.strip() == "": + index += 1 + continue + if indentation(script_line) <= run_indent: + break + if INPUT_INTERPOLATION_RE.search(script_line): + violations.append((index + 1, script_line.strip())) + index += 1 + + return violations + + +def main() -> int: + root = pathlib.Path(".github/actions") + files = sorted(root.rglob("action.y*ml")) + all_violations: list[tuple[pathlib.Path, int, str]] = [] + + for file_path in files: + for line_no, line in scan_file(file_path): + all_violations.append((file_path, line_no, line)) + + if all_violations: + print("Disallowed direct inputs interpolation in composite run blocks:") + for file_path, line_no, line in all_violations: + print(f"- {file_path}:{line_no}: {line}") + print("Use env: and reference shell variables instead.") + return 1 + + print("No direct inputs interpolation found in composite run blocks.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 3d7ad1cfca4daaa84cd553e843e0e08fa6201349 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 15:27:45 +0100 Subject: [PATCH 0025/1006] fix(security): centralize owner-only tool gating and scope maps --- CHANGELOG.md | 4 +- extensions/feishu/src/bot.ts | 4 +- src/agents/openclaw-gateway-tool.e2e.test.ts | 12 +- src/agents/openclaw-tools.ts | 2 - src/agents/tool-policy.e2e.test.ts | 15 ++ src/agents/tool-policy.ts | 24 +- src/agents/tools/common.ts | 19 +- src/agents/tools/cron-tool.e2e.test.ts | 11 +- src/agents/tools/cron-tool.ts | 5 +- src/agents/tools/gateway-tool.ts | 5 +- .../reply/get-reply-inline-actions.ts | 5 +- .../plugins/agent-tools/whatsapp-login.ts | 1 + src/channels/plugins/types.core.ts | 4 +- src/gateway/call.ts | 250 ++++++++++++------ src/gateway/method-scopes.test.ts | 11 + src/gateway/method-scopes.ts | 245 ++++++++--------- 16 files changed, 369 insertions(+), 248 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d5f8382dd5bfe..8b33458faaeb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -80,8 +80,8 @@ Docs: https://docs.openclaw.ai - Security/Browser: route browser URL navigation through one SSRF-guarded validation path for tab-open/CDP-target/Playwright navigation flows and block private/metadata destinations by default (configurable via `browser.ssrfPolicy`). This ships in the next npm release. Thanks @dorjoos for reporting. - Security/Exec: for the next npm release, harden safe-bin stdin-only enforcement by blocking output/recursive flags (`sort -o/--output`, grep recursion) and tightening default safe bins to remove `sort`/`grep`, preventing safe-bin allowlist bypass for file writes/recursive reads. Thanks @nedlir for reporting. - Cron/Webhooks: protect cron webhook POST delivery with SSRF-guarded outbound fetch (`fetchWithSsrFGuard`) to block private/metadata destinations before request dispatch. Thanks @Adam55A-code. -- Security/Gateway/Agents: remove implicit admin scopes from agent tool gateway calls by classifying methods to least-privilege operator scopes, and restrict `cron`/`gateway` tools to owner senders (with explicit runtime owner checks) to prevent non-owner DM privilege escalation. Ships in the next npm release. Thanks @Adam55A-code for reporting. -- Security/Gateway: centralize gateway method-scope authorization and default non-CLI gateway callers to least-privilege method scopes, with explicit CLI scope handling and regression coverage to prevent scope drift. +- Security/Gateway/Agents: remove implicit admin scopes from agent tool gateway calls by classifying methods to least-privilege operator scopes, and enforce owner-only tooling (`cron`, `gateway`, `whatsapp_login`) through centralized tool-policy wrappers plus tool metadata to prevent non-owner DM privilege escalation. Ships in the next npm release. Thanks @Adam55A-code for reporting. +- Security/Gateway: centralize gateway method-scope authorization and default non-CLI gateway callers to least-privilege method scopes, with explicit CLI scope handling, full core-handler scope classification coverage, and regression guards to prevent scope drift. - Security/Net: block SSRF bypass via NAT64 (`64:ff9b::/96`, `64:ff9b:1::/48`), 6to4 (`2002::/16`), and Teredo (`2001:0000::/32`) IPv6 transition addresses, and fail closed on IPv6 parse errors. Thanks @jackhax. ## 2026.2.17 diff --git a/extensions/feishu/src/bot.ts b/extensions/feishu/src/bot.ts index 1a534ed40c0c0..9e1ea5934ac51 100644 --- a/extensions/feishu/src/bot.ts +++ b/extensions/feishu/src/bot.ts @@ -7,8 +7,6 @@ import { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry, } from "openclaw/plugin-sdk"; -import type { FeishuMessageContext, FeishuMediaInfo, ResolvedFeishuAccount } from "./types.js"; -import type { DynamicAgentCreationConfig } from "./types.js"; import { resolveFeishuAccount } from "./accounts.js"; import { createFeishuClient } from "./client.js"; import { tryRecordMessage } from "./dedup.js"; @@ -30,6 +28,8 @@ import { import { createFeishuReplyDispatcher } from "./reply-dispatcher.js"; import { getFeishuRuntime } from "./runtime.js"; import { getMessageFeishu, sendMessageFeishu } from "./send.js"; +import type { FeishuMessageContext, FeishuMediaInfo, ResolvedFeishuAccount } from "./types.js"; +import type { DynamicAgentCreationConfig } from "./types.js"; // --- Permission error extraction --- // Extract permission grant URL from Feishu API error response. diff --git a/src/agents/openclaw-gateway-tool.e2e.test.ts b/src/agents/openclaw-gateway-tool.e2e.test.ts index 04137f7dc1a06..77eb4d20e5199 100644 --- a/src/agents/openclaw-gateway-tool.e2e.test.ts +++ b/src/agents/openclaw-gateway-tool.e2e.test.ts @@ -17,23 +17,15 @@ vi.mock("./tools/gateway.js", () => ({ })); describe("gateway tool", () => { - it("rejects non-owner callers explicitly", async () => { - const { callGatewayTool } = await import("./tools/gateway.js"); + it("marks gateway as owner-only", async () => { const tool = createOpenClawTools({ - senderIsOwner: false, config: { commands: { restart: true } }, }).find((candidate) => candidate.name === "gateway"); expect(tool).toBeDefined(); if (!tool) { throw new Error("missing gateway tool"); } - - await expect( - tool.execute("call-owner-check", { - action: "config.get", - }), - ).rejects.toThrow("Tool restricted to owner senders."); - expect(callGatewayTool).not.toHaveBeenCalled(); + expect(tool.ownerOnly).toBe(true); }); it("schedules SIGUSR1 restart", async () => { diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index fbdab56352afb..1c99a6dce5f8c 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -114,7 +114,6 @@ export function createOpenClawTools(options?: { }), createCronTool({ agentSessionKey: options?.agentSessionKey, - senderIsOwner: options?.senderIsOwner, }), ...(messageTool ? [messageTool] : []), createTtsTool({ @@ -124,7 +123,6 @@ export function createOpenClawTools(options?: { createGatewayTool({ agentSessionKey: options?.agentSessionKey, config: options?.config, - senderIsOwner: options?.senderIsOwner, }), createAgentsListTool({ agentSessionKey: options?.agentSessionKey, diff --git a/src/agents/tool-policy.e2e.test.ts b/src/agents/tool-policy.e2e.test.ts index 57396fb546e4a..cf6ab15d34138 100644 --- a/src/agents/tool-policy.e2e.test.ts +++ b/src/agents/tool-policy.e2e.test.ts @@ -22,11 +22,13 @@ function createOwnerPolicyTools() { }, { name: "cron", + ownerOnly: true, // oxlint-disable-next-line typescript/no-explicit-any execute: async () => ({ content: [], details: {} }) as any, }, { name: "gateway", + ownerOnly: true, // oxlint-disable-next-line typescript/no-explicit-any execute: async () => ({ content: [], details: {} }) as any, }, @@ -89,6 +91,19 @@ describe("tool-policy", () => { const filtered = applyOwnerOnlyToolPolicy(tools, true); expect(filtered.map((t) => t.name)).toEqual(["read", "cron", "gateway", "whatsapp_login"]); }); + + it("honors ownerOnly metadata for custom tool names", async () => { + const tools = [ + { + name: "custom_admin_tool", + ownerOnly: true, + // oxlint-disable-next-line typescript/no-explicit-any + execute: async () => ({ content: [], details: {} }) as any, + }, + ] as unknown as AnyAgentTool[]; + expect(applyOwnerOnlyToolPolicy(tools, false)).toEqual([]); + expect(applyOwnerOnlyToolPolicy(tools, true)).toHaveLength(1); + }); }); describe("TOOL_POLICY_CONFORMANCE", () => { diff --git a/src/agents/tool-policy.ts b/src/agents/tool-policy.ts index 0b3edfbb3997f..393a110069e26 100644 --- a/src/agents/tool-policy.ts +++ b/src/agents/tool-policy.ts @@ -1,4 +1,4 @@ -import { OWNER_ONLY_TOOL_ERROR, type AnyAgentTool } from "./tools/common.js"; +import { type AnyAgentTool, wrapOwnerOnlyToolExecution } from "./tools/common.js"; export type ToolProfileId = "minimal" | "coding" | "messaging" | "full"; @@ -60,7 +60,7 @@ export const TOOL_GROUPS: Record = { ], }; -const OWNER_ONLY_TOOL_NAMES = new Set(["whatsapp_login", "cron", "gateway"]); +const OWNER_ONLY_TOOL_NAME_FALLBACKS = new Set(["whatsapp_login", "cron", "gateway"]); const TOOL_PROFILES: Record = { minimal: { @@ -87,28 +87,24 @@ export function normalizeToolName(name: string) { } export function isOwnerOnlyToolName(name: string) { - return OWNER_ONLY_TOOL_NAMES.has(normalizeToolName(name)); + return OWNER_ONLY_TOOL_NAME_FALLBACKS.has(normalizeToolName(name)); +} + +function isOwnerOnlyTool(tool: AnyAgentTool) { + return tool.ownerOnly === true || isOwnerOnlyToolName(tool.name); } export function applyOwnerOnlyToolPolicy(tools: AnyAgentTool[], senderIsOwner: boolean) { const withGuard = tools.map((tool) => { - if (!isOwnerOnlyToolName(tool.name)) { - return tool; - } - if (senderIsOwner || !tool.execute) { + if (!isOwnerOnlyTool(tool)) { return tool; } - return { - ...tool, - execute: async () => { - throw new Error(OWNER_ONLY_TOOL_ERROR); - }, - }; + return wrapOwnerOnlyToolExecution(tool, senderIsOwner); }); if (senderIsOwner) { return withGuard; } - return withGuard.filter((tool) => !isOwnerOnlyToolName(tool.name)); + return withGuard.filter((tool) => !isOwnerOnlyTool(tool)); } export function normalizeToolList(list?: string[]) { diff --git a/src/agents/tools/common.ts b/src/agents/tools/common.ts index 27f22be1d05eb..93f1db42ea662 100644 --- a/src/agents/tools/common.ts +++ b/src/agents/tools/common.ts @@ -5,7 +5,9 @@ import type { ImageSanitizationLimits } from "../image-sanitization.js"; import { sanitizeToolResultImages } from "../tool-images.js"; // oxlint-disable-next-line typescript/no-explicit-any -export type AnyAgentTool = AgentTool; +export type AnyAgentTool = AgentTool & { + ownerOnly?: boolean; +}; export type StringParamOptions = { required?: boolean; @@ -210,10 +212,19 @@ export function jsonResult(payload: unknown): AgentToolResult { }; } -export function assertOwnerSender(senderIsOwner?: boolean): void { - if (senderIsOwner === false) { - throw new Error(OWNER_ONLY_TOOL_ERROR); +export function wrapOwnerOnlyToolExecution( + tool: AnyAgentTool, + senderIsOwner: boolean, +): AnyAgentTool { + if (tool.ownerOnly !== true || senderIsOwner || !tool.execute) { + return tool; } + return { + ...tool, + execute: async () => { + throw new Error(OWNER_ONLY_TOOL_ERROR); + }, + }; } export async function imageResult(params: { diff --git a/src/agents/tools/cron-tool.e2e.test.ts b/src/agents/tools/cron-tool.e2e.test.ts index fd9039d5dbaf5..713c61b9d8cf3 100644 --- a/src/agents/tools/cron-tool.e2e.test.ts +++ b/src/agents/tools/cron-tool.e2e.test.ts @@ -30,14 +30,9 @@ describe("cron tool", () => { callGatewayMock.mockResolvedValue({ ok: true }); }); - it("rejects non-owner callers explicitly", async () => { - const tool = createCronTool({ senderIsOwner: false }); - await expect( - tool.execute("call-owner-check", { - action: "status", - }), - ).rejects.toThrow("Tool restricted to owner senders."); - expect(callGatewayMock).not.toHaveBeenCalled(); + it("marks cron as owner-only", async () => { + const tool = createCronTool(); + expect(tool.ownerOnly).toBe(true); }); it.each([ diff --git a/src/agents/tools/cron-tool.ts b/src/agents/tools/cron-tool.ts index 8a5b51519fa3a..35997b4e9b919 100644 --- a/src/agents/tools/cron-tool.ts +++ b/src/agents/tools/cron-tool.ts @@ -8,7 +8,7 @@ import { extractTextFromChatContent } from "../../shared/chat-content.js"; import { isRecord, truncateUtf16Safe } from "../../utils.js"; import { resolveSessionAgentId } from "../agent-scope.js"; import { optionalStringEnum, stringEnum } from "../schema/typebox.js"; -import { assertOwnerSender, type AnyAgentTool, jsonResult, readStringParam } from "./common.js"; +import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js"; import { callGatewayTool, readGatewayCallOptions, type GatewayCallOptions } from "./gateway.js"; import { resolveInternalSessionKey, resolveMainSessionAlias } from "./sessions-helpers.js"; @@ -48,7 +48,6 @@ const CronToolSchema = Type.Object({ type CronToolOptions = { agentSessionKey?: string; - senderIsOwner?: boolean; }; type ChatMessage = { @@ -202,6 +201,7 @@ export function createCronTool(opts?: CronToolOptions): AnyAgentTool { return { label: "Cron", name: "cron", + ownerOnly: true, description: `Manage Gateway cron jobs (status/list/add/update/remove/run/runs) and send wake events. ACTIONS: @@ -260,7 +260,6 @@ WAKE MODES (for wake action): Use jobId as the canonical identifier; id is accepted for compatibility. Use contextMessages (0-10) to add previous messages as context to the job text.`, parameters: CronToolSchema, execute: async (_toolCallId, args) => { - assertOwnerSender(opts?.senderIsOwner); const params = args as Record; const action = readStringParam(params, "action", { required: true }); const gatewayOpts: GatewayCallOptions = { diff --git a/src/agents/tools/gateway-tool.ts b/src/agents/tools/gateway-tool.ts index ee360082455ea..5cd59d756d916 100644 --- a/src/agents/tools/gateway-tool.ts +++ b/src/agents/tools/gateway-tool.ts @@ -10,7 +10,7 @@ import { } from "../../infra/restart-sentinel.js"; import { scheduleGatewaySigusr1Restart } from "../../infra/restart.js"; import { stringEnum } from "../schema/typebox.js"; -import { assertOwnerSender, type AnyAgentTool, jsonResult, readStringParam } from "./common.js"; +import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js"; import { callGatewayTool, readGatewayCallOptions } from "./gateway.js"; const DEFAULT_UPDATE_TIMEOUT_MS = 20 * 60_000; @@ -65,16 +65,15 @@ const GatewayToolSchema = Type.Object({ export function createGatewayTool(opts?: { agentSessionKey?: string; config?: OpenClawConfig; - senderIsOwner?: boolean; }): AnyAgentTool { return { label: "Gateway", name: "gateway", + ownerOnly: true, description: "Restart, apply config, or update the gateway in-place (SIGUSR1). Use config.patch for safe partial config updates (merges with existing). Use config.apply only when replacing entire config. Both trigger restart after writing. Always pass a human-readable completion message via the `note` parameter so the system can deliver it to the user after restart.", parameters: GatewayToolSchema, execute: async (_toolCallId, args) => { - assertOwnerSender(opts?.senderIsOwner); const params = args as Record; const action = readStringParam(params, "action", { required: true }); if (action === "restart") { diff --git a/src/auto-reply/reply/get-reply-inline-actions.ts b/src/auto-reply/reply/get-reply-inline-actions.ts index be943f8604e41..4dc6e5e7eec3b 100644 --- a/src/auto-reply/reply/get-reply-inline-actions.ts +++ b/src/auto-reply/reply/get-reply-inline-actions.ts @@ -1,6 +1,7 @@ import { collectTextContentBlocks } from "../../agents/content-blocks.js"; import { createOpenClawTools } from "../../agents/openclaw-tools.js"; import type { SkillCommandSpec } from "../../agents/skills.js"; +import { applyOwnerOnlyToolPolicy } from "../../agents/tool-policy.js"; import { getChannelDock } from "../../channels/dock.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { SessionEntry } from "../../config/sessions.js"; @@ -200,10 +201,10 @@ export async function handleInlineActions(params: { agentDir, workspaceDir, config: cfg, - senderIsOwner: command.senderIsOwner, }); + const authorizedTools = applyOwnerOnlyToolPolicy(tools, command.senderIsOwner); - const tool = tools.find((candidate) => candidate.name === dispatch.toolName); + const tool = authorizedTools.find((candidate) => candidate.name === dispatch.toolName); if (!tool) { typing.cleanup(); return { kind: "reply", reply: { text: `❌ Tool not available: ${dispatch.toolName}` } }; diff --git a/src/channels/plugins/agent-tools/whatsapp-login.ts b/src/channels/plugins/agent-tools/whatsapp-login.ts index 1418dcc4fe331..bba6380841087 100644 --- a/src/channels/plugins/agent-tools/whatsapp-login.ts +++ b/src/channels/plugins/agent-tools/whatsapp-login.ts @@ -5,6 +5,7 @@ export function createWhatsAppLoginTool(): ChannelAgentTool { return { label: "WhatsApp Login", name: "whatsapp_login", + ownerOnly: true, description: "Generate a WhatsApp QR code for linking, or wait for the scan to complete.", // NOTE: Using Type.Unsafe for action enum instead of Type.Union([Type.Literal(...)] // because Claude API on Vertex AI rejects nested anyOf schemas as invalid JSON Schema. diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index 63fde936deb3a..5c0b075b54f37 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -12,7 +12,9 @@ export type ChannelId = ChatChannelId | (string & {}); export type ChannelOutboundTargetMode = "explicit" | "implicit" | "heartbeat"; -export type ChannelAgentTool = AgentTool; +export type ChannelAgentTool = AgentTool & { + ownerOnly?: boolean; +}; export type ChannelAgentToolFactory = (params: { cfg?: OpenClawConfig }) => ChannelAgentTool[]; diff --git a/src/gateway/call.ts b/src/gateway/call.ts index 09845b7e1cf3d..300a556436e12 100644 --- a/src/gateway/call.ts +++ b/src/gateway/call.ts @@ -186,95 +186,153 @@ export function buildGatewayConnectionDetails( }; } -async function callGatewayWithScopes>( - opts: CallGatewayBaseOptions, - scopes: OperatorScope[], -): Promise { +type GatewayRemoteSettings = { + url?: string; + token?: string; + password?: string; + tlsFingerprint?: string; +}; + +type ResolvedGatewayCallContext = { + config: OpenClawConfig; + configPath: string; + isRemoteMode: boolean; + remote?: GatewayRemoteSettings; + urlOverride?: string; + remoteUrl?: string; + explicitAuth: ExplicitGatewayAuth; +}; + +function trimToUndefined(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function resolveGatewayCallTimeout(timeoutValue: unknown): { + timeoutMs: number; + safeTimerTimeoutMs: number; +} { const timeoutMs = - typeof opts.timeoutMs === "number" && Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : 10_000; + typeof timeoutValue === "number" && Number.isFinite(timeoutValue) ? timeoutValue : 10_000; const safeTimerTimeoutMs = Math.max(1, Math.min(Math.floor(timeoutMs), 2_147_483_647)); + return { timeoutMs, safeTimerTimeoutMs }; +} + +function resolveGatewayCallContext(opts: CallGatewayBaseOptions): ResolvedGatewayCallContext { const config = opts.config ?? loadConfig(); + const configPath = + opts.configPath ?? resolveConfigPath(process.env, resolveStateDir(process.env)); const isRemoteMode = config.gateway?.mode === "remote"; - const remote = isRemoteMode ? config.gateway?.remote : undefined; - const urlOverride = - typeof opts.url === "string" && opts.url.trim().length > 0 ? opts.url.trim() : undefined; + const remote = isRemoteMode + ? (config.gateway?.remote as GatewayRemoteSettings | undefined) + : undefined; + const urlOverride = trimToUndefined(opts.url); + const remoteUrl = trimToUndefined(remote?.url); const explicitAuth = resolveExplicitGatewayAuth({ token: opts.token, password: opts.password }); - ensureExplicitGatewayAuth({ - urlOverride, - auth: explicitAuth, - errorHint: "Fix: pass --token or --password (or gatewayToken in tools).", - configPath: opts.configPath ?? resolveConfigPath(process.env, resolveStateDir(process.env)), - }); - const remoteUrl = - typeof remote?.url === "string" && remote.url.trim().length > 0 ? remote.url.trim() : undefined; - if (isRemoteMode && !urlOverride && !remoteUrl) { - const configPath = - opts.configPath ?? resolveConfigPath(process.env, resolveStateDir(process.env)); - throw new Error( - [ - "gateway remote mode misconfigured: gateway.remote.url missing", - `Config: ${configPath}`, - "Fix: set gateway.remote.url, or set gateway.mode=local.", - ].join("\n"), - ); + return { config, configPath, isRemoteMode, remote, urlOverride, remoteUrl, explicitAuth }; +} + +function ensureRemoteModeUrlConfigured(context: ResolvedGatewayCallContext): void { + if (!context.isRemoteMode || context.urlOverride || context.remoteUrl) { + return; } - const authToken = config.gateway?.auth?.token; - const authPassword = config.gateway?.auth?.password; - const connectionDetails = buildGatewayConnectionDetails({ - config, - url: urlOverride, - ...(opts.configPath ? { configPath: opts.configPath } : {}), - }); - const url = connectionDetails.url; + throw new Error( + [ + "gateway remote mode misconfigured: gateway.remote.url missing", + `Config: ${context.configPath}`, + "Fix: set gateway.remote.url, or set gateway.mode=local.", + ].join("\n"), + ); +} + +function resolveGatewayCredentials(context: ResolvedGatewayCallContext): { + token?: string; + password?: string; +} { + const authToken = context.config.gateway?.auth?.token; + const authPassword = context.config.gateway?.auth?.password; + const token = + context.explicitAuth.token || + (!context.urlOverride + ? context.isRemoteMode + ? trimToUndefined(context.remote?.token) + : trimToUndefined(process.env.OPENCLAW_GATEWAY_TOKEN) || + trimToUndefined(process.env.CLAWDBOT_GATEWAY_TOKEN) || + trimToUndefined(authToken) + : undefined); + const password = + context.explicitAuth.password || + (!context.urlOverride + ? trimToUndefined(process.env.OPENCLAW_GATEWAY_PASSWORD) || + trimToUndefined(process.env.CLAWDBOT_GATEWAY_PASSWORD) || + (context.isRemoteMode + ? trimToUndefined(context.remote?.password) + : trimToUndefined(authPassword)) + : undefined); + return { token, password }; +} + +async function resolveGatewayTlsFingerprint(params: { + opts: CallGatewayBaseOptions; + context: ResolvedGatewayCallContext; + url: string; +}): Promise { + const { opts, context, url } = params; const useLocalTls = - config.gateway?.tls?.enabled === true && !urlOverride && !remoteUrl && url.startsWith("wss://"); - const tlsRuntime = useLocalTls ? await loadGatewayTlsRuntime(config.gateway?.tls) : undefined; + context.config.gateway?.tls?.enabled === true && + !context.urlOverride && + !context.remoteUrl && + url.startsWith("wss://"); + const tlsRuntime = useLocalTls + ? await loadGatewayTlsRuntime(context.config.gateway?.tls) + : undefined; + const overrideTlsFingerprint = trimToUndefined(opts.tlsFingerprint); const remoteTlsFingerprint = - isRemoteMode && !urlOverride && remoteUrl && typeof remote?.tlsFingerprint === "string" - ? remote.tlsFingerprint.trim() + context.isRemoteMode && !context.urlOverride && context.remoteUrl + ? trimToUndefined(context.remote?.tlsFingerprint) : undefined; - const overrideTlsFingerprint = - typeof opts.tlsFingerprint === "string" ? opts.tlsFingerprint.trim() : undefined; - const tlsFingerprint = + return ( overrideTlsFingerprint || remoteTlsFingerprint || - (tlsRuntime?.enabled ? tlsRuntime.fingerprintSha256 : undefined); - const token = - explicitAuth.token || - (!urlOverride - ? isRemoteMode - ? typeof remote?.token === "string" && remote.token.trim().length > 0 - ? remote.token.trim() - : undefined - : process.env.OPENCLAW_GATEWAY_TOKEN?.trim() || - process.env.CLAWDBOT_GATEWAY_TOKEN?.trim() || - (typeof authToken === "string" && authToken.trim().length > 0 - ? authToken.trim() - : undefined) - : undefined); - const password = - explicitAuth.password || - (!urlOverride - ? process.env.OPENCLAW_GATEWAY_PASSWORD?.trim() || - process.env.CLAWDBOT_GATEWAY_PASSWORD?.trim() || - (isRemoteMode - ? typeof remote?.password === "string" && remote.password.trim().length > 0 - ? remote.password.trim() - : undefined - : typeof authPassword === "string" && authPassword.trim().length > 0 - ? authPassword.trim() - : undefined) - : undefined); + (tlsRuntime?.enabled ? tlsRuntime.fingerprintSha256 : undefined) + ); +} - const formatCloseError = (code: number, reason: string) => { - const reasonText = reason?.trim() || "no close reason"; - const hint = - code === 1006 ? "abnormal closure (no close frame)" : code === 1000 ? "normal closure" : ""; - const suffix = hint ? ` ${hint}` : ""; - return `gateway closed (${code}${suffix}): ${reasonText}\n${connectionDetails.message}`; - }; - const formatTimeoutError = () => - `gateway timeout after ${timeoutMs}ms\n${connectionDetails.message}`; +function formatGatewayCloseError( + code: number, + reason: string, + connectionDetails: GatewayConnectionDetails, +): string { + const reasonText = reason?.trim() || "no close reason"; + const hint = + code === 1006 ? "abnormal closure (no close frame)" : code === 1000 ? "normal closure" : ""; + const suffix = hint ? ` ${hint}` : ""; + return `gateway closed (${code}${suffix}): ${reasonText}\n${connectionDetails.message}`; +} + +function formatGatewayTimeoutError( + timeoutMs: number, + connectionDetails: GatewayConnectionDetails, +): string { + return `gateway timeout after ${timeoutMs}ms\n${connectionDetails.message}`; +} + +async function executeGatewayRequestWithScopes(params: { + opts: CallGatewayBaseOptions; + scopes: OperatorScope[]; + url: string; + token?: string; + password?: string; + tlsFingerprint?: string; + timeoutMs: number; + safeTimerTimeoutMs: number; + connectionDetails: GatewayConnectionDetails; +}): Promise { + const { opts, scopes, url, token, password, tlsFingerprint, timeoutMs, safeTimerTimeoutMs } = + params; return await new Promise((resolve, reject) => { let settled = false; let ignoreClose = false; @@ -327,20 +385,54 @@ async function callGatewayWithScopes>( } ignoreClose = true; client.stop(); - stop(new Error(formatCloseError(code, reason))); + stop(new Error(formatGatewayCloseError(code, reason, params.connectionDetails))); }, }); const timer = setTimeout(() => { ignoreClose = true; client.stop(); - stop(new Error(formatTimeoutError())); + stop(new Error(formatGatewayTimeoutError(timeoutMs, params.connectionDetails))); }, safeTimerTimeoutMs); client.start(); }); } +async function callGatewayWithScopes>( + opts: CallGatewayBaseOptions, + scopes: OperatorScope[], +): Promise { + const { timeoutMs, safeTimerTimeoutMs } = resolveGatewayCallTimeout(opts.timeoutMs); + const context = resolveGatewayCallContext(opts); + ensureExplicitGatewayAuth({ + urlOverride: context.urlOverride, + auth: context.explicitAuth, + errorHint: "Fix: pass --token or --password (or gatewayToken in tools).", + configPath: context.configPath, + }); + ensureRemoteModeUrlConfigured(context); + const connectionDetails = buildGatewayConnectionDetails({ + config: context.config, + url: context.urlOverride, + ...(opts.configPath ? { configPath: opts.configPath } : {}), + }); + const url = connectionDetails.url; + const tlsFingerprint = await resolveGatewayTlsFingerprint({ opts, context, url }); + const { token, password } = resolveGatewayCredentials(context); + return await executeGatewayRequestWithScopes({ + opts, + scopes, + url, + token, + password, + tlsFingerprint, + timeoutMs, + safeTimerTimeoutMs, + connectionDetails, + }); +} + export async function callGatewayScoped>( opts: CallGatewayScopedOptions, ): Promise { diff --git a/src/gateway/method-scopes.test.ts b/src/gateway/method-scopes.test.ts index 61a80bfeaae94..6a054fc64e4d9 100644 --- a/src/gateway/method-scopes.test.ts +++ b/src/gateway/method-scopes.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from "vitest"; import { authorizeOperatorScopesForMethod, + isGatewayMethodClassified, resolveLeastPrivilegeOperatorScopesForMethod, } from "./method-scopes.js"; +import { coreGatewayHandlers } from "./server-methods.js"; describe("method scope resolution", () => { it("classifies sessions.resolve as read and poll as write", () => { @@ -48,3 +50,12 @@ describe("operator scope authorization", () => { }); }); }); + +describe("core gateway method classification", () => { + it("classifies every exposed core gateway handler method", () => { + const unclassified = Object.keys(coreGatewayHandlers).filter( + (method) => !isGatewayMethodClassified(method), + ); + expect(unclassified).toEqual([]); + }); +}); diff --git a/src/gateway/method-scopes.ts b/src/gateway/method-scopes.ts index c270a4495ab5e..1fd9377ead68c 100644 --- a/src/gateway/method-scopes.ts +++ b/src/gateway/method-scopes.ts @@ -17,110 +17,137 @@ export const CLI_DEFAULT_OPERATOR_SCOPES: OperatorScope[] = [ PAIRING_SCOPE, ]; -const APPROVAL_METHODS = new Set([ - "exec.approval.request", - "exec.approval.waitDecision", - "exec.approval.resolve", -]); - const NODE_ROLE_METHODS = new Set(["node.invoke.result", "node.event", "skills.bins"]); -const PAIRING_METHODS = new Set([ - "node.pair.request", - "node.pair.list", - "node.pair.approve", - "node.pair.reject", - "node.pair.verify", - "device.pair.list", - "device.pair.approve", - "device.pair.reject", - "device.pair.remove", - "device.token.rotate", - "device.token.revoke", - "node.rename", -]); - -const ADMIN_METHOD_PREFIXES = ["exec.approvals."]; - -const READ_METHODS = new Set([ - "health", - "logs.tail", - "channels.status", - "status", - "usage.status", - "usage.cost", - "tts.status", - "tts.providers", - "models.list", - "agents.list", - "agent.identity.get", - "skills.status", - "voicewake.get", - "sessions.list", - "sessions.preview", - "sessions.resolve", - "cron.list", - "cron.status", - "cron.runs", - "system-presence", - "last-heartbeat", - "node.list", - "node.describe", - "chat.history", - "config.get", - "talk.config", -]); - -const WRITE_METHODS = new Set([ - "send", - "poll", - "agent", - "agent.wait", - "wake", - "talk.mode", - "tts.enable", - "tts.disable", - "tts.convert", - "tts.setProvider", - "voicewake.set", - "node.invoke", - "chat.send", - "chat.abort", - "browser.request", - "push.test", -]); - -const ADMIN_METHODS = new Set([ - "channels.logout", - "agents.create", - "agents.update", - "agents.delete", - "skills.install", - "skills.update", - "cron.add", - "cron.update", - "cron.remove", - "cron.run", - "sessions.patch", - "sessions.reset", - "sessions.delete", - "sessions.compact", -]); +const METHOD_SCOPE_GROUPS: Record = { + [APPROVALS_SCOPE]: [ + "exec.approval.request", + "exec.approval.waitDecision", + "exec.approval.resolve", + ], + [PAIRING_SCOPE]: [ + "node.pair.request", + "node.pair.list", + "node.pair.approve", + "node.pair.reject", + "node.pair.verify", + "device.pair.list", + "device.pair.approve", + "device.pair.reject", + "device.pair.remove", + "device.token.rotate", + "device.token.revoke", + "node.rename", + ], + [READ_SCOPE]: [ + "health", + "logs.tail", + "channels.status", + "status", + "usage.status", + "usage.cost", + "tts.status", + "tts.providers", + "models.list", + "agents.list", + "agent.identity.get", + "skills.status", + "voicewake.get", + "sessions.list", + "sessions.preview", + "sessions.resolve", + "sessions.usage", + "sessions.usage.timeseries", + "sessions.usage.logs", + "cron.list", + "cron.status", + "cron.runs", + "system-presence", + "last-heartbeat", + "node.list", + "node.describe", + "chat.history", + "config.get", + "talk.config", + "agents.files.list", + "agents.files.get", + ], + [WRITE_SCOPE]: [ + "send", + "poll", + "agent", + "agent.wait", + "wake", + "talk.mode", + "tts.enable", + "tts.disable", + "tts.convert", + "tts.setProvider", + "voicewake.set", + "node.invoke", + "chat.send", + "chat.abort", + "browser.request", + "push.test", + ], + [ADMIN_SCOPE]: [ + "channels.logout", + "agents.create", + "agents.update", + "agents.delete", + "skills.install", + "skills.update", + "cron.add", + "cron.update", + "cron.remove", + "cron.run", + "sessions.patch", + "sessions.reset", + "sessions.delete", + "sessions.compact", + "connect", + "chat.inject", + "web.login.start", + "web.login.wait", + "set-heartbeats", + "system-event", + "agents.files.set", + ], +}; + +const ADMIN_METHOD_PREFIXES = ["exec.approvals.", "config.", "wizard.", "update."] as const; + +const METHOD_SCOPE_BY_NAME = new Map( + Object.entries(METHOD_SCOPE_GROUPS).flatMap(([scope, methods]) => + methods.map((method) => [method, scope as OperatorScope]), + ), +); + +function resolveScopedMethod(method: string): OperatorScope | undefined { + const explicitScope = METHOD_SCOPE_BY_NAME.get(method); + if (explicitScope) { + return explicitScope; + } + if (ADMIN_METHOD_PREFIXES.some((prefix) => method.startsWith(prefix))) { + return ADMIN_SCOPE; + } + return undefined; +} export function isApprovalMethod(method: string): boolean { - return APPROVAL_METHODS.has(method); + return resolveScopedMethod(method) === APPROVALS_SCOPE; } export function isPairingMethod(method: string): boolean { - return PAIRING_METHODS.has(method); + return resolveScopedMethod(method) === PAIRING_SCOPE; } export function isReadMethod(method: string): boolean { - return READ_METHODS.has(method); + return resolveScopedMethod(method) === READ_SCOPE; } export function isWriteMethod(method: string): boolean { - return WRITE_METHODS.has(method); + return resolveScopedMethod(method) === WRITE_SCOPE; } export function isNodeRoleMethod(method: string): boolean { @@ -128,36 +155,11 @@ export function isNodeRoleMethod(method: string): boolean { } export function isAdminOnlyMethod(method: string): boolean { - if (ADMIN_METHOD_PREFIXES.some((prefix) => method.startsWith(prefix))) { - return true; - } - if ( - method.startsWith("config.") || - method.startsWith("wizard.") || - method.startsWith("update.") - ) { - return true; - } - return ADMIN_METHODS.has(method); + return resolveScopedMethod(method) === ADMIN_SCOPE; } export function resolveRequiredOperatorScopeForMethod(method: string): OperatorScope | undefined { - if (isApprovalMethod(method)) { - return APPROVALS_SCOPE; - } - if (isPairingMethod(method)) { - return PAIRING_SCOPE; - } - if (isReadMethod(method)) { - return READ_SCOPE; - } - if (isWriteMethod(method)) { - return WRITE_SCOPE; - } - if (isAdminOnlyMethod(method)) { - return ADMIN_SCOPE; - } - return undefined; + return resolveScopedMethod(method); } export function resolveLeastPrivilegeOperatorScopesForMethod(method: string): OperatorScope[] { @@ -188,3 +190,10 @@ export function authorizeOperatorScopesForMethod( } return { allowed: false, missingScope: requiredScope }; } + +export function isGatewayMethodClassified(method: string): boolean { + if (isNodeRoleMethod(method)) { + return true; + } + return resolveRequiredOperatorScopeForMethod(method) !== undefined; +} From b40821b068e101a30d7846d2ce1a30a923e06478 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 15:33:25 +0100 Subject: [PATCH 0026/1006] fix: harden ACP secret handling and exec preflight boundaries --- CHANGELOG.md | 1 + docs/cli/acp.md | 14 ++++- docs/gateway/security/index.md | 43 +++++++------ docs/tools/exec.md | 3 + src/acp/secret-file.ts | 22 +++++++ src/acp/server.ts | 44 ++++++++++++- src/acp/translator.prompt-prefix.test.ts | 56 +++++++++++++++++ src/acp/translator.ts | 6 +- .../bash-tools.exec.script-preflight.test.ts | 21 +++++++ src/agents/bash-tools.exec.ts | 18 ++++-- src/cli/acp-cli.option-collisions.test.ts | 63 +++++++++++++++++++ src/cli/acp-cli.ts | 51 ++++++++++++++- src/security/audit.test.ts | 52 +++++++++++++++ src/security/audit.ts | 54 +++++++++++++++- 14 files changed, 412 insertions(+), 36 deletions(-) create mode 100644 src/acp/secret-file.ts create mode 100644 src/acp/translator.prompt-prefix.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b33458faaeb6..5a56440dd0f00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai - Security/Net: enforce strict dotted-decimal IPv4 literals in SSRF checks and fail closed on unsupported legacy forms (octal/hex/short/packed, for example `0177.0.0.1`, `127.1`, `2130706433`) before DNS lookup. - Security/Discord: enforce trusted-sender guild permission checks for moderation actions (`timeout`, `kick`, `ban`) and ignore untrusted `senderUserId` params to prevent privilege escalation in tool-driven flows. Thanks @aether-ai-agent for reporting. +- Security/ACP+Exec: add `openclaw acp --token-file/--password-file` secret-file support (with inline secret flag warnings), redact ACP working-directory prefixes to `~` home-relative paths, constrain exec script preflight file inspection to the effective `workdir` boundary, and add security-audit warnings when `tools.exec.host="sandbox"` is configured while sandbox mode is off. - Security/ACP: harden ACP bridge session management with duplicate-session refresh, idle-session reaping, oldest-idle soft-cap eviction, and burst rate limiting on session creation to reduce local DoS risk without disrupting normal IDE usage. - Security/Plugins/Hooks: add optional `--pin` for npm plugin/hook installs, persist resolved npm metadata (`name`, `version`, `spec`, integrity, shasum, timestamp), warn/confirm on integrity drift during updates, and extend `openclaw security audit` to flag unpinned specs, missing integrity metadata, and install-record version drift. - Security/Plugins: harden plugin discovery by blocking unsafe candidates (root escapes, world-writable paths, suspicious ownership), add startup warnings when `plugins.allow` is empty with discoverable non-bundled plugins, and warn on loaded plugins without install/load-path provenance. diff --git a/docs/cli/acp.md b/docs/cli/acp.md index 46b78cce6f51d..9535509016d23 100644 --- a/docs/cli/acp.md +++ b/docs/cli/acp.md @@ -21,6 +21,9 @@ openclaw acp # Remote Gateway openclaw acp --url wss://gateway-host:18789 --token +# Remote Gateway (token from file) +openclaw acp --url wss://gateway-host:18789 --token-file ~/.openclaw/gateway.token + # Attach to an existing session key openclaw acp --session agent:main:main @@ -40,7 +43,7 @@ It spawns the ACP bridge and lets you type prompts interactively. openclaw acp client # Point the spawned bridge at a remote Gateway -openclaw acp client --server-args --url wss://gateway-host:18789 --token +openclaw acp client --server-args --url wss://gateway-host:18789 --token-file ~/.openclaw/gateway.token # Override the server command (default: openclaw) openclaw acp client --server "node" --server-args openclaw.mjs acp --url ws://127.0.0.1:19001 @@ -66,6 +69,8 @@ Example direct run (no config write): ```bash openclaw acp --url wss://gateway-host:18789 --token +# preferred for local process safety +openclaw acp --url wss://gateway-host:18789 --token-file ~/.openclaw/gateway.token ``` ## Selecting agents @@ -153,7 +158,9 @@ Learn more about session keys at [/concepts/session](/concepts/session). - `--url `: Gateway WebSocket URL (defaults to gateway.remote.url when configured). - `--token `: Gateway auth token. +- `--token-file `: read Gateway auth token from file. - `--password `: Gateway auth password. +- `--password-file `: read Gateway auth password from file. - `--session `: default session key. - `--session-label