← Back to Plugins
Voice

Agent Permissions

clawnify By clawnify 👁 35 views ▲ 0 votes

OpenClaw plugin β€” permission and approval engine. Gates built-in tool calls (bash, file edit, etc.) and plugin tools via a three-bucket policy (allow/deny/ask) with wildcard rules, in-chat approval, and no network calls.

GitHub

Install

npm install @clawnify/agent-permissions

Configuration Example

{
  "plugins": {
    "allow": ["agent-permissions", "your-consumer-plugin"],
    "entries": {
      "agent-permissions": {
        "enabled": true,
        "config": {
          "defaultMode": "default",
          "ask": ["Bash(*)"],
          "deny": ["Bash(rm -rf /:*)"]
        }
      }
    },
    "load": {
      "paths": [
        "/path/to/agent-permissions",
        "/path/to/your-consumer-plugin"
      ]
    }
  }
}

README

# @clawnify/agent-permissions

OpenClaw plugin β€” permission and approval engine for **any OpenClaw agent**.

Gates built-in tool calls (bash, file edit, web fetch, etc.) and any plugin-
registered tool through a three-bucket policy (`allow` / `deny` / `ask`),
with rule sources walked in priority order, in-chat approval surfaced via
OpenClaw's native `requireApproval`, and learning into `allow-always` rules.

**MIT licensed. Maintained by Clawnify, designed for the wider OpenClaw
ecosystem β€” works in any gateway with any agent setup.**

## Installation

```bash
openclaw plugins install @clawnify/agent-permissions --pin
```

Or via npm:

```bash
npm install @clawnify/agent-permissions
```

Then enable in `openclaw.json`:

```jsonc
{
  "plugins": {
    "allow": ["agent-permissions", "your-consumer-plugin"],
    "entries": {
      "agent-permissions": {
        "enabled": true,
        "config": {
          "defaultMode": "default",
          "ask": ["Bash(*)"],
          "deny": ["Bash(rm -rf /:*)"]
        }
      }
    },
    "load": {
      "paths": [
        "/path/to/agent-permissions",
        "/path/to/your-consumer-plugin"
      ]
    }
  }
}
```

`agent-permissions` must load **before** any consumer plugin (list it first
in `plugins.load.paths`) so the registration API is available when consumer
`register()` runs.

## Why this exists

OpenClaw's built-in permission infrastructure today is:

- **Gateway-level exec-approval** for `bash` β€” coarse, command-shape rules
- **`registerTrustedToolPolicy`** β€” bundled-only; external plugins can't use it

Non-bundled plugins that want to gate their own tools (or gate other plugins'
tools) have no host-level seam. They either reinvent approval per-plugin or
ship without it. This engine fills that gap.

Single global `before_tool_call` hook + an extension point so any plugin can
participate without each one rebuilding the policy / approval / learning loop.

## What it does

| Capability | How |
|---|---|
| Gates **any tool call** in the gateway | Single global `before_tool_call` hook at priority 100 (verified upstream sort direction in `src/plugins/hooks.ts:266`) |
| Built-in tools (bash, edit, …) supported | Via consumer-registered resolvers β€” opt-in per tool |
| Plugin tools supported | Consumer plugin calls `registerResolver({ toolName, resolve })` |
| Three-bucket policy | `allow` / `deny` / `ask` evaluated against rule sources in priority order |
| In-chat approval | Uses OpenClaw's native `requireApproval` β€” same UI as exec approvals |
| Learning | `allow-always` resolutions persist to user/local/session as configured |
| Wildcard rules | `Tool(foo)` exact, `Tool(foo:*)` legacy prefix, `Tool(foo *)` new wildcard |
| Dangerous-pattern denylist | Patterns like `python:*`, `node:*`, `eval` cannot be allow-always-persisted |
| Fail-closed | OpenClaw's hook runner catches exceptions and fails open β€” this plugin wraps every code path in try/catch and returns `{ block: true }` instead |

## What it does NOT do

- **Network calls.** Storage is local files. Consumers that want cloud sync
  can hook `onAllowAlwaysPersisted` and mirror to their own backend.
- **Tool-specific logic.** Each tool's rule-content + prompt text comes from
  a resolver, not from this engine. The engine is tool-agnostic.

## Architecture

