daniel-ai-permissions-layer 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,26 @@
1
+ AI Permissions Layer
2
+ Copyright (C) 2025
3
+
4
+ This program is free software: you can redistribute it and/or modify
5
+ it under the terms of the GNU General Public License as published by
6
+ the Free Software Foundation, either version 3 of the License, or
7
+ (at your option) any later version.
8
+
9
+ This program is distributed in the hope that it will be useful,
10
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ GNU General Public License for more details.
13
+
14
+ You should have received a copy of the GNU General Public License
15
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
16
+
17
+ ---
18
+
19
+ GNU GENERAL PUBLIC LICENSE
20
+ Version 3, 29 June 2007
21
+
22
+ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
23
+ Everyone is permitted to copy and distribute verbatim copies
24
+ of this license document, but changing it is not allowed.
25
+
26
+ Full license text: https://www.gnu.org/licenses/gpl-3.0.html
package/README.md ADDED
@@ -0,0 +1,302 @@
1
+ # AI Permissions Layer
2
+
3
+ Middleware that intercepts AI agent tool calls, applies your rules, and returns **allow**, **block**, or **require approval**. Use it to control what your agent can do—block dangerous actions, require approval for sensitive ones, and allow safe operations by default.
4
+
5
+ ---
6
+
7
+ ## Table of Contents
8
+
9
+ - [Quick Start (OpenClaw)](#quick-start-openclaw)
10
+ - [Writing Rules](#writing-rules)
11
+ - [OpenClaw Setup & Usage](#openclaw-setup--usage)
12
+ - [Rule Reference](#rule-reference)
13
+ - [Approval Flow](#approval-flow)
14
+ - [Path Protection](#path-protection)
15
+ - [Configuration](#configuration)
16
+ - [Standalone Compile](#standalone-compile)
17
+ - [Library Usage](#library-usage)
18
+
19
+ ---
20
+
21
+ ## Quick Start (OpenClaw)
22
+
23
+ ```bash
24
+ openclaw plugins install ai-permissions-openclaw
25
+ openclaw gateway restart
26
+ openclaw ai-permissions compile
27
+ ```
28
+
29
+ The first `compile` creates `~/.openclaw/rules.yaml` with starter rules if none exist. Edit `rules.yaml`, run `compile` again when you change rules, and you're done. No extra API keys needed if you've run `openclaw onboard`.
30
+
31
+ ---
32
+
33
+ ## Writing Rules
34
+
35
+ Rules define what the agent can do. Each rule has an **action** and applies to one or more **tools**.
36
+
37
+ ### Actions
38
+
39
+ | Action | Behavior |
40
+ |--------|----------|
41
+ | **block** | Tool is never allowed. The agent sees an error and cannot proceed. |
42
+ | **require_approval** | Tool is blocked until you reply `APPROVE <uuid>` in chat. One-time approval per request. |
43
+ | **allow** | Tool runs without asking. |
44
+
45
+ ### Plain-Text Format (YAML)
46
+
47
+ Write rules in plain English, one per line, each starting with `-`:
48
+
49
+ ```yaml
50
+ # Block dangerous actions
51
+ - block gmail.delete and gmail.batchDelete - never auto-delete emails
52
+ - block payments and money transfers - no financial actions
53
+
54
+ # Require approval before risky operations
55
+ - require approval before exec, bash, or process - ask before running commands
56
+ - require approval before write, edit, apply_patch - ask before file changes
57
+
58
+ # Allow safe read-only operations
59
+ - allow read, search, list - safe read-only operations
60
+ - allow gmail.list and gmail.get - reading emails is fine
61
+ ```
62
+
63
+ **Compile** these into JSON with `openclaw ai-permissions compile` or `npx ai-permissions-compile --openclaw`. The compiler uses an LLM to infer tool names from your wording.
64
+
65
+ ### Compiler Language Hints
66
+
67
+ The compiler maps natural language to actions:
68
+
69
+ | You write | Action |
70
+ |-----------|--------|
71
+ | "block", "don't allow", "never" | `block` |
72
+ | "ask me", "prompt me", "require approval", "before X" | `require_approval` |
73
+ | "allow", "ok", "permit" | `allow` |
74
+
75
+ Include tool names when you know them (e.g. `gmail.delete`, `write`, `exec`). The compiler will infer others (e.g. "payments" → `payments`, "money transfers" → `money_transfers`).
76
+
77
+ ### Compiled JSON Format
78
+
79
+ After compiling, rules become JSON:
80
+
81
+ ```json
82
+ {
83
+ "rules": [
84
+ { "action": "block", "tool": "gmail.delete", "reason": "never auto-delete emails" },
85
+ { "action": "require_approval", "tool": "write", "reason": "ask before file changes" },
86
+ { "action": "allow", "tool": "read", "reason": "safe read-only operations" }
87
+ ]
88
+ }
89
+ ```
90
+
91
+ Each rule: `action`, `tool`, `reason`. The plugin loads this file; you typically edit the YAML and recompile.
92
+
93
+ ### OpenClaw Tool Names
94
+
95
+ Common OpenClaw tools to reference in rules:
96
+
97
+ | Category | Tools |
98
+ |----------|-------|
99
+ | **Files** | `read`, `write`, `edit`, `apply_patch` |
100
+ | **Runtime** | `exec`, `bash`, `process` |
101
+ | **Gmail** | `gmail.list`, `gmail.get`, `gmail.send`, `gmail.delete`, `gmail.batchDelete` |
102
+ | **Browser** | `browser_*`, `web_*` |
103
+ | **Internal** | `pairing`, `device-pair`, `openclaw.*` (these bypass rules) |
104
+
105
+ Internal tools (pairing, device-pair, openclaw.*) are never intercepted.
106
+
107
+ ### Rule Precedence
108
+
109
+ - **block** wins over **require_approval** when both match the same tool.
110
+ - First matching rule applies. Order matters when you have overlapping rules.
111
+ - When **no rule matches**, behavior is set by `defaultWhenNoMatch` (default: `require_approval`).
112
+
113
+ ---
114
+
115
+ ## OpenClaw Setup & Usage
116
+
117
+ ### Installation
118
+
119
+ ```bash
120
+ openclaw plugins install ai-permissions-openclaw
121
+ openclaw gateway restart
122
+ ```
123
+
124
+ ### Compile Rules
125
+
126
+ ```bash
127
+ # Default: ~/.openclaw/rules.yaml → ~/.openclaw/ai-permissions-rules.json
128
+ openclaw ai-permissions compile
129
+
130
+ # Custom input
131
+ openclaw ai-permissions compile my-rules.yaml
132
+
133
+ # Custom input and output
134
+ openclaw ai-permissions compile my-rules.yaml ~/.openclaw/ai-permissions-rules.json
135
+ ```
136
+
137
+ **First run:** If `~/.openclaw/rules.yaml` doesn't exist, it is created with starter rules and compiled.
138
+
139
+ **Credentials:** Uses OpenClaw's primary model from `~/.openclaw/openclaw.json` and credentials from `~/.openclaw/.env`. Run `openclaw onboard` first if you haven't.
140
+
141
+ ### Workflow
142
+
143
+ 1. Edit `~/.openclaw/rules.yaml`
144
+ 2. Run `openclaw ai-permissions compile`
145
+ 3. Rules take effect immediately (plugin reloads on each tool call)
146
+ 4. Restart the gateway only when changing plugin config
147
+
148
+ ---
149
+
150
+ ## Rule Reference
151
+
152
+ ### Exact Tool Match
153
+
154
+ Rules match by exact tool name:
155
+
156
+ ```yaml
157
+ - block gmail.delete - no auto delete
158
+ - allow read - read-only ok
159
+ ```
160
+
161
+ ### Multiple Tools per Rule
162
+
163
+ List tools in one rule (compiler creates one JSON rule per tool):
164
+
165
+ ```yaml
166
+ - block gmail.delete and gmail.batchDelete - never auto-delete emails
167
+ - require approval before write, edit, apply_patch - ask before file changes
168
+ ```
169
+
170
+ ### Advanced: toolPattern and intentPattern (JSON only)
171
+
172
+ For programmatic use, compiled rules can include:
173
+
174
+ - **toolPattern** — regex to match tool names (e.g. `gmail\.(delete|batchDelete)`)
175
+ - **intentPattern** — regex to match user intent text (optional)
176
+
177
+ These are produced by the compiler when it infers patterns. You can also edit the JSON directly for fine-grained control.
178
+
179
+ ---
180
+
181
+ ## Approval Flow
182
+
183
+ When a tool needs approval:
184
+
185
+ 1. The agent attempts the tool.
186
+ 2. The plugin blocks it and returns a message with a **request ID** (UUID).
187
+ 3. You reply in chat: `APPROVE <uuid>` to allow once, or `DENY <uuid>` to block.
188
+ 4. If you approved, the agent can retry the same action; the approval is consumed.
189
+
190
+ **Example:**
191
+
192
+ ```
193
+ Agent: I'll run `write` to save the file.
194
+ [Blocked] [Approval required] ask before file changes
195
+ Request ID: a1b2c3d4-...
196
+ Reply APPROVE a1b2c3d4-... to allow, or DENY a1b2c3d4-... to block.
197
+
198
+ You: APPROVE a1b2c3d4-...
199
+
200
+ Agent: [retries the write; it succeeds]
201
+ ```
202
+
203
+ ---
204
+
205
+ ## Path Protection
206
+
207
+ The plugin blocks the agent from modifying its own rules file. Writes to paths matching `**/rules*.json` or `**/.config/ai-permissions-layer/**` via `write`, `edit`, or `apply_patch` are blocked regardless of your rules.
208
+
209
+ This is enabled by default. Disable or customize via `pathProtection` in plugin config.
210
+
211
+ ---
212
+
213
+ ## Configuration
214
+
215
+ Edit `~/.openclaw/openclaw.json` under `plugins.entries.ai-permissions-openclaw.config`:
216
+
217
+ | Option | Default | Description |
218
+ |--------|---------|--------------|
219
+ | `rulesPath` | `~/.openclaw/ai-permissions-rules.json` | Path to compiled rules JSON |
220
+ | `defaultWhenNoMatch` | `require_approval` | When no rule matches: `allow`, `require_approval`, or `block` |
221
+ | `pathProtection.enabled` | `true` | Block writes to rules file |
222
+ | `pathProtection.dangerousTools` | `['write','edit','apply_patch']` | Tools that can write files (for path protection) |
223
+
224
+ **Example:**
225
+
226
+ ```json
227
+ {
228
+ "plugins": {
229
+ "entries": {
230
+ "ai-permissions-openclaw": {
231
+ "enabled": true,
232
+ "config": {
233
+ "rulesPath": "~/.openclaw/ai-permissions-rules.json",
234
+ "defaultWhenNoMatch": "require_approval",
235
+ "pathProtection": {
236
+ "enabled": true
237
+ }
238
+ }
239
+ }
240
+ }
241
+ }
242
+ }
243
+ ```
244
+
245
+ ---
246
+
247
+ ## Standalone Compile
248
+
249
+ Without OpenClaw, use the npm CLI:
250
+
251
+ ```bash
252
+ # With OpenAI (requires OPENAI_API_KEY)
253
+ export OPENAI_API_KEY=your_key
254
+ npx ai-permissions-compile examples/rules.yaml ~/.openclaw/ai-permissions-rules.json
255
+
256
+ # With OpenClaw config (uses ~/.openclaw/.env and openclaw.json)
257
+ npx ai-permissions-compile --openclaw examples/rules.yaml ~/.openclaw/ai-permissions-rules.json
258
+ ```
259
+
260
+ **Format:** One rule per line, each starting with `-`. See [examples/rules.yaml](examples/rules.yaml).
261
+
262
+ ---
263
+
264
+ ## Library Usage
265
+
266
+ ```bash
267
+ npm install ai-permissions-layer
268
+ ```
269
+
270
+ ```ts
271
+ import { createMiddleware, match } from 'ai-permissions-layer';
272
+
273
+ const rules = [
274
+ { action: 'block', tool: 'gmail.delete', reason: 'no delete' },
275
+ { action: 'require_approval', tool: 'gmail.send', reason: 'ask first' },
276
+ { action: 'allow', tool: 'read', reason: 'read-only ok' },
277
+ ];
278
+
279
+ const middleware = createMiddleware(rules, executor, {
280
+ defaultWhenNoMatch: 'require_approval',
281
+ pathProtection: {},
282
+ });
283
+
284
+ const result = await middleware(
285
+ { toolName: 'gmail.delete', args: {} },
286
+ { text: 'delete emails' }
287
+ );
288
+ // result.decision === 'BLOCK', result.executed === false
289
+ ```
290
+
291
+ ---
292
+
293
+ ## Examples
294
+
295
+ - [examples/rules.yaml](examples/rules.yaml) — plain-text rules
296
+ - [examples/ai-permissions-rules.json](examples/ai-permissions-rules.json) — compiled JSON
297
+
298
+ ---
299
+
300
+ ## License
301
+
302
+ GPL v3.
@@ -0,0 +1,2 @@
1
+ import type { LLMAdapter } from '../llm-adapter.js';
2
+ export declare function createOpenAIAdapter(apiKey: string, model?: string): LLMAdapter;
@@ -0,0 +1,19 @@
1
+ export function createOpenAIAdapter(apiKey, model = 'gpt-4o-mini') {
2
+ return {
3
+ async complete(prompt) {
4
+ const res = await fetch('https://api.openai.com/v1/chat/completions', {
5
+ method: 'POST',
6
+ headers: {
7
+ 'Content-Type': 'application/json',
8
+ Authorization: `Bearer ${apiKey}`,
9
+ },
10
+ body: JSON.stringify({
11
+ model,
12
+ messages: [{ role: 'user', content: prompt }],
13
+ }),
14
+ });
15
+ const data = (await res.json());
16
+ return data.choices[0].message.content;
17
+ },
18
+ };
19
+ }
@@ -0,0 +1,6 @@
1
+ import type { LLMAdapter } from '../llm-adapter.js';
2
+ /**
3
+ * Create an LLM adapter that uses OpenClaw's primary model from ~/.openclaw/openclaw.json.
4
+ * Falls back to OpenAI if config is missing or model resolution fails.
5
+ */
6
+ export declare function createOpenClawAdapter(fallbackApiKey?: string): LLMAdapter | null;
@@ -0,0 +1,202 @@
1
+ import { readFileSync, existsSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ function resolvePath(p) {
5
+ if (p.startsWith('~'))
6
+ return path.join(os.homedir(), p.slice(1));
7
+ return path.resolve(p);
8
+ }
9
+ function loadOpenClawEnv() {
10
+ const envPath = resolvePath('~/.openclaw/.env');
11
+ if (!existsSync(envPath))
12
+ return;
13
+ try {
14
+ const content = readFileSync(envPath, 'utf-8');
15
+ for (const line of content.split('\n')) {
16
+ const m = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
17
+ if (m && !(m[1] in process.env)) {
18
+ const val = m[2].replace(/^["']|["']$/g, '').trim();
19
+ process.env[m[1]] = val;
20
+ }
21
+ }
22
+ }
23
+ catch {
24
+ /* ignore */
25
+ }
26
+ }
27
+ function loadOpenClawConfig() {
28
+ const paths = [
29
+ resolvePath('~/.openclaw/openclaw.json'),
30
+ resolvePath('~/.config/openclaw/openclaw.json'),
31
+ ];
32
+ for (const p of paths) {
33
+ if (existsSync(p)) {
34
+ try {
35
+ return JSON.parse(readFileSync(p, 'utf-8'));
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ }
41
+ }
42
+ return null;
43
+ }
44
+ function resolveEnvValue(val) {
45
+ if (typeof val !== 'string')
46
+ return undefined;
47
+ const m = val.match(/^\$\{?([A-Z_][A-Z0-9_]*)\}?$/);
48
+ if (m)
49
+ return process.env[m[1]];
50
+ return val;
51
+ }
52
+ const BUILTIN_PROVIDERS = {
53
+ openai: {
54
+ baseUrl: 'https://api.openai.com/v1',
55
+ getApiKey: () => process.env.OPENAI_API_KEY,
56
+ },
57
+ ollama: {
58
+ baseUrl: 'http://127.0.0.1:11434/v1',
59
+ getApiKey: () => 'ollama',
60
+ },
61
+ 'lm-studio': {
62
+ baseUrl: 'http://localhost:1234/v1',
63
+ getApiKey: () => 'lm-studio',
64
+ },
65
+ lmstudio: {
66
+ baseUrl: 'http://localhost:1234/v1',
67
+ getApiKey: () => 'lm-studio',
68
+ },
69
+ vllm: {
70
+ baseUrl: 'http://127.0.0.1:8000/v1',
71
+ getApiKey: () => '',
72
+ },
73
+ };
74
+ /**
75
+ * Create an LLM adapter that uses OpenClaw's primary model from ~/.openclaw/openclaw.json.
76
+ * Falls back to OpenAI if config is missing or model resolution fails.
77
+ */
78
+ export function createOpenClawAdapter(fallbackApiKey) {
79
+ loadOpenClawEnv();
80
+ const config = loadOpenClawConfig();
81
+ if (!config)
82
+ return null;
83
+ const agents = (config.agents ?? config.agent);
84
+ const defaults = agents?.defaults;
85
+ const modelConfig = (defaults?.model ?? agents?.model);
86
+ const primary = modelConfig?.primary;
87
+ if (!primary || typeof primary !== 'string')
88
+ return null;
89
+ const slash = primary.indexOf('/');
90
+ const provider = slash >= 0 ? primary.slice(0, slash) : 'openai';
91
+ const modelId = slash >= 0 ? primary.slice(slash + 1) : primary;
92
+ const models = config.models;
93
+ const providers = models?.providers;
94
+ const providerConfig = providers?.[provider];
95
+ let baseUrl;
96
+ let apiKey;
97
+ if (providerConfig?.baseUrl) {
98
+ baseUrl = providerConfig.baseUrl.replace(/\/$/, '');
99
+ if (!baseUrl.endsWith('/v1'))
100
+ baseUrl += '/v1';
101
+ apiKey = resolveEnvValue(providerConfig.apiKey) ?? providerConfig.apiKey;
102
+ }
103
+ else {
104
+ const builtin = BUILTIN_PROVIDERS[provider] ?? BUILTIN_PROVIDERS.openai;
105
+ baseUrl = builtin.baseUrl;
106
+ apiKey = builtin.getApiKey();
107
+ }
108
+ if (!apiKey && provider === 'openai') {
109
+ apiKey = fallbackApiKey ?? process.env.OPENAI_API_KEY;
110
+ }
111
+ if (!apiKey && provider !== 'ollama' && provider !== 'lmstudio' && provider !== 'lm-studio' && provider !== 'vllm') {
112
+ return null;
113
+ }
114
+ const chatUrl = `${baseUrl}/chat/completions`;
115
+ const completionsUrl = `${baseUrl}/completions`;
116
+ const headers = {
117
+ 'Content-Type': 'application/json',
118
+ ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
119
+ };
120
+ async function tryChatWithModel(model, p) {
121
+ const res = await fetch(chatUrl, {
122
+ method: 'POST',
123
+ headers,
124
+ body: JSON.stringify({
125
+ model,
126
+ messages: [{ role: 'user', content: p }],
127
+ }),
128
+ });
129
+ if (!res.ok) {
130
+ const err = await res.text();
131
+ throw new Error(`OpenClaw model request failed: ${res.status} ${err}`);
132
+ }
133
+ const data = (await res.json());
134
+ const content = data.choices?.[0]?.message?.content;
135
+ if (content == null)
136
+ throw new Error('No response content from model');
137
+ return content;
138
+ }
139
+ return {
140
+ async complete(prompt) {
141
+ const tryChat = async () => {
142
+ const res = await fetch(chatUrl, {
143
+ method: 'POST',
144
+ headers,
145
+ body: JSON.stringify({
146
+ model: modelId,
147
+ messages: [{ role: 'user', content: prompt }],
148
+ }),
149
+ });
150
+ if (!res.ok) {
151
+ const err = await res.text();
152
+ if (res.status === 404 && /not a chat model|chat\/completions/i.test(err)) {
153
+ throw new Error('USE_COMPLETIONS');
154
+ }
155
+ throw new Error(`OpenClaw model request failed: ${res.status} ${err}`);
156
+ }
157
+ const data = (await res.json());
158
+ const content = data.choices?.[0]?.message?.content;
159
+ if (content == null)
160
+ throw new Error('No response content from model');
161
+ return content;
162
+ };
163
+ const tryCompletions = async () => {
164
+ const res = await fetch(completionsUrl, {
165
+ method: 'POST',
166
+ headers,
167
+ body: JSON.stringify({
168
+ model: modelId,
169
+ prompt,
170
+ }),
171
+ });
172
+ if (!res.ok) {
173
+ const err = await res.text();
174
+ throw new Error(`OpenClaw model request failed: ${res.status} ${err}`);
175
+ }
176
+ const data = (await res.json());
177
+ const text = data.choices?.[0]?.text;
178
+ if (text == null)
179
+ throw new Error('No response content from model');
180
+ return text.trim();
181
+ };
182
+ try {
183
+ return await tryChat();
184
+ }
185
+ catch (e) {
186
+ if (e instanceof Error && e.message === 'USE_COMPLETIONS') {
187
+ try {
188
+ return await tryCompletions();
189
+ }
190
+ catch {
191
+ /* fall through to chat fallback */
192
+ }
193
+ }
194
+ if (provider === 'openai' && apiKey) {
195
+ console.error(`Primary model ${modelId} does not support chat; using gpt-4o-mini for compile`);
196
+ return await tryChatWithModel('gpt-4o-mini', prompt);
197
+ }
198
+ throw e;
199
+ }
200
+ },
201
+ };
202
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync, writeFileSync, existsSync } from 'fs';
3
+ import { compile } from './compiler.js';
4
+ import { createOpenAIAdapter } from './adapters/openai-adapter.js';
5
+ import { createOpenClawAdapter } from './adapters/openclaw-adapter.js';
6
+ async function main() {
7
+ const args = process.argv.slice(2);
8
+ const useOpenClaw = args.includes('--openclaw');
9
+ const filtered = args.filter((a) => a !== '--openclaw');
10
+ const inputFile = filtered[0] || 'rules.yaml';
11
+ const outputFile = filtered[1] || 'rules.compiled.json';
12
+ let llm;
13
+ if (useOpenClaw) {
14
+ const openClaw = createOpenClawAdapter(process.env.OPENAI_API_KEY);
15
+ if (openClaw) {
16
+ llm = openClaw;
17
+ console.error('Using OpenClaw primary model');
18
+ }
19
+ else {
20
+ const apiKey = process.env.OPENAI_API_KEY;
21
+ if (!apiKey) {
22
+ console.error('OpenClaw config not found or model unresolved. Set OPENAI_API_KEY or run openclaw onboard first.');
23
+ process.exit(1);
24
+ }
25
+ llm = createOpenAIAdapter(apiKey);
26
+ console.error('OpenClaw config not found, falling back to OpenAI');
27
+ }
28
+ }
29
+ else {
30
+ const apiKey = process.env.OPENAI_API_KEY;
31
+ if (!apiKey) {
32
+ console.error('OPENAI_API_KEY required');
33
+ process.exit(1);
34
+ }
35
+ llm = createOpenAIAdapter(apiKey);
36
+ }
37
+ if (!existsSync(inputFile)) {
38
+ console.error(`Input file not found: ${inputFile}`);
39
+ console.error('Usage: npx ai-permissions-compile [--openclaw] <input.yaml> [output.json]');
40
+ console.error('Example: npx ai-permissions-compile --openclaw examples/rules.yaml ~/.openclaw/ai-permissions-rules.json');
41
+ process.exit(1);
42
+ }
43
+ const content = readFileSync(inputFile, 'utf-8');
44
+ const rules = content
45
+ .split('\n')
46
+ .filter((l) => l.trim().startsWith('-'))
47
+ .map((l) => l.replace(/^-\s*["']?|["']?$/g, '').trim());
48
+ const { rules: compiled } = await compile(rules, llm);
49
+ writeFileSync(outputFile, JSON.stringify({ rules: compiled }, null, 2));
50
+ console.log(`Compiled ${compiled.length} rules to ${outputFile}`);
51
+ }
52
+ main().catch(console.error);
@@ -0,0 +1,5 @@
1
+ import type { CompiledRule } from './types.js';
2
+ import type { LLMAdapter } from './llm-adapter.js';
3
+ export declare function compile(plainTextRules: string[], llm: LLMAdapter): Promise<{
4
+ rules: CompiledRule[];
5
+ }>;
@@ -0,0 +1,17 @@
1
+ const COMPILER_PROMPT = `You are a rule extractor. Convert user rules into structured JSON.
2
+
3
+ Rules:
4
+ - "don't allow" / "never" / "block" → action: "block"
5
+ - "ask me" / "prompt me" / "before X" → action: "require_approval" (NEVER "allow")
6
+ - "allow" → action: "allow"
7
+
8
+ Output ONLY valid JSON: { "rules": [ { "action": "...", "tool": "...", "reason": "..." } ] }
9
+ Include tool names when inferable (e.g. gmail.delete, gmail.batchDelete for email delete).
10
+ `;
11
+ export async function compile(plainTextRules, llm) {
12
+ const prompt = `${COMPILER_PROMPT}\n\nUser rules:\n${plainTextRules.map((r) => `- ${r}`).join('\n')}`;
13
+ const raw = await llm.complete(prompt);
14
+ const stripped = raw.replace(/^```(?:json)?\s*\n?|\n?```\s*$/g, '').trim();
15
+ const parsed = JSON.parse(stripped);
16
+ return { rules: parsed.rules };
17
+ }
@@ -0,0 +1,8 @@
1
+ export * from './types.js';
2
+ export * from './matcher.js';
3
+ export * from './compiler.js';
4
+ export * from './middleware.js';
5
+ export * from './llm-adapter.js';
6
+ export * from './rules.js';
7
+ export * from './path-protection.js';
8
+ export { createOpenClawAdapter } from './adapters/openclaw-adapter.js';
package/dist/index.js ADDED
@@ -0,0 +1,8 @@
1
+ export * from './types.js';
2
+ export * from './matcher.js';
3
+ export * from './compiler.js';
4
+ export * from './middleware.js';
5
+ export * from './llm-adapter.js';
6
+ export * from './rules.js';
7
+ export * from './path-protection.js';
8
+ export { createOpenClawAdapter } from './adapters/openclaw-adapter.js';
@@ -0,0 +1,3 @@
1
+ export interface LLMAdapter {
2
+ complete(prompt: string): Promise<string>;
3
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,5 @@
1
+ import type { ToolCall, Intent, CompiledRule, CheckResult, DefaultWhenNoMatch } from './types.js';
2
+ export interface MatchOptions {
3
+ defaultWhenNoMatch?: DefaultWhenNoMatch;
4
+ }
5
+ export declare function match(toolCall: ToolCall, intent: Intent, rules: CompiledRule[], options?: MatchOptions): CheckResult;
@@ -0,0 +1,49 @@
1
+ const ACTION_TO_DECISION = {
2
+ block: 'BLOCK',
3
+ require_approval: 'REQUIRES_APPROVAL',
4
+ allow: 'ALLOW',
5
+ };
6
+ function matchArgs(rule, toolCall) {
7
+ if (!rule.argsPattern)
8
+ return true;
9
+ const argsStr = Object.values(toolCall.args)
10
+ .filter((v) => typeof v === 'string' || typeof v === 'number')
11
+ .join(' ');
12
+ return new RegExp(rule.argsPattern).test(argsStr);
13
+ }
14
+ export function match(toolCall, intent, rules, options = {}) {
15
+ const { defaultWhenNoMatch = 'require_approval' } = options;
16
+ let matched = null;
17
+ let matchedAction = null;
18
+ for (const rule of rules) {
19
+ const toolMatch = (rule.tool && rule.tool === toolCall.toolName) ||
20
+ (rule.toolPattern &&
21
+ new RegExp(rule.toolPattern).test(toolCall.toolName));
22
+ const argsMatch = matchArgs(rule, toolCall);
23
+ const intentMatch = !rule.intentPattern ||
24
+ new RegExp(rule.intentPattern, 'i').test(intent.text);
25
+ if (toolMatch && argsMatch && intentMatch) {
26
+ if (!matched || rule.action === 'block') {
27
+ matched = rule;
28
+ matchedAction = rule.action;
29
+ if (rule.action === 'block')
30
+ break;
31
+ }
32
+ }
33
+ }
34
+ if (!matched) {
35
+ const decision = defaultWhenNoMatch === 'allow'
36
+ ? 'ALLOW'
37
+ : defaultWhenNoMatch === 'block'
38
+ ? 'BLOCK'
39
+ : 'REQUIRES_APPROVAL';
40
+ return {
41
+ decision,
42
+ reason: 'No matching rule',
43
+ };
44
+ }
45
+ return {
46
+ decision: ACTION_TO_DECISION[matchedAction],
47
+ reason: matched.reason,
48
+ };
49
+ }
@@ -0,0 +1,13 @@
1
+ import type { ToolCall, Intent, CompiledRule, DefaultWhenNoMatch, PathProtectionConfig } from './types.js';
2
+ export type ToolExecutor = (toolCall: ToolCall) => Promise<unknown>;
3
+ export interface MiddlewareResult {
4
+ decision: 'ALLOW' | 'BLOCK' | 'REQUIRES_APPROVAL';
5
+ reason?: string;
6
+ executed: boolean;
7
+ result?: unknown;
8
+ }
9
+ export interface MiddlewareOptions {
10
+ defaultWhenNoMatch?: DefaultWhenNoMatch;
11
+ pathProtection?: PathProtectionConfig | Record<string, never>;
12
+ }
13
+ export declare function createMiddleware(rules: CompiledRule[], executor: ToolExecutor, options?: MiddlewareOptions): (toolCall: ToolCall, intent: Intent) => Promise<MiddlewareResult>;
@@ -0,0 +1,34 @@
1
+ import { match } from './matcher.js';
2
+ import { isProtectedPathViolation, DEFAULT_DANGEROUS_TOOLS, DEFAULT_PROTECTED_PATTERNS, } from './path-protection.js';
3
+ function resolvePathProtection(pathProtection) {
4
+ if (!pathProtection)
5
+ return null;
6
+ if (Object.keys(pathProtection).length === 0) {
7
+ return {
8
+ dangerousTools: DEFAULT_DANGEROUS_TOOLS,
9
+ protectedPatterns: DEFAULT_PROTECTED_PATTERNS,
10
+ };
11
+ }
12
+ return pathProtection;
13
+ }
14
+ export function createMiddleware(rules, executor, options = {}) {
15
+ const { defaultWhenNoMatch = 'require_approval', pathProtection, } = options;
16
+ const pathConfig = resolvePathProtection(pathProtection);
17
+ return async (toolCall, intent) => {
18
+ if (pathConfig && isProtectedPathViolation(toolCall, pathConfig)) {
19
+ return {
20
+ decision: 'BLOCK',
21
+ reason: 'Protected path: rules cannot be modified by agent',
22
+ executed: false,
23
+ };
24
+ }
25
+ const { decision, reason } = match(toolCall, intent, rules, {
26
+ defaultWhenNoMatch,
27
+ });
28
+ if (decision === 'BLOCK' || decision === 'REQUIRES_APPROVAL') {
29
+ return { decision, reason, executed: false };
30
+ }
31
+ const result = await executor(toolCall);
32
+ return { decision: 'ALLOW', executed: true, result };
33
+ };
34
+ }
@@ -0,0 +1,6 @@
1
+ import type { ToolCall, PathProtectionConfig } from './types.js';
2
+ export declare const DEFAULT_DANGEROUS_TOOLS: string[];
3
+ /** OpenClaw file tools (group:fs) that can write files */
4
+ export declare const OPENCLAW_DANGEROUS_TOOLS: string[];
5
+ export declare const DEFAULT_PROTECTED_PATTERNS: string[];
6
+ export declare function isProtectedPathViolation(toolCall: ToolCall, config: PathProtectionConfig): boolean;
@@ -0,0 +1,34 @@
1
+ import { minimatch } from 'minimatch';
2
+ export const DEFAULT_DANGEROUS_TOOLS = [
3
+ 'filesystem.write',
4
+ 'filesystem.edit',
5
+ 'write_file',
6
+ 'edit_file',
7
+ 'writeFile',
8
+ 'fs.writeFile',
9
+ ];
10
+ /** OpenClaw file tools (group:fs) that can write files */
11
+ export const OPENCLAW_DANGEROUS_TOOLS = ['write', 'edit', 'apply_patch'];
12
+ export const DEFAULT_PROTECTED_PATTERNS = [
13
+ '**/rules*.json',
14
+ '**/.config/ai-permissions-layer/**',
15
+ ];
16
+ const PATH_KEYS = ['path', 'file_path', 'filePath', 'filename'];
17
+ function extractPath(args) {
18
+ for (const key of PATH_KEYS) {
19
+ const val = args[key];
20
+ if (typeof val === 'string')
21
+ return val;
22
+ }
23
+ return null;
24
+ }
25
+ export function isProtectedPathViolation(toolCall, config) {
26
+ if (!config.dangerousTools.includes(toolCall.toolName)) {
27
+ return false;
28
+ }
29
+ const path = extractPath(toolCall.args);
30
+ if (!path)
31
+ return false;
32
+ const normalizedPath = path.replace(/\\/g, '/');
33
+ return config.protectedPatterns.some((pattern) => minimatch(normalizedPath, pattern));
34
+ }
@@ -0,0 +1,2 @@
1
+ import type { ToolCall, Intent, CompiledRule } from './types.js';
2
+ export declare function createAllowRule(toolCall: ToolCall, _intent: Intent): CompiledRule;
package/dist/rules.js ADDED
@@ -0,0 +1,8 @@
1
+ export function createAllowRule(toolCall, _intent) {
2
+ const date = new Date().toISOString().slice(0, 10);
3
+ return {
4
+ action: 'allow',
5
+ tool: toolCall.toolName,
6
+ reason: `User approved forever on ${date}`,
7
+ };
8
+ }
@@ -0,0 +1,30 @@
1
+ export type Decision = 'ALLOW' | 'BLOCK' | 'REQUIRES_APPROVAL';
2
+ export interface ToolCall {
3
+ toolName: string;
4
+ args: Record<string, unknown>;
5
+ }
6
+ export interface Intent {
7
+ text: string;
8
+ }
9
+ export type RuleAction = 'block' | 'require_approval' | 'allow';
10
+ export interface CompiledRule {
11
+ action: RuleAction;
12
+ tool?: string;
13
+ toolPattern?: string;
14
+ intentPattern?: string;
15
+ /** Regex to match against tool call arguments (joined as space-separated string) */
16
+ argsPattern?: string;
17
+ reason: string;
18
+ }
19
+ export interface CheckResult {
20
+ decision: Decision;
21
+ reason?: string;
22
+ }
23
+ /** When no rule matches: 'allow', 'require_approval', or 'block'. Default: 'require_approval'. */
24
+ export type DefaultWhenNoMatch = 'allow' | 'require_approval' | 'block';
25
+ export interface PathProtectionConfig {
26
+ /** Tool names that can write files (e.g. "filesystem.write", "edit_file") */
27
+ dangerousTools: string[];
28
+ /** Glob patterns for paths the agent must not write to */
29
+ protectedPatterns: string[];
30
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "daniel-ai-permissions-layer",
3
+ "version": "0.2.0",
4
+ "description": "Offline-first middleware for AI agent tool call permissions. Intercepts tool calls, applies rules, returns ALLOW | BLOCK | REQUIRES_APPROVAL.",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "bin": {
9
+ "ai-permissions-compile": "./dist/cli.js"
10
+ },
11
+ "scripts": {
12
+ "build": "tsc",
13
+ "test": "vitest run",
14
+ "test:watch": "vitest",
15
+ "build:plugin": "npm run build && cd openclaw-plugin && npm run build",
16
+ "prepublishOnly": "npm run build"
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "LICENSE",
21
+ "README.md"
22
+ ],
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "https://github.com/wei292224644/my-ai-permissions-layer.git"
26
+ },
27
+ "bugs": {
28
+ "url": "https://github.com/wei292224644/my-ai-permissions-layer/issues"
29
+ },
30
+ "homepage": "https://github.com/wei292224644/my-ai-permissions-layer#readme",
31
+ "keywords": [
32
+ "ai",
33
+ "permissions",
34
+ "middleware",
35
+ "openclaw",
36
+ "tool-calls",
37
+ "agent",
38
+ "llm"
39
+ ],
40
+ "license": "GPL-3.0-or-later",
41
+ "engines": {
42
+ "node": ">=18"
43
+ },
44
+ "devDependencies": {
45
+ "@types/node": "^25.3.0",
46
+ "typescript": "^5.9.3",
47
+ "vitest": "^4.0.18"
48
+ },
49
+ "dependencies": {
50
+ "minimatch": "^10.2.2"
51
+ }
52
+ }