Skip to content
Merged
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
155 changes: 109 additions & 46 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@
<source media="(prefers-color-scheme: light)" srcset="assets/cat-plus-lobster-light.svg">
<img src="https://cdn.jsdelivr.net/npm/agent-control-openclaw-plugin/assets/cat-plus-lobster-light.svg" alt="Agent Control cat plus OpenClaw lobster" width="430">
</picture>
</p>

<br>
<a href="https://www.npmjs.com/package/agent-control-openclaw-plugin">
<img src="https://img.shields.io/npm/v/agent-control-openclaw-plugin?logo=npm" alt="npm version">
</a>
Expand All @@ -20,91 +19,155 @@
<a href="https://app.codecov.io/gh/agentcontrol/openclaw-plugin">
<img src="https://codecov.io/gh/agentcontrol/openclaw-plugin/graph/badge.svg?branch=main" alt="Codecov">
</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.

## Why use Agent Control with OpenClaw?
> [!WARNING]
> Experimental plugin: this may break across OpenClaw updates. Use in non-production or pinned environments.

## Why use this?

- Enforce policy before tool execution, so unsafe or disallowed actions can be blocked before they run.
- Carry session and channel context into evaluations, which helps policies reason about where a request came from and how the agent is being used.
- Enforce policy before tool execution, so unsafe or disallowed actions never run.
- Carry session and channel context into evaluations, so policies can reason about where a request came from and how the agent is being used.

> [!WARNING]
> Experimental plugin: this may break across OpenClaw updates. Use in non-production or pinned environments.
## 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.

The plugin handles multiple agents, tracks tool catalog changes between calls, and re-syncs automatically when the catalog drifts.

## Install from npm
## Quick start

Install the published plugin directly into OpenClaw:
Install and configure with the minimum required settings:

```bash
openclaw plugins install agent-control-openclaw-plugin

openclaw config set plugins.entries.agent-control-openclaw-plugin.enabled true
openclaw config set plugins.entries.agent-control-openclaw-plugin.config.serverUrl "http://localhost:8000"
```

Then restart the gateway.
Restart the gateway. The plugin is now active with fail-open defaults and warn-level logging.

## Local dev install
For authenticated setups, also set:

1. Clone this repo anywhere on disk.
2. Install plugin deps in this repo:
```bash
openclaw config set plugins.entries.agent-control-openclaw-plugin.config.apiKey "ac_your_api_key"
```

## Configuration

| Setting | Type | Default | Description |
|---------|------|---------|-------------|
| `serverUrl` | string | — | Base URL for the Agent Control server. **Required.** |
| `apiKey` | string | — | API key for authenticating with Agent Control. |
| `agentName` | string | `openclaw-agent` | Base name used when registering agents with Agent Control. |
| `agentVersion` | string | — | Version string sent to Agent Control during agent sync. |
| `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). |
| `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:

```bash
npm install
npm run lint
npm run typecheck
npm test
npm run coverage
openclaw config set plugins.entries.agent-control-openclaw-plugin.config.<key> <value>
```

Coverage reports are written to `coverage/`, including `coverage/lcov.info` for Codecov-compatible uploads. The GitHub Actions workflow will upload that report to Codecov automatically when a `CODECOV_TOKEN` secret is configured for the repository.
### Environment variables

`serverUrl` and `apiKey` can also be set through environment variables. This is useful in container or CI environments where you do not want secrets in the OpenClaw config file.

| Variable | Equivalent config |
|----------|-------------------|
| `AGENT_CONTROL_SERVER_URL` | `serverUrl` |
| `AGENT_CONTROL_API_KEY` | `apiKey` |

Config values take precedence over environment variables when both are set.

3. Link it into your OpenClaw config from your OpenClaw checkout:
## Fail-open vs fail-closed

By default, the plugin is **fail-open**: if Agent Control is unreachable or the evaluation request fails, tool calls are allowed through. This avoids breaking your gateway when Agent Control has a transient outage.

Set `failClosed` to `true` if you need the guarantee that no tool call executes without a policy decision. In fail-closed mode, a sync failure or evaluation error will block the tool call.

```bash
openclaw plugins install -l /absolute/path/to/openclaw-plugin
openclaw config set plugins.entries.agent-control-openclaw-plugin.config.failClosed true
```

## Logging

The plugin stays quiet by default and only emits warnings, errors, and tool block events.

| Level | What it logs |
|-------|-------------|
| `warn` | Warnings, errors, and block events. This is the default. |
| `info` | Adds lifecycle events: client init, gateway warmup, agent syncs. |
| `debug` | Adds verbose diagnostics: phase timings, context building, evaluation details. |

```bash
openclaw config set plugins.entries.agent-control-openclaw-plugin.config.logLevel "debug"
```

