mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-20 16:11:42 +00:00
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:
committed by
GitHub
parent
6fb3aae404
commit
4c55a4fc28
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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 }
|
||||
: {}),
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user