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
This commit is contained in:
Peter Steinberger
2026-07-19 10:31:58 -07:00
committed by GitHub
parent 6fb3aae404
commit 4c55a4fc28
11 changed files with 228 additions and 3 deletions

View File

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

View File

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

View File

@@ -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<PluginHookToolContext["requester"]>;
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({

View File

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

View File

@@ -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" });

View File

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

View File

@@ -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"],
},
}),
);
});

View File

@@ -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 } : {}),

View File

@@ -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",
});

View File

@@ -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 }
: {}),

View File

@@ -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 = {