4. Restart gateway.
## OpenClaw CLI reference

## OpenClaw commands
### Inspect plugin state

```bash
# Inspect plugin state
openclaw plugins list
openclaw plugins info agent-control-openclaw-plugin
openclaw plugins doctor
```

### Enable or disable

# Enable / disable
```bash
openclaw plugins enable agent-control-openclaw-plugin
openclaw plugins disable agent-control-openclaw-plugin
```

# Configure plugin entry + settings
openclaw config set plugins.entries.agent-control-openclaw-plugin.enabled true --strict-json
openclaw config set plugins.entries.agent-control-openclaw-plugin.config.serverUrl "http://localhost:8000"
openclaw config set plugins.entries.agent-control-openclaw-plugin.config.apiKey "ac_your_api_key"
openclaw config set plugins.entries.agent-control-openclaw-plugin.config.agentName "openclaw-agent"
openclaw config set plugins.entries.agent-control-openclaw-plugin.config.timeoutMs 15000 --strict-json
openclaw config set plugins.entries.agent-control-openclaw-plugin.config.failClosed false --strict-json

# Optional settings
openclaw config set plugins.entries.agent-control-openclaw-plugin.config.logLevel "info"
openclaw config set plugins.entries.agent-control-openclaw-plugin.config.logLevel "debug"
openclaw config set plugins.entries.agent-control-openclaw-plugin.config.agentId "00000000-0000-4000-8000-000000000000"
openclaw config set plugins.entries.agent-control-openclaw-plugin.config.agentVersion "2026.3.3"
openclaw config set plugins.entries.agent-control-openclaw-plugin.config.userAgent "agent-control-plugin/0.1"
### Remove optional config keys

# Remove optional keys
```bash
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.agentId
openclaw config unset plugins.entries.agent-control-openclaw-plugin.config.agentVersion
openclaw config unset plugins.entries.agent-control-openclaw-plugin.config.userAgent
```

# Uninstall plugin link/install record from OpenClaw config
### Uninstall

```bash
openclaw plugins uninstall agent-control-openclaw-plugin --force
```

By default the plugin stays quiet and only emits warnings, errors, and tool block events.
## Local development

1. Clone this repo anywhere on disk.
2. Install dependencies and run the verification stack:

```bash
npm install
npm run lint
npm run typecheck
npm test
```

3. Link the plugin into your OpenClaw checkout:

```bash
openclaw plugins install -l /absolute/path/to/openclaw-plugin
```

4. Restart the gateway.

`npm run coverage` generates a report under `coverage/` including `coverage/lcov.info` for Codecov uploads.

## Contributing

See [AGENTS.md](AGENTS.md) for project conventions, testing patterns, and the verification checklist.

Set `config.logLevel` to:
## License

