From 4c55a4fc28280520299482b1ce50ce2d10fc86fc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 19 Jul 2026 10:31:58 -0700 Subject: [PATCH] feat(plugins): expose requester context to tool hooks (#111190) * feat(plugins): expose requester context to tool hooks * chore(plugin-sdk): restore generated API baseline * chore: drop release-owned changelog entry * docs: refresh documentation map --- docs/docs_map.md | 1 + docs/plugins/hooks.md | 129 ++++++++++++++++++ .../codex/src/app-server/native-hook-relay.ts | 3 + .../src/app-server/run-attempt-resources.ts | 10 ++ .../run-attempt.native-hook-relay.test.ts | 11 ++ src/agents/agent-tools.before-tool-call.ts | 4 + ...tools.create-openclaw-coding-tools.test.ts | 20 ++- src/agents/agent-tools.ts | 14 +- src/agents/harness/native-hook-relay.test.ts | 14 ++ src/agents/harness/native-hook-relay.ts | 5 + src/plugins/hook-types.ts | 20 +++ 11 files changed, 228 insertions(+), 3 deletions(-) diff --git a/docs/docs_map.md b/docs/docs_map.md index 2f09459290ae..c796c9092728 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -5886,6 +5886,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H3: Channel pairing requests - H2: Debug runtime hooks - H2: Tool call policy + - H3: Sender-aware policy in one file - H3: Exec environment hook - H3: Tool result persistence - H2: Prompt and model hooks diff --git a/docs/plugins/hooks.md b/docs/plugins/hooks.md index 03b1f68cf7c8..06234dd93550 100644 --- a/docs/plugins/hooks.md +++ b/docs/plugins/hooks.md @@ -239,6 +239,10 @@ provider payloads, start the Gateway with `--raw-stream` and - optional `event.toolCallId` - context fields such as `ctx.agentId`, `ctx.sessionKey`, `ctx.sessionId`, `ctx.runId`, `ctx.toolKind`, `ctx.toolInputKind`, and diagnostic `ctx.trace` +- optional `ctx.requester`, the host-derived requester that initiated the current + message run. It can include `channel`, `accountId`, `senderId`, + `senderIsOwner`, and provider-native `roleIds`. Missing fields are unproven, + not false assurances; fail closed when policy requires them. It can return: @@ -278,6 +282,131 @@ Guard behavior for typed lifecycle hooks: - `onResolution` receives the resolved decision: `allow-once`, `allow-always`, `deny`, `timeout`, or `cancelled`. +### Sender-aware policy in one file + +A standalone plugin file can keep deployment-specific policy in code instead +of adding another configuration schema. This example gives owners every tool, +lets configured maintainers use a conservative tool and message-action set, +and exposes `/fix` to senders already authorized by the channel configuration: + +```typescript +import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; + +const AGENT_ID = "maintenance-agent"; +const MAINTAINER_SCOPES = [ + { + channel: "discord", + accountId: "operations", + senderIds: new Set(["maintainer-user-id"]), + roleIds: new Set(["maintainer-role-id"]), + }, +]; +const MAINTAINER_TOOLS = new Set(["read", "web_fetch", "web_search", "session_status", "message"]); +const MAINTAINER_MESSAGE_ACTIONS = new Set(["react", "reply", "thread-create", "thread-reply"]); + +export default definePluginEntry({ + id: "maintenance-access", + name: "Maintenance access", + description: "Apply sender-aware tool policy to the maintenance agent.", + register(api) { + api.on("before_tool_call", (event, ctx) => { + if (ctx.agentId !== AGENT_ID) { + return; + } + + const requester = ctx.requester; + if (requester?.senderIsOwner === true) { + return; + } + + const maintainerScope = requester + ? MAINTAINER_SCOPES.find( + (scope) => + scope.channel === requester.channel && scope.accountId === requester.accountId, + ) + : undefined; + const isMaintainer = + maintainerScope !== undefined && + ((requester?.senderId !== undefined && maintainerScope.senderIds.has(requester.senderId)) || + requester?.roleIds?.some((roleId) => maintainerScope.roleIds.has(roleId)) === true); + if (!isMaintainer) { + return { block: true, blockReason: "Maintainer access required." }; + } + + if (event.toolName === "message") { + const action = typeof event.params.action === "string" ? event.params.action : ""; + if (MAINTAINER_MESSAGE_ACTIONS.has(action)) { + return; + } + return { block: true, blockReason: `Owner required for message.${action || "unknown"}.` }; + } + + if (MAINTAINER_TOOLS.has(event.toolName)) { + return; + } + return { block: true, blockReason: `Owner required for ${event.toolName}.` }; + }); + + api.registerCommand({ + name: "fix", + description: "Ask the maintenance agent to investigate and fix an issue.", + acceptsArgs: true, + requireAuth: true, + handler: async (ctx) => + ctx.agentId === AGENT_ID + ? { continueAgent: true } + : { text: "This command is only available in the maintenance conversation." }, + }); + }, +}); +``` + +Load the file directly and restart the Gateway: + +```json5 +{ + agents: { + list: [ + { + id: "maintenance-agent", + workspace: "~/.openclaw/workspace-maintenance", + }, + ], + }, + bindings: [ + { + agentId: "maintenance-agent", + match: { + channel: "discord", + accountId: "operations", + peer: { kind: "channel", id: "maintenance-channel-id" }, + }, + }, + ], + plugins: { + load: { paths: ["~/.openclaw/policies/maintenance-access.ts"] }, + }, +} +``` + +`AGENT_ID` must name the agent bound to the maintenance conversation. The +binding selects that agent for normal messages and `/fix`; the standalone file +remains the single owner of owner-versus-maintainer tool policy. + +`requireAuth: true` reuses each channel's existing sender admission. For +Discord, a guild or channel `users`/`roles` allowlist can authorize the +maintenance audience. Other channels can use stable sender ids. The hook then +applies the finer per-tool decision on every tool call in the run, including +Codex native `PreToolUse` calls. It can veto a tool the model sees, but cannot +add a tool omitted by the host. Existing sandbox, exec approval, owner-only +core-tool, and channel policies still apply; the hook cannot grant past them. + +Scope sender and role ids to an exact channel/account pair as shown; both are +provider-local namespaces. Keep the allowlists conservative. Add write or +execution tools only when the deployment's sandbox and approval policy make +that safe. For automated or system runs, decide explicitly whether an absent +`ctx.requester` should pass; the example denies it for the scoped agent. + See [Plugin permission requests](/plugins/plugin-permission-requests) for approval routing, decision behavior, and when to use `requireApproval` instead of optional tools or exec approvals. diff --git a/extensions/codex/src/app-server/native-hook-relay.ts b/extensions/codex/src/app-server/native-hook-relay.ts index 1c61de815368..35ecd9227182 100644 --- a/extensions/codex/src/app-server/native-hook-relay.ts +++ b/extensions/codex/src/app-server/native-hook-relay.ts @@ -15,6 +15,7 @@ import { addTimerTimeoutGraceMs, finiteSecondsToTimerSafeMilliseconds, } from "openclaw/plugin-sdk/number-runtime"; +import type { PluginHookToolContext } from "openclaw/plugin-sdk/types"; import type { CodexAppServerRuntimeOptions } from "./config.js"; import { resolveCodexToolAbortTerminalReason } from "./dynamic-tool-execution.js"; import { nativeHookRelayUnregisterQueue } from "./native-hook-relay-state.js"; @@ -136,6 +137,7 @@ export function createCodexNativeHookRelay(params: { config: EmbeddedRunAttemptParams["config"]; runId: string; channelId?: string; + requester?: NonNullable; attemptTimeoutMs: number; startupTimeoutMs: number; turnStartTimeoutMs: number; @@ -163,6 +165,7 @@ export function createCodexNativeHookRelay(params: { ...(params.config ? { config: params.config } : {}), runId: params.runId, ...(params.channelId ? { channelId: params.channelId } : {}), + ...(params.requester ? { requester: params.requester } : {}), allowedEvents: params.events, preToolUseLoopDetection: params.loopDetectionPreToolUseRelay, ttlMs: resolveCodexNativeHookRelayTtlMs({ diff --git a/extensions/codex/src/app-server/run-attempt-resources.ts b/extensions/codex/src/app-server/run-attempt-resources.ts index ea0271253097..caf8a7b4cd8b 100644 --- a/extensions/codex/src/app-server/run-attempt-resources.ts +++ b/extensions/codex/src/app-server/run-attempt-resources.ts @@ -183,6 +183,15 @@ export function prepareCodexAttemptResources(prompt: CodexAttemptPrompt) { timeoutMs: params.timeoutMs, timeoutFloorMs: options.startupTimeoutFloorMs, }); + const requesterChannel = params.messageChannel ?? params.messageProvider; + const requester = { + ...(requesterChannel ? { channel: requesterChannel } : {}), + ...(params.agentAccountId ? { accountId: params.agentAccountId } : {}), + ...(params.senderId ? { senderId: params.senderId } : {}), + ...(params.senderIsOwner !== undefined ? { senderIsOwner: params.senderIsOwner } : {}), + ...(params.memberRoleIds?.length ? { roleIds: [...params.memberRoleIds] } : {}), + }; + const hasRequester = Object.keys(requester).length > 0; const buildNativeHookRelayFinalConfigPatch = ( decision: { action: "resume"; binding: CodexAppServerThreadBinding } | { action: "start" }, ) => { @@ -202,6 +211,7 @@ export function prepareCodexAttemptResources(prompt: CodexAttemptPrompt) { config: params.config, runId: params.runId, channelId: hookChannelId, + ...(hasRequester ? { requester } : {}), attemptTimeoutMs: params.timeoutMs, startupTimeoutMs, turnStartTimeoutMs: params.timeoutMs, diff --git a/extensions/codex/src/app-server/run-attempt.native-hook-relay.test.ts b/extensions/codex/src/app-server/run-attempt.native-hook-relay.test.ts index 3a5579073292..0cc27a856b0c 100644 --- a/extensions/codex/src/app-server/run-attempt.native-hook-relay.test.ts +++ b/extensions/codex/src/app-server/run-attempt.native-hook-relay.test.ts @@ -204,7 +204,11 @@ describe("runCodexAppServerAttempt native hook relay", () => { const harness = createStartedThreadHarness(); const params = createParams(sessionFile, workspaceDir); params.messageChannel = "discord"; + params.agentAccountId = "operations"; params.currentChannelId = "channel:target"; + params.memberRoleIds = ["maintainer-role"]; + params.senderId = "maintainer-user"; + params.senderIsOwner = false; const run = runCodexAppServerAttempt(params, { nativeHookRelay: { @@ -251,6 +255,13 @@ describe("runCodexAppServerAttempt native hook relay", () => { }); expect(nativeHookRelayTesting.getNativeHookRelayRegistrationForTests(relayId)).toMatchObject({ channelId: "target", + requester: { + channel: "discord", + accountId: "operations", + senderId: "maintainer-user", + senderIsOwner: false, + roleIds: ["maintainer-role"], + }, }); await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" }); diff --git a/src/agents/agent-tools.before-tool-call.ts b/src/agents/agent-tools.before-tool-call.ts index e8f048664d06..8f34916398a6 100644 --- a/src/agents/agent-tools.before-tool-call.ts +++ b/src/agents/agent-tools.before-tool-call.ts @@ -63,6 +63,7 @@ import { type PluginHookBeforeToolCallResult, type PluginHookToolInputKind, type PluginHookToolKind, + type PluginHookToolRequesterContext, } from "../plugins/types.js"; import { createLazyRuntimeSurface } from "../shared/lazy-runtime.js"; import { @@ -155,6 +156,8 @@ export type HookContext = { approvalReviewerDeviceId?: string; trace?: DiagnosticTraceContext; channelId?: string; + /** Host-derived message requester for sender-aware tool hooks. */ + requester?: PluginHookToolRequesterContext; /** Originating channel for approval delivery routing; mirrors exec approval turn-source fields. */ turnSourceChannel?: string; turnSourceTo?: string; @@ -1586,6 +1589,7 @@ export async function runBeforeToolCallHook(args: { ...(args.ctx?.trace && { trace: freezeDiagnosticTraceContext(args.ctx.trace) }), ...(args.toolCallId && { toolCallId: args.toolCallId }), ...(args.ctx?.channelId && { channelId: args.ctx.channelId }), + ...(args.ctx?.requester ? { requester: args.ctx.requester } : {}), }); const toolContext = buildToolContext(toolIdentity); const trustedPolicyResult = shouldRunTrustedPolicies diff --git a/src/agents/agent-tools.create-openclaw-coding-tools.test.ts b/src/agents/agent-tools.create-openclaw-coding-tools.test.ts index 70e0da949693..3ea41d127774 100644 --- a/src/agents/agent-tools.create-openclaw-coding-tools.test.ts +++ b/src/agents/agent-tools.create-openclaw-coding-tools.test.ts @@ -232,24 +232,40 @@ describe("createOpenClawCodingTools", () => { expect(names.has("tool_call")).toBe(false); }); - it("passes explicit hook channel ids to wrapped tool hooks", async () => { + it("passes explicit channel and requester facts to wrapped tool hooks", async () => { const beforeToolCall = vi.fn(); initializeGlobalHookRunner( createMockPluginRegistry([{ hookName: "before_tool_call", handler: beforeToolCall }]), ); const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-hook-channel-")); await fs.writeFile(path.join(tmpDir, "note.txt"), "hello"); + const memberRoleIds = ["maintainer-role"]; const tools = createOpenClawCodingTools({ workspaceDir: tmpDir, + messageChannel: "discord", + agentAccountId: "operations", currentChannelId: "telegram:-100123", hookChannelId: "-100123", + memberRoleIds, + senderId: "maintainer-user", + senderIsOwner: false, }); + memberRoleIds.push("late-role"); const readTool = requireTool(tools, "read"); await requireToolExecute(readTool)("tool-hook-channel", { path: "note.txt" }); expect(beforeToolCall).toHaveBeenCalledTimes(1); expect(beforeToolCall.mock.calls[0]?.[1]).toEqual( - expect.objectContaining({ channelId: "-100123" }), + expect.objectContaining({ + channelId: "-100123", + requester: { + channel: "discord", + accountId: "operations", + senderId: "maintainer-user", + senderIsOwner: false, + roleIds: ["maintainer-role"], + }, + }), ); }); diff --git a/src/agents/agent-tools.ts b/src/agents/agent-tools.ts index 76fc57c95630..c30d9b5b9c1b 100644 --- a/src/agents/agent-tools.ts +++ b/src/agents/agent-tools.ts @@ -17,7 +17,10 @@ import type { DiagnosticTraceContext } from "../infra/diagnostic-trace-context.j import { resolveEventSessionRoutingPolicy } from "../infra/event-session-routing.js"; import { applyExecPolicyLayer } from "../infra/exec-policy.js"; import { logWarn } from "../logger.js"; -import type { PluginHookChannelContext } from "../plugins/hook-types.js"; +import type { + PluginHookChannelContext, + PluginHookToolRequesterContext, +} from "../plugins/hook-types.js"; import { appendRuntimePluginToolGrant } from "../plugins/tool-grant-allowlist.js"; import { getPluginToolMeta } from "../plugins/tools.js"; import { GATEWAY_OWNER_ONLY_CORE_TOOLS } from "../security/dangerous-tools.js"; @@ -1170,6 +1173,14 @@ function createOpenClawCodingToolsInternal(options?: OpenClawCodingToolsOptions) options?.recordToolPrepStage?.("schema-normalization"); const turnSourceChannel = options?.messageChannel ?? options?.messageProvider; const turnSourceTo = options?.currentMessagingTarget ?? options?.currentChannelId; + const requester = { + ...(turnSourceChannel ? { channel: turnSourceChannel } : {}), + ...(options?.agentAccountId ? { accountId: options.agentAccountId } : {}), + ...(options?.senderId ? { senderId: options.senderId } : {}), + ...(options?.senderIsOwner !== undefined ? { senderIsOwner: options.senderIsOwner } : {}), + ...(options?.memberRoleIds?.length ? { roleIds: [...options.memberRoleIds] } : {}), + } satisfies PluginHookToolRequesterContext; + const hasRequester = Object.keys(requester).length > 0; const hookContext = { agentId, ...(options?.config ? { config: options.config } : {}), @@ -1185,6 +1196,7 @@ function createOpenClawCodingToolsInternal(options?: OpenClawCodingToolsOptions) runId: options?.runId, approvalReviewerDeviceId: options?.approvalReviewerDeviceId, channelId: options?.hookChannelId ?? options?.currentChannelId, + ...(hasRequester ? { requester } : {}), ...(turnSourceChannel ? { turnSourceChannel } : {}), ...(turnSourceTo ? { turnSourceTo } : {}), ...(options?.agentAccountId ? { turnSourceAccountId: options.agentAccountId } : {}), diff --git a/src/agents/harness/native-hook-relay.test.ts b/src/agents/harness/native-hook-relay.test.ts index c28d5347d6d9..d4f60936f602 100644 --- a/src/agents/harness/native-hook-relay.test.ts +++ b/src/agents/harness/native-hook-relay.test.ts @@ -1700,6 +1700,13 @@ describe("native hook relay registry", () => { sessionKey: "agent:main:session-1", runId: "run-1", channelId: "telegram", + requester: { + channel: "telegram", + accountId: "operations", + senderId: "maintainer-user", + senderIsOwner: false, + roleIds: ["maintainer-role"], + }, }); const response = await invokeNativeHookRelay({ @@ -1738,6 +1745,13 @@ describe("native hook relay registry", () => { sessionKey: "agent:main:session-1", runId: "run-1", channelId: "telegram", + requester: { + channel: "telegram", + accountId: "operations", + senderId: "maintainer-user", + senderIsOwner: false, + roleIds: ["maintainer-role"], + }, toolName: "exec", toolCallId: "native-call-1", }); diff --git a/src/agents/harness/native-hook-relay.ts b/src/agents/harness/native-hook-relay.ts index 2a2efb7a17b2..f94290c47af0 100644 --- a/src/agents/harness/native-hook-relay.ts +++ b/src/agents/harness/native-hook-relay.ts @@ -23,6 +23,7 @@ import { resolveOpenClawPackageRootSync } from "../../infra/openclaw-root.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { listAgentToolResultMiddlewares } from "../../plugins/agent-tool-result-middleware.js"; import { hasGlobalHooks } from "../../plugins/hook-runner-global.js"; +import type { PluginHookToolRequesterContext } from "../../plugins/hook-types.js"; import { PluginApprovalResolutions } from "../../plugins/types.js"; import { resolveOpenClawStateSqlitePath } from "../../state/openclaw-state-db.paths.js"; import { @@ -105,6 +106,7 @@ type NativeHookRelayRegistration = { config?: OpenClawConfig; runId: string; channelId?: string; + requester?: PluginHookToolRequesterContext; allowedEvents: readonly NativeHookRelayEvent[]; expiresAtMs: number; signal?: AbortSignal; @@ -138,6 +140,7 @@ type RegisterNativeHookRelayParams = { config?: OpenClawConfig; runId: string; channelId?: string; + requester?: PluginHookToolRequesterContext; allowedEvents?: readonly NativeHookRelayEvent[]; /** Whether this relay should run OpenClaw loop detection from native PreToolUse hooks. */ preToolUseLoopDetection?: boolean; @@ -445,6 +448,7 @@ export function registerNativeHookRelay( ...(params.config ? { config: params.config } : {}), runId: params.runId, ...(params.channelId ? { channelId: params.channelId } : {}), + ...(params.requester ? { requester: params.requester } : {}), allowedEvents, preToolUseLoopDetection: params.preToolUseLoopDetection !== false, expiresAtMs, @@ -1437,6 +1441,7 @@ async function runNativeHookRelayPreToolUse(params: { ...(params.registration.config ? { config: params.registration.config } : {}), runId: params.registration.runId, ...(params.registration.channelId ? { channelId: params.registration.channelId } : {}), + ...(params.registration.requester ? { requester: params.registration.requester } : {}), ...(params.invocation.cwd ? { cwd: params.invocation.cwd, workspaceDir: params.invocation.cwd } : {}), diff --git a/src/plugins/hook-types.ts b/src/plugins/hook-types.ts index 7336ad043f31..ec63b48d801f 100644 --- a/src/plugins/hook-types.ts +++ b/src/plugins/hook-types.ts @@ -628,6 +628,20 @@ export type PluginHookReplyPayloadSendingResult = { export type PluginHookToolKind = "code_mode_exec"; export type PluginHookToolInputKind = "javascript" | "typescript"; +/** Host-derived identity for the message requester that initiated a tool call. */ +export type PluginHookToolRequesterContext = { + /** Channel/plugin id, for example `discord` or `telegram`. */ + readonly channel?: string; + /** Channel account used by the agent when multiple accounts are configured. */ + readonly accountId?: string; + /** Channel-scoped sender id when the host received one. */ + readonly senderId?: string; + /** True only when the host resolved the sender as an owner. */ + readonly senderIsOwner?: boolean; + /** Provider-native role ids when the channel supplies them. */ + readonly roleIds?: readonly string[]; +}; + export type PluginHookToolContext = { agentId?: string; sessionKey?: string; @@ -642,6 +656,12 @@ export type PluginHookToolContext = { toolCallId?: string; getSessionExtension?: (namespace: string) => PluginJsonValue | undefined; channelId?: string; + /** + * Message requester for this turn. Absent for non-message runs and harnesses + * that cannot prove requester identity. Authorization hooks should fail + * closed when a required field is absent. + */ + requester?: PluginHookToolRequesterContext; }; export type PluginHookBeforeToolCallEvent = {