From ea6a8e4c9b08d76e400ea9b801187b11a67836a4 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Mon, 30 Mar 2026 13:56:33 -0700 Subject: [PATCH 1/3] Handle steer results with approval requests --- README.md | 20 ++- openclaw.plugin.json | 10 +- src/agent-control-plugin.ts | 139 +++++++++++++-- src/types.ts | 2 + test/agent-control-plugin.test.ts | 267 +++++++++++++++++++++++++++- types/openclaw-plugin-sdk-core.d.ts | 16 +- 6 files changed, 433 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 99f1403..fad905d 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@

-This plugin integrates OpenClaw with [Agent Control](https://github.com/agentcontrol/agent-control), a security and policy layer for agent tool use. It registers OpenClaw tools with Agent Control and can block unsafe tool invocations before they execute. +This plugin integrates OpenClaw with [Agent Control](https://github.com/agentcontrol/agent-control), a security and policy layer for agent tool use. It registers OpenClaw tools with Agent Control and can block unsafe tool invocations or escalate them for operator approval before they execute. > [!WARNING] > Experimental plugin: this may break across OpenClaw updates. Use in non-production or pinned environments. @@ -36,7 +36,7 @@ This plugin integrates OpenClaw with [Agent Control](https://github.com/agentcon ## How it works -When the gateway starts, the plugin loads the OpenClaw tool catalog and syncs it to Agent Control. On every tool call, the plugin intercepts the invocation through a `before_tool_call` hook, builds an evaluation context (session, channel, provider, agent identity), and sends it to Agent Control for a policy decision. If the evaluation comes back safe the call proceeds normally. If it comes back denied the call is blocked and the user sees a rejection message. +When the gateway starts, the plugin loads the OpenClaw tool catalog and syncs it to Agent Control. On every tool call, the plugin intercepts the invocation through a `before_tool_call` hook, builds an evaluation context (session, channel, provider, agent identity), and sends it to Agent Control for a policy decision. If the evaluation comes back safe the call proceeds normally. If it comes back denied the call is blocked and the user sees a rejection message. If it comes back with a `steer` action, the plugin can either require operator approval or block immediately, depending on `steerBehavior`. The plugin handles multiple agents, tracks tool catalog changes between calls, and re-syncs automatically when the catalog drifts. @@ -70,6 +70,7 @@ openclaw config set plugins.entries.agent-control-openclaw-plugin.config.apiKey | `timeoutMs` | integer | SDK default | Client timeout in milliseconds. | | `failClosed` | boolean | `false` | Block tool calls when Agent Control is unreachable. See [Fail-open vs fail-closed](#fail-open-vs-fail-closed). | | `logLevel` | string | `warn` | Logging verbosity. See [Logging](#logging). | +| `steerBehavior` | string | `requireApproval` | How Agent Control `steer` results for tool calls are handled: `requireApproval` asks an operator to approve within 2 minutes, `block` rejects immediately. | | `userAgent` | string | `openclaw-agent-control-plugin/0.1` | Custom User-Agent header for requests to Agent Control. | All settings are configured through the OpenClaw CLI: @@ -99,6 +100,20 @@ Set `failClosed` to `true` if you need the guarantee that no tool call executes openclaw config set plugins.entries.agent-control-openclaw-plugin.config.failClosed true ``` +## Steering behavior + +By default, the plugin maps Agent Control `steer` results for tool calls to OpenClaw approval requests. OpenClaw will wait up to 2 minutes for an operator decision and deny the call on timeout or when approval is unavailable. + +```bash +openclaw config set plugins.entries.agent-control-openclaw-plugin.config.steerBehavior "requireApproval" +``` + +If you prefer to reject steered tool calls immediately, switch the behavior to `block`: + +```bash +openclaw config set plugins.entries.agent-control-openclaw-plugin.config.steerBehavior "block" +``` + ## Logging The plugin stays quiet by default and only emits warnings, errors, and tool block events. @@ -136,6 +151,7 @@ openclaw plugins disable agent-control-openclaw-plugin openclaw config unset plugins.entries.agent-control-openclaw-plugin.config.apiKey openclaw config unset plugins.entries.agent-control-openclaw-plugin.config.logLevel openclaw config unset plugins.entries.agent-control-openclaw-plugin.config.agentVersion +openclaw config unset plugins.entries.agent-control-openclaw-plugin.config.steerBehavior openclaw config unset plugins.entries.agent-control-openclaw-plugin.config.userAgent ``` diff --git a/openclaw.plugin.json b/openclaw.plugin.json index b36a66e..404ca48 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -1,7 +1,7 @@ { "id": "agent-control-openclaw-plugin", "name": "Agent Control", - "description": "Registers OpenClaw tools with Agent Control and blocks unsafe tool invocations.", + "description": "Registers OpenClaw tools with Agent Control and blocks or escalates unsafe tool invocations.", "configSchema": { "type": "object", "additionalProperties": false, @@ -34,6 +34,10 @@ "logLevel": { "type": "string", "enum": ["warn", "info", "debug"] + }, + "steerBehavior": { + "type": "string", + "enum": ["requireApproval", "block"] } } }, @@ -57,6 +61,10 @@ "logLevel": { "label": "Log Level", "help": "Controls plugin verbosity: warn logs only warnings, errors, and block events; info adds high-level lifecycle logs; debug adds verbose diagnostics." + }, + "steerBehavior": { + "label": "Steer Behavior", + "help": "Controls how Agent Control steer decisions are handled for tool calls. requireApproval asks an operator to approve the tool call; block rejects it immediately." } } } diff --git a/src/agent-control-plugin.ts b/src/agent-control-plugin.ts index ad81163..1bd48bb 100644 --- a/src/agent-control-plugin.ts +++ b/src/agent-control-plugin.ts @@ -14,30 +14,67 @@ import { trimToMax, USER_BLOCK_MESSAGE, } from "./shared.ts"; -import type { AgentControlPluginConfig, AgentState } from "./types.ts"; +import type { AgentControlPluginConfig, AgentState, SteerBehavior } from "./types.ts"; -function collectDenyControlNames(response: { - matches?: Array<{ action?: string; controlName?: string }> | null; - errors?: Array<{ action?: string; controlName?: string }> | null; -}): string[] { +const APPROVAL_TITLE = "Agent Control approval required"; +const APPROVAL_TIMEOUT_MS = 120_000; +const DEFAULT_STEER_GUIDANCE = "Tool call requires operator approval due to steering policy."; + +type PolicyMatch = { + action?: string | null; + controlName?: string | null; + steeringContext?: { + message?: string | null; + } | null; +}; + +type PolicyResponse = { + reason?: string | null; + matches?: PolicyMatch[] | null; + errors?: PolicyMatch[] | null; +}; + +function hasPolicyAction( + response: PolicyResponse, + action: "deny" | "steer", + includeErrors: boolean, +): boolean { + const entries = includeErrors + ? [...(response.matches ?? []), ...(response.errors ?? [])] + : [...(response.matches ?? [])]; + return entries.some((entry) => entry.action === action); +} + +function collectControlNames( + entries: PolicyMatch[], + action: "deny" | "steer", +): string[] { const names: string[] = []; - for (const match of [...(response.matches ?? []), ...(response.errors ?? [])]) { + for (const entry of entries) { if ( - match.action === "deny" && - typeof match.controlName === "string" && - match.controlName.trim() + entry.action === action && + typeof entry.controlName === "string" && + entry.controlName.trim() ) { - names.push(match.controlName.trim()); + names.push(entry.controlName.trim()); } } return [...new Set(names)]; } -function buildBlockReason(response: { - reason?: string | null; - matches?: Array<{ action?: string; controlName?: string }> | null; - errors?: Array<{ action?: string; controlName?: string }> | null; -}): string { +function collectDenyControlNames(response: PolicyResponse): string[] { + return collectControlNames([...(response.matches ?? []), ...(response.errors ?? [])], "deny"); +} + +function collectSteerMatches(response: PolicyResponse): PolicyMatch[] { + return (response.matches ?? []).filter((match) => match.action === "steer"); +} + +function collectSteerControlNames(response: PolicyResponse): string[] { + return collectControlNames(collectSteerMatches(response), "steer"); +} + +function buildBlockReason(response: PolicyResponse): string { const denyControls = collectDenyControlNames(response); if (denyControls.length > 0) { return `[agent-control] blocked by deny control(s): ${denyControls.join(", ")}`; @@ -48,6 +85,41 @@ function buildBlockReason(response: { return "[agent-control] blocked by policy evaluation"; } +function resolveSteerGuidance(response: PolicyResponse): string { + for (const match of collectSteerMatches(response)) { + const steeringMessage = asString(match.steeringContext?.message)?.trim(); + if (steeringMessage) { + return steeringMessage; + } + } + + const reason = asString(response.reason)?.trim(); + if (reason) { + return reason; + } + + return DEFAULT_STEER_GUIDANCE; +} + +function buildSteerReason(response: PolicyResponse): string { + const steerControls = collectSteerControlNames(response); + const guidance = resolveSteerGuidance(response); + if (steerControls.length > 0) { + return `[agent-control] blocked by steer control(s): ${steerControls.join(", ")}; guidance: ${guidance}`; + } + return `[agent-control] ${guidance}`; +} + +function buildApprovalDescription(toolName: string, response: PolicyResponse): string { + const steerControls = collectSteerControlNames(response); + const guidance = resolveSteerGuidance(response); + const controlSummary = + steerControls.length > 0 + ? `matched steering control(s): ${steerControls.join(", ")}` + : "matched a steering policy"; + return `Tool call "${toolName}" ${controlSummary}. Guidance: ${guidance}`; +} + function resolveSourceAgentId(agentId: string | undefined): string { const normalized = asString(agentId); return normalized ?? "default"; @@ -78,6 +150,7 @@ export default function register(api: OpenClawPluginApi) { const configuredAgentVersion = asString(cfg.agentVersion); const pluginVersion = asString(api.version); const clientTimeoutMs = asPositiveInt(cfg.timeoutMs); + const steerBehavior: SteerBehavior = cfg.steerBehavior === "block" ? "block" : "requireApproval"; const clientInitStartedAt = process.hrtime.bigint(); const client = new AgentControlClient(); @@ -303,6 +376,42 @@ export default function register(api: OpenClawPluginApi) { return; } + if (hasPolicyAction(evaluation, "deny", true)) { + logger.block( + `agent-control: blocked tool=${event.toolName} agent=${sourceAgentId} reason=${buildBlockReason(evaluation)}`, + ); + return { + block: true, + blockReason: USER_BLOCK_MESSAGE, + }; + } + + if (hasPolicyAction(evaluation, "steer", false)) { + if (steerBehavior === "block") { + logger.block( + `agent-control: blocked tool=${event.toolName} agent=${sourceAgentId} reason=${buildSteerReason(evaluation)} policy_action=steer`, + ); + return { + block: true, + blockReason: USER_BLOCK_MESSAGE, + }; + } + + const description = buildApprovalDescription(event.toolName, evaluation); + logger.warn( + `agent-control: approval_required tool=${event.toolName} agent=${sourceAgentId} reason=${description}`, + ); + return { + requireApproval: { + title: APPROVAL_TITLE, + description, + severity: "warning", + timeoutMs: APPROVAL_TIMEOUT_MS, + timeoutBehavior: "deny", + }, + }; + } + logger.block( `agent-control: blocked tool=${event.toolName} agent=${sourceAgentId} reason=${buildBlockReason(evaluation)}`, ); diff --git a/src/types.ts b/src/types.ts index 2da17e4..c6d4fe5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,6 +1,7 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; export type LogLevel = "warn" | "info" | "debug"; +export type SteerBehavior = "requireApproval" | "block"; export type AgentControlPluginConfig = { enabled?: boolean; @@ -12,6 +13,7 @@ export type AgentControlPluginConfig = { userAgent?: string; failClosed?: boolean; logLevel?: LogLevel; + steerBehavior?: SteerBehavior; }; export type AgentControlStep = { diff --git a/test/agent-control-plugin.test.ts b/test/agent-control-plugin.test.ts index 6229bd4..b0ef57f 100644 --- a/test/agent-control-plugin.test.ts +++ b/test/agent-control-plugin.test.ts @@ -176,6 +176,120 @@ describe("agent-control plugin logging and blocking", () => { expect(clientMocks.evaluationEvaluate).toHaveBeenCalledOnce(); }); + it("requires approval when a steer control matches", async () => { + // Given the default steer behavior and an unsafe steer evaluation response + const api = createMockApi({ + serverUrl: "http://localhost:8000", + }); + + clientMocks.evaluationEvaluate.mockResolvedValueOnce({ + isSafe: false, + reason: "top-level reason", + matches: [ + { + action: "steer", + controlName: "shell-review", + steeringContext: { + message: "Review the command before running it.", + }, + }, + ], + errors: null, + }); + + // When the plugin evaluates the tool call + register(api.api); + const result = await runBeforeToolCall(api); + + // Then the tool call requires operator approval with the steering guidance + expect(result).toEqual({ + requireApproval: { + title: "Agent Control approval required", + description: + 'Tool call "shell" matched steering control(s): shell-review. Guidance: Review the command before running it.', + severity: "warning", + timeoutMs: 120_000, + timeoutBehavior: "deny", + }, + }); + expect(api.warn).toHaveBeenCalledWith( + expect.stringContaining("approval_required tool=shell agent=default"), + ); + }); + + it("blocks steer results when steerBehavior is block", async () => { + // Given steer handling is configured to block immediately + const api = createMockApi({ + serverUrl: "http://localhost:8000", + steerBehavior: "block", + }); + + clientMocks.evaluationEvaluate.mockResolvedValueOnce({ + isSafe: false, + matches: [ + { + action: "steer", + controlName: "shell-review", + steeringContext: { + message: "Review the command before running it.", + }, + }, + ], + errors: null, + }); + + // When the plugin evaluates the tool call + register(api.api); + const result = await runBeforeToolCall(api); + + // Then the tool call is blocked instead of requesting approval + expect(result).toEqual({ + block: true, + blockReason: USER_BLOCK_MESSAGE, + }); + expect(api.warn).toHaveBeenCalledWith( + expect.stringContaining("policy_action=steer"), + ); + }); + + it("blocks deny matches even when steer matches are also present", async () => { + // Given an unsafe evaluation response containing both deny and steer results + const api = createMockApi({ + serverUrl: "http://localhost:8000", + }); + + clientMocks.evaluationEvaluate.mockResolvedValueOnce({ + isSafe: false, + matches: [ + { + action: "steer", + controlName: "shell-review", + steeringContext: { + message: "Review the command before running it.", + }, + }, + { + action: "deny", + controlName: "shell-deny", + }, + ], + errors: null, + }); + + // When the plugin evaluates the tool call + register(api.api); + const result = await runBeforeToolCall(api); + + // Then the deny result wins and the tool call is blocked + expect(result).toEqual({ + block: true, + blockReason: USER_BLOCK_MESSAGE, + }); + expect(api.warn).toHaveBeenCalledWith( + expect.stringContaining("blocked by deny control(s): shell-deny"), + ); + }); + it("emits lifecycle logs without debug traces in info mode", async () => { // Given info-level logging for a plugin that can warm up and evaluate tools const api = createMockApi({ @@ -351,6 +465,151 @@ describe("agent-control plugin logging and blocking", () => { expect(message).not.toContain("alpha, alpha"); }); + it("deduplicates steer controls in the approval description", async () => { + // Given an unsafe steer response with duplicate control names + const api = createMockApi({ + serverUrl: "http://localhost:8000", + }); + + clientMocks.evaluationEvaluate.mockResolvedValueOnce({ + isSafe: false, + matches: [ + { + action: "steer", + controlName: "alpha", + steeringContext: { + message: "Review the command before running it.", + }, + }, + { + action: "steer", + controlName: "alpha", + steeringContext: { + message: "Review the command before running it.", + }, + }, + { + action: "steer", + controlName: "beta", + steeringContext: { + message: "Review the command before running it.", + }, + }, + ], + errors: null, + }); + + // When the plugin evaluates the tool call + register(api.api); + const result = await runBeforeToolCall(api); + + // Then the approval description lists each steer control name only once + expect(result).toEqual({ + requireApproval: expect.objectContaining({ + description: + 'Tool call "shell" matched steering control(s): alpha, beta. Guidance: Review the command before running it.', + }), + }); + }); + + it("prefers the evaluation reason when steer guidance is missing", async () => { + // Given a steer response without per-control steering guidance + const api = createMockApi({ + serverUrl: "http://localhost:8000", + }); + + clientMocks.evaluationEvaluate.mockResolvedValueOnce({ + isSafe: false, + reason: "Operator review is required for this command.", + matches: [ + { + action: "steer", + controlName: "shell-review", + steeringContext: null, + }, + ], + errors: null, + }); + + // When the plugin evaluates the tool call + register(api.api); + const result = await runBeforeToolCall(api); + + // Then the approval description falls back to the top-level evaluation reason + expect(result).toEqual({ + requireApproval: expect.objectContaining({ + description: + 'Tool call "shell" matched steering control(s): shell-review. Guidance: Operator review is required for this command.', + }), + }); + }); + + it("falls back to generic guidance when steer details are missing", async () => { + // Given a steer response without control guidance or a top-level reason + const api = createMockApi({ + serverUrl: "http://localhost:8000", + }); + + clientMocks.evaluationEvaluate.mockResolvedValueOnce({ + isSafe: false, + reason: "", + matches: [ + { + action: "steer", + controlName: "shell-review", + steeringContext: { + message: "", + }, + }, + ], + errors: null, + }); + + // When the plugin evaluates the tool call + register(api.api); + const result = await runBeforeToolCall(api); + + // Then the generic steering guidance is used in the approval description + expect(result).toEqual({ + requireApproval: expect.objectContaining({ + description: + 'Tool call "shell" matched steering control(s): shell-review. Guidance: Tool call requires operator approval due to steering policy.', + }), + }); + }); + + it("does not treat evaluator errors as steer triggers", async () => { + // Given an unsafe evaluation response with a steer action only in errors + const api = createMockApi({ + serverUrl: "http://localhost:8000", + }); + + clientMocks.evaluationEvaluate.mockResolvedValueOnce({ + isSafe: false, + reason: "", + matches: null, + errors: [ + { + action: "steer", + controlName: "shell-review", + }, + ], + }); + + // When the plugin evaluates the tool call + register(api.api); + const result = await runBeforeToolCall(api); + + // Then the plugin falls back to a generic block instead of requesting approval + expect(result).toEqual({ + block: true, + blockReason: USER_BLOCK_MESSAGE, + }); + expect(api.warn).toHaveBeenCalledWith( + expect.stringContaining("reason=[agent-control] blocked by policy evaluation"), + ); + }); + it("logs the generic block reason when no policy details are returned", async () => { // Given an unsafe evaluation response with no policy reason or deny controls const api = createMockApi({ @@ -366,9 +625,13 @@ describe("agent-control plugin logging and blocking", () => { // When the tool call is evaluated and blocked register(api.api); - await runBeforeToolCall(api); + const result = await runBeforeToolCall(api); - // Then the generic policy block reason is logged + // Then the tool call is blocked with the generic policy block reason logged + expect(result).toEqual({ + block: true, + blockReason: USER_BLOCK_MESSAGE, + }); expect(api.warn).toHaveBeenCalledWith( expect.stringContaining("reason=[agent-control] blocked by policy evaluation"), ); diff --git a/types/openclaw-plugin-sdk-core.d.ts b/types/openclaw-plugin-sdk-core.d.ts index 7fd01fc..8f65e4b 100644 --- a/types/openclaw-plugin-sdk-core.d.ts +++ b/types/openclaw-plugin-sdk-core.d.ts @@ -1,4 +1,18 @@ declare module "openclaw/plugin-sdk/core" { + export type OpenClawApprovalRequest = { + title: string; + description: string; + severity: "warning"; + timeoutMs: number; + timeoutBehavior: "deny"; + }; + + export type OpenClawBeforeToolCallResult = { + block?: boolean; + blockReason?: string; + requireApproval?: OpenClawApprovalRequest; + }; + export type OpenClawBeforeToolCallEvent = { toolName: string; params?: unknown; @@ -29,7 +43,7 @@ declare module "openclaw/plugin-sdk/core" { handler: ( event: OpenClawBeforeToolCallEvent, ctx: OpenClawBeforeToolCallContext, - ) => unknown, + ) => OpenClawBeforeToolCallResult | void | Promise, ): void; on(event: string, handler: (...args: any[]) => unknown): void; } From 5264743cf1b86dd3abe43e5dbdadea3fa1e0f471 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Mon, 30 Mar 2026 15:07:35 -0700 Subject: [PATCH 2/3] Log plugin approval resolutions --- src/agent-control-plugin.ts | 5 ++++ test/agent-control-plugin.test.ts | 41 +++++++++++++++++++++++++++-- types/openclaw-plugin-sdk-core.d.ts | 10 +++++++ 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/src/agent-control-plugin.ts b/src/agent-control-plugin.ts index 1bd48bb..597d5ed 100644 --- a/src/agent-control-plugin.ts +++ b/src/agent-control-plugin.ts @@ -408,6 +408,11 @@ export default function register(api: OpenClawPluginApi) { severity: "warning", timeoutMs: APPROVAL_TIMEOUT_MS, timeoutBehavior: "deny", + onResolution(resolution) { + logger.warn( + `agent-control: approval_resolved tool=${event.toolName} agent=${sourceAgentId} decision=${resolution} policy_action=steer`, + ); + }, }, }; } diff --git a/test/agent-control-plugin.test.ts b/test/agent-control-plugin.test.ts index b0ef57f..2bf9199 100644 --- a/test/agent-control-plugin.test.ts +++ b/test/agent-control-plugin.test.ts @@ -1,4 +1,5 @@ import type { + OpenClawApprovalRequest, OpenClawBeforeToolCallContext, OpenClawBeforeToolCallEvent, OpenClawPluginApi, @@ -203,20 +204,56 @@ describe("agent-control plugin logging and blocking", () => { // Then the tool call requires operator approval with the steering guidance expect(result).toEqual({ - requireApproval: { + requireApproval: expect.objectContaining({ title: "Agent Control approval required", description: 'Tool call "shell" matched steering control(s): shell-review. Guidance: Review the command before running it.', severity: "warning", timeoutMs: 120_000, timeoutBehavior: "deny", - }, + onResolution: expect.any(Function), + }), }); expect(api.warn).toHaveBeenCalledWith( expect.stringContaining("approval_required tool=shell agent=default"), ); }); + it("logs the approval resolution when OpenClaw resolves a steer approval", async () => { + // Given a steer evaluation response that returns a plugin approval request + const api = createMockApi({ + serverUrl: "http://localhost:8000", + }); + + clientMocks.evaluationEvaluate.mockResolvedValueOnce({ + isSafe: false, + matches: [ + { + action: "steer", + controlName: "shell-review", + steeringContext: { + message: "Review the command before running it.", + }, + }, + ], + errors: null, + }); + + // When the plugin evaluates the tool call and OpenClaw reports an allow-once resolution + register(api.api); + const result = (await runBeforeToolCall(api)) as + | { requireApproval?: OpenClawApprovalRequest } + | undefined; + await result?.requireApproval?.onResolution?.("allow-once"); + + // Then the plugin logs the final approval resolution for the steered tool call + expect(api.warn).toHaveBeenCalledWith( + expect.stringContaining( + "approval_resolved tool=shell agent=default decision=allow-once policy_action=steer", + ), + ); + }); + it("blocks steer results when steerBehavior is block", async () => { // Given steer handling is configured to block immediately const api = createMockApi({ diff --git a/types/openclaw-plugin-sdk-core.d.ts b/types/openclaw-plugin-sdk-core.d.ts index 8f65e4b..65682dd 100644 --- a/types/openclaw-plugin-sdk-core.d.ts +++ b/types/openclaw-plugin-sdk-core.d.ts @@ -1,10 +1,20 @@ declare module "openclaw/plugin-sdk/core" { + export type OpenClawApprovalResolution = + | "allow-once" + | "allow-always" + | "deny" + | "timeout" + | "cancelled"; + export type OpenClawApprovalRequest = { title: string; description: string; severity: "warning"; timeoutMs: number; timeoutBehavior: "deny"; + onResolution?: ( + resolution: OpenClawApprovalResolution, + ) => void | Promise; }; export type OpenClawBeforeToolCallResult = { From aab0f9f4a0b6389190886135bac4e7b0bf9f3c1a Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Mon, 30 Mar 2026 15:25:10 -0700 Subject: [PATCH 3/3] Route exec steer to native approval --- README.md | 6 ++--- openclaw.plugin.json | 2 +- src/agent-control-plugin.ts | 16 +++++++++++ test/agent-control-plugin.test.ts | 44 +++++++++++++++++++++++++++++++ 4 files changed, 64 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index fad905d..5577710 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ This plugin integrates OpenClaw with [Agent Control](https://github.com/agentcon ## How it works -When the gateway starts, the plugin loads the OpenClaw tool catalog and syncs it to Agent Control. On every tool call, the plugin intercepts the invocation through a `before_tool_call` hook, builds an evaluation context (session, channel, provider, agent identity), and sends it to Agent Control for a policy decision. If the evaluation comes back safe the call proceeds normally. If it comes back denied the call is blocked and the user sees a rejection message. If it comes back with a `steer` action, the plugin can either require operator approval or block immediately, depending on `steerBehavior`. +When the gateway starts, the plugin loads the OpenClaw tool catalog and syncs it to Agent Control. On every tool call, the plugin intercepts the invocation through a `before_tool_call` hook, builds an evaluation context (session, channel, provider, agent identity), and sends it to Agent Control for a policy decision. If the evaluation comes back safe the call proceeds normally. If it comes back denied the call is blocked and the user sees a rejection message. If it comes back with a `steer` action, the plugin can either require operator approval or block immediately, depending on `steerBehavior`. When `steerBehavior=requireApproval`, the `exec` tool is routed into OpenClaw's native exec approval flow by forcing `ask=always`, while other tools use plugin approval requests. The plugin handles multiple agents, tracks tool catalog changes between calls, and re-syncs automatically when the catalog drifts. @@ -70,7 +70,7 @@ openclaw config set plugins.entries.agent-control-openclaw-plugin.config.apiKey | `timeoutMs` | integer | SDK default | Client timeout in milliseconds. | | `failClosed` | boolean | `false` | Block tool calls when Agent Control is unreachable. See [Fail-open vs fail-closed](#fail-open-vs-fail-closed). | | `logLevel` | string | `warn` | Logging verbosity. See [Logging](#logging). | -| `steerBehavior` | string | `requireApproval` | How Agent Control `steer` results for tool calls are handled: `requireApproval` asks an operator to approve within 2 minutes, `block` rejects immediately. | +| `steerBehavior` | string | `requireApproval` | How Agent Control `steer` results for tool calls are handled: `requireApproval` uses OpenClaw approvals, with native exec approval for `exec` and plugin approval requests for other tools; `block` rejects immediately. | | `userAgent` | string | `openclaw-agent-control-plugin/0.1` | Custom User-Agent header for requests to Agent Control. | All settings are configured through the OpenClaw CLI: @@ -102,7 +102,7 @@ openclaw config set plugins.entries.agent-control-openclaw-plugin.config.failClo ## Steering behavior -By default, the plugin maps Agent Control `steer` results for tool calls to OpenClaw approval requests. OpenClaw will wait up to 2 minutes for an operator decision and deny the call on timeout or when approval is unavailable. +By default, the plugin maps Agent Control `steer` results for tool calls to OpenClaw approvals. For `exec`, the plugin forces `ask=always` so the command goes through OpenClaw's native exec approval flow in the originating session. For other tools, the plugin uses OpenClaw plugin approval requests and waits up to 2 minutes for an operator decision before denying on timeout or when approval is unavailable. ```bash openclaw config set plugins.entries.agent-control-openclaw-plugin.config.steerBehavior "requireApproval" diff --git a/openclaw.plugin.json b/openclaw.plugin.json index 404ca48..f1700d5 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -64,7 +64,7 @@ }, "steerBehavior": { "label": "Steer Behavior", - "help": "Controls how Agent Control steer decisions are handled for tool calls. requireApproval asks an operator to approve the tool call; block rejects it immediately." + "help": "Controls how Agent Control steer decisions are handled for tool calls. requireApproval uses OpenClaw approvals, routing exec through native exec approval and other tools through plugin approvals; block rejects immediately." } } } diff --git a/src/agent-control-plugin.ts b/src/agent-control-plugin.ts index 597d5ed..4ba1d25 100644 --- a/src/agent-control-plugin.ts +++ b/src/agent-control-plugin.ts @@ -19,6 +19,8 @@ import type { AgentControlPluginConfig, AgentState, SteerBehavior } from "./type const APPROVAL_TITLE = "Agent Control approval required"; const APPROVAL_TIMEOUT_MS = 120_000; const DEFAULT_STEER_GUIDANCE = "Tool call requires operator approval due to steering policy."; +const EXEC_TOOL_NAME = "exec"; +const EXEC_STEER_ASK_MODE = "always"; type PolicyMatch = { action?: string | null; @@ -120,6 +122,12 @@ function buildApprovalDescription(toolName: string, response: PolicyResponse): s return `Tool call "${toolName}" ${controlSummary}. Guidance: ${guidance}`; } +function buildNativeExecApprovalParams(): Record { + return { + ask: EXEC_STEER_ASK_MODE, + }; +} + function resolveSourceAgentId(agentId: string | undefined): string { const normalized = asString(agentId); return normalized ?? "default"; @@ -398,6 +406,14 @@ export default function register(api: OpenClawPluginApi) { } const description = buildApprovalDescription(event.toolName, evaluation); + if (event.toolName === EXEC_TOOL_NAME) { + logger.warn( + `agent-control: exec_native_approval_required tool=${event.toolName} agent=${sourceAgentId} reason=${description} ask=${EXEC_STEER_ASK_MODE} policy_action=steer`, + ); + return { + params: buildNativeExecApprovalParams(), + }; + } logger.warn( `agent-control: approval_required tool=${event.toolName} agent=${sourceAgentId} reason=${description}`, ); diff --git a/test/agent-control-plugin.test.ts b/test/agent-control-plugin.test.ts index 2bf9199..d37f92d 100644 --- a/test/agent-control-plugin.test.ts +++ b/test/agent-control-plugin.test.ts @@ -219,6 +219,50 @@ describe("agent-control plugin logging and blocking", () => { ); }); + it("forces native exec approval when a steer control matches exec", async () => { + // Given the default steer behavior and a steered exec tool call + const api = createMockApi({ + serverUrl: "http://localhost:8000", + }); + + clientMocks.evaluationEvaluate.mockResolvedValueOnce({ + isSafe: false, + matches: [ + { + action: "steer", + controlName: "exec-review", + steeringContext: { + message: "Ask before running this command.", + }, + }, + ], + errors: null, + }); + + // When the plugin evaluates the exec tool call + register(api.api); + const result = await runBeforeToolCall( + api, + { + toolName: "exec", + params: { command: "ls -la /" }, + }, + {}, + ); + + // Then the plugin forces OpenClaw's native exec approval flow through ask=always + expect(result).toEqual({ + params: { + ask: "always", + }, + }); + expect(api.warn).toHaveBeenCalledWith( + expect.stringContaining( + "exec_native_approval_required tool=exec agent=default", + ), + ); + }); + it("logs the approval resolution when OpenClaw resolves a steer approval", async () => { // Given a steer evaluation response that returns a plugin approval request const api = createMockApi({