- `info` for one-line lifecycle logs such as client init, warmup, and agent syncs
- `debug` for verbose startup, sync, and evaluation diagnostics
[Apache 2.0](LICENSE)
7 changes: 0 additions & 7 deletions openclaw.plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,6 @@
"agentName": {
"type": "string"
},
"agentId": {
"type": "string"
},
"agentVersion": {
"type": "string"
},
Expand Down Expand Up @@ -53,10 +50,6 @@
"label": "Agent Name Prefix",
"help": "Base name used for OpenClaw agents when registering with Agent Control."
},
"agentId": {
"label": "Agent UUID",
"help": "Optional fixed Agent Control UUID for single-agent deployments."
},
"failClosed": {
"label": "Fail Closed",
"help": "If true, block tool invocations when Agent Control is unavailable."
Expand Down
13 changes: 1 addition & 12 deletions src/agent-control-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import {
formatToolArgsForLog,
hashSteps,
isRecord,
isUuid,
secondsSince,
trimToMax,
USER_BLOCK_MESSAGE,
Expand Down Expand Up @@ -74,12 +73,6 @@ export default function register(api: OpenClawPluginApi) {
return;
}

const configuredAgentId = asString(cfg.agentId);
if (configuredAgentId && !isUuid(configuredAgentId)) {
logger.warn(`agent-control: configured agentId is not a UUID: ${configuredAgentId}`);
}
const hasConfiguredAgentId = configuredAgentId ? isUuid(configuredAgentId) : false;

const failClosed = cfg.failClosed === true;
const baseAgentName = asString(cfg.agentName) ?? "openclaw-agent";
const configuredAgentVersion = asString(cfg.agentVersion);
Expand Down Expand Up @@ -109,9 +102,7 @@ export default function register(api: OpenClawPluginApi) {
return existing;
}

const agentName = hasConfiguredAgentId
? trimToMax(baseAgentName, 255)
: trimToMax(`${baseAgentName}:${sourceAgentId}`, 255);
const agentName = trimToMax(`${baseAgentName}:${sourceAgentId}`, 255);

const created: AgentState = {
sourceAgentId,
Expand Down Expand Up @@ -176,7 +167,6 @@ export default function register(api: OpenClawPluginApi) {
agentMetadata: {
source: "openclaw",
openclawAgentId: state.sourceAgentId,
...(configuredAgentId ? { openclawConfiguredAgentId: configuredAgentId } : {}),
pluginId: api.id,
},
},
Expand Down Expand Up @@ -282,7 +272,6 @@ export default function register(api: OpenClawPluginApi) {
},
pluginVersion,
failClosed,
configuredAgentId,
configuredAgentVersion,
});
logger.debug(
Expand Down
2 changes: 0 additions & 2 deletions src/session-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,6 @@ export async function buildEvaluationContext(params: {
};
pluginVersion?: string;
failClosed: boolean;
configuredAgentId?: string;
configuredAgentVersion?: string;
}): Promise<Record<string, unknown>> {
const channelFromSessionKey = deriveChannelContext(params.ctx.sessionKey);
Expand Down Expand Up @@ -122,7 +121,6 @@ export async function buildEvaluationContext(params: {
},
policy: {
failClosed: params.failClosed,
configuredAgentId: params.configuredAgentId ?? null,
configuredAgentVersion: params.configuredAgentVersion ?? null,
},
sync: {
Expand Down
5 changes: 0 additions & 5 deletions src/shared.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { createHash } from "node:crypto";
import type { AgentControlStep } from "./types.ts";

export const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
export const USER_BLOCK_MESSAGE =
"This action is blocked by a security policy set by your operator. Do not attempt to circumvent, disable, or work around this control. Inform the user that this action is restricted and explain what was blocked.";
export const BOOT_WARMUP_AGENT_ID = "main";
Expand Down Expand Up @@ -58,10 +57,6 @@ export function sanitizeToolCatalogConfig(config: Record<string, unknown>): Reco
};
}

export function isUuid(value: string): boolean {
return UUID_RE.test(value);
}

export function trimToMax(value: string, maxLen: number): string {
return value.length <= maxLen ? value : value.slice(0, maxLen);
}
Expand Down
1 change: 0 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ export type AgentControlPluginConfig = {
serverUrl?: string;
apiKey?: string;
agentName?: string;
agentId?: string;
agentVersion?: string;
timeoutMs?: number;
userAgent?: string;
Expand Down
47 changes: 2 additions & 45 deletions test/agent-control-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,6 @@ vi.mock("../src/session-context.ts", () => ({

import register from "../src/agent-control-plugin.ts";

const VALID_AGENT_ID = "00000000-0000-4000-8000-000000000000";

type MockApi = {
api: OpenClawPluginApi;
handlers: Map<string, (...args: any[]) => unknown>;
Expand Down Expand Up @@ -151,22 +149,6 @@ describe("agent-control plugin logging and blocking", () => {
);
});

it("warns when the configured agent ID is not a UUID", () => {
// Given plugin configuration with an invalid configured agent ID
const api = createMockApi({
serverUrl: "http://localhost:8000",
agentId: "not-a-uuid",
});

// When the plugin is registered
register(api.api);

// Then a UUID validation warning is emitted
expect(api.warn).toHaveBeenCalledWith(
"agent-control: configured agentId is not a UUID: not-a-uuid",
);
});

it("only logs the block event in warn mode for unsafe evaluations", async () => {
// Given warn-level logging and an unsafe policy evaluation response
const api = createMockApi({
Expand Down Expand Up @@ -261,33 +243,8 @@ describe("agent-control plugin logging and blocking", () => {
);
});

it("uses the base agent name when a fixed configured agent ID is present", async () => {
// Given a fixed configured agent ID and a base agent name
const api = createMockApi({
serverUrl: "http://localhost:8000",
agentId: VALID_AGENT_ID,
agentName: "base-agent",
});

// When a source agent evaluates a tool call
register(api.api);
await runBeforeToolCall(api, {}, { agentId: "worker-1" });

// Then Agent Control receives the base agent name without a source suffix
expect(clientMocks.agentsInit).toHaveBeenCalledWith(
expect.objectContaining({
agent: expect.objectContaining({
agentName: "base-agent",
agentMetadata: expect.objectContaining({
openclawConfiguredAgentId: VALID_AGENT_ID,
}),
}),
}),
);
});

it("appends the source agent ID when no configured agent ID is present", async () => {
// Given a base agent name without a fixed configured agent ID
it("appends the source agent ID to the base agent name", async () => {
// Given a base agent name
const api = createMockApi({
serverUrl: "http://localhost:8000",
agentName: "base-agent",
Expand Down
Loading
Loading