```
β”Œβ”€β”€β”€ OpenClaw gateway process ────────────────────────────────────────┐
β”‚                                                                     β”‚
β”‚  Consumer plugin (e.g. agent-tools, clawflow, third-party)          β”‚
β”‚   └─ on register():                                                 β”‚
β”‚        getAgentPermissionsApi().registerResolver({                  β”‚
β”‚          toolName: "some_tool",                                     β”‚
β”‚          resolve(params) {                                          β”‚
β”‚            return { ruleContent: "delete", title, description };    β”‚
β”‚          },                                                         β”‚
β”‚        })                                                           β”‚
β”‚                                                                     β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚
β”‚  β”‚ agent-permissions (this plugin)                              β”‚  β”‚
β”‚  β”‚                                                              β”‚  β”‚
β”‚  β”‚  before_tool_call hook (priority: 100)                       β”‚  β”‚
β”‚  β”‚   β”œβ”€ resolver = resolvers.get(event.toolName)                β”‚  β”‚
β”‚  β”‚   β”œβ”€ req = resolver(event.params)                            β”‚  β”‚
β”‚  β”‚   β”œβ”€ decision = ruleEngine.evaluate(toolName, ruleContent)   β”‚  β”‚
β”‚  β”‚   β”œβ”€ bucket "deny"  β†’ { block: true, blockReason }           β”‚  β”‚
β”‚  β”‚   β”œβ”€ bucket "ask"   β†’ { requireApproval: {...} }             β”‚  β”‚
β”‚  β”‚   β”œβ”€ bucket "allow" β†’ undefined (proceed)                    β”‚  β”‚
β”‚  β”‚   └─ try/catch wrapper β†’ { block: true } on any error        β”‚  β”‚
β”‚  β”‚                                                              β”‚  β”‚
β”‚  β”‚  rule sources walked in priority order:                      β”‚  β”‚
β”‚  β”‚   1. session (in-memory, allow-always with scope:session)    β”‚  β”‚
β”‚  β”‚   2. local   (.openclaw/permissions.json in CWD)             β”‚  β”‚
β”‚  β”‚   3. user    (~/.openclaw/permissions.json)                  β”‚  β”‚
β”‚  β”‚   4. config  (pluginConfig.allow/deny/ask from openclaw.json)β”‚  β”‚
β”‚  β”‚                                                              β”‚  β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
```

## Rule format

Same shape as Anthropic's Claude Code permission system (studied as prior
art).

| Rule | Meaning |
|---|---|
| `ToolName` | Tool-wide rule (any params match). |
| `ToolName(*)` | Equivalent to tool-wide (empty / `*` content). |
| `Bash(npm install)` | Exact match on `(content)`. |
| `Bash(npm:*)` | Legacy prefix syntax β€” matches `npm`, `npm install`, etc. |
| `Bash(git *)` | Wildcard β€” `*` matches any chars. Trailing ` *` makes trailing args optional, so `git *` matches both `git add` and bare `git`. |
| `Bash(python -c "print\\(1\\)")` | Escape `(`, `)` in content with `\`. Escape `*` with `\*`. Escape `\` with `\\`. |

## Dangerous patterns

`dangerousPatterns` config (defaults built in) lists prefixes that may match
`ask` rules but can **never** be allow-always-persisted, even if the user
clicks "allow always." Reason: granting `Bash(python:*)` = arbitrary code
execution, which defeats the gate entirely.

Default list (conservative):

```
python python3 python2 node deno tsx ruby perl php lua
npx bunx npm run yarn run pnpm run bun run
bash sh zsh fish
eval exec env xargs sudo ssh
curl wget
```

Override `dangerousPatterns` in `openclaw.json` to extend or replace.

## Inter-plugin API (runtime)

```ts
import { getAgentPermissionsApi } from "@clawnify/agent-permissions";

// In your consumer plugin's register():
const perms = getAgentPermissionsApi(); // throws if agent-permissions not loaded

perms.registerResolver({
  toolName: "my_dangerous_tool",
  resolve(params) {
    const p = params as { target?: string };
    return {
      ruleContent: "delete",
      title: `Delete ${p.target ?? "?"}?`,
      description: "This is irreversible.",
    };
  },
});

perms.onAllowAlwaysPersisted(async (event) => {
  // Optional: mirror to your own backend, audit, etc.
});
```

The plugin publishes its API on `globalThis[Symbol.for("clawnify.agent-permissions.api.v1")]`,
so consumers find it at runtime even when each plugin ships as an independent
tarball with no shared `node_modules`. The `getAgentPermissionsApi()` helper
wraps the Symbol lookup with a descriptive error if the plugin isn't loaded
(typically a `plugins.load.paths` ordering issue).

## Development

```bash
git clone https://github.com/clawnify/agent-permissions.git
cd agent-permissions
npm install
npm run build
npm test
```

Tests use Node's built-in test runner via `tsx`. No vitest/jest setup.

## Releases

Tag a release on GitHub β†’ `.github/workflows/publish.yml` runs `npm publish --provenance`.

## License

MIT.
voice

Comments

Sign in to leave a comment

Loading comments...