Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
</a>
</p>

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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
```

Expand Down
10 changes: 9 additions & 1 deletion openclaw.plugin.json
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -34,6 +34,10 @@
"logLevel": {
"type": "string",
"enum": ["warn", "info", "debug"]
},
"steerBehavior": {
"type": "string",
"enum": ["requireApproval", "block"]
}
}
},
Expand All @@ -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."
}
}
}
160 changes: 145 additions & 15 deletions src/agent-control-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(", ")}`;
Expand All @@ -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<string, string> {
return {
ask: EXEC_STEER_ASK_MODE,
};
}

function resolveSourceAgentId(agentId: string | undefined): string {
const normalized = asString(agentId);
return normalized ?? "default";
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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)}`,
);
Expand Down
2 changes: 2 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -12,6 +13,7 @@ export type AgentControlPluginConfig = {
userAgent?: string;
failClosed?: boolean;
logLevel?: LogLevel;
steerBehavior?: SteerBehavior;
};

export type AgentControlStep = {
Expand Down
Loading
Loading