diff --git a/README.md b/README.md index 99f1403..5577710 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`. 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,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` 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: @@ -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 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" +``` + +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..f1700d5 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 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 ad81163..4ba1d25 100644 --- a/src/agent-control-plugin.ts +++ b/src/agent-control-plugin.ts @@ -14,30 +14,69 @@ 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."; +const EXEC_TOOL_NAME = "exec"; +const EXEC_STEER_ASK_MODE = "always"; + +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 +87,47 @@ 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 buildNativeExecApprovalParams(): Record { + return { + ask: EXEC_STEER_ASK_MODE, + }; +} + function resolveSourceAgentId(agentId: string | undefined): string { const normalized = asString(agentId); return normalized ?? "default"; @@ -78,6 +158,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 +384,55 @@ 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); + 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}`, + ); + return { + requireApproval: { + title: APPROVAL_TITLE, + description, + 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`, + ); + }, + }, + }; + } + 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..d37f92d 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, @@ -176,6 +177,200 @@ 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: 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("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({ + 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({ + 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 +546,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 +706,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..65682dd 100644 --- a/types/openclaw-plugin-sdk-core.d.ts +++ b/types/openclaw-plugin-sdk-core.d.ts @@ -1,4 +1,28 @@ 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 = { + block?: boolean; + blockReason?: string; + requireApproval?: OpenClawApprovalRequest; + }; + export type OpenClawBeforeToolCallEvent = { toolName: string; params?: unknown; @@ -29,7 +53,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; }