daniel-ai-permissions-openclaw 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/dist/approval-store.d.ts +24 -0
- package/dist/approval-store.js +83 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +175 -0
- package/openclaw.plugin.json +44 -0
- package/package.json +42 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-use approval store for REQUIRES_APPROVAL flow.
|
|
3
|
+
* UUIDs are consumed on first use; cannot be reused.
|
|
4
|
+
*/
|
|
5
|
+
export interface PendingApproval {
|
|
6
|
+
uuid: string;
|
|
7
|
+
toolName: string;
|
|
8
|
+
params: Record<string, unknown>;
|
|
9
|
+
status: 'pending' | 'approved' | 'denied';
|
|
10
|
+
reason: string;
|
|
11
|
+
createdAt: number;
|
|
12
|
+
}
|
|
13
|
+
export declare function createApprovalRequest(toolName: string, params: Record<string, unknown>, reason: string): string;
|
|
14
|
+
export declare function resolveApproval(uuid: string, decision: 'APPROVE' | 'DENY'): boolean;
|
|
15
|
+
/**
|
|
16
|
+
* Consume an approved approval for this tool call. One-use: deletes after use.
|
|
17
|
+
* Returns true if an approved matching approval was found and consumed.
|
|
18
|
+
*/
|
|
19
|
+
export declare function consumeApprovalIfExists(toolName: string, params: Record<string, unknown>): boolean;
|
|
20
|
+
export declare function getApprovalStatus(uuid: string): 'pending' | 'approved' | 'denied' | 'unknown';
|
|
21
|
+
export declare function parseApprovalFromMessage(content: string): {
|
|
22
|
+
uuid: string;
|
|
23
|
+
decision: 'APPROVE' | 'DENY';
|
|
24
|
+
} | null;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-use approval store for REQUIRES_APPROVAL flow.
|
|
3
|
+
* UUIDs are consumed on first use; cannot be reused.
|
|
4
|
+
*/
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
6
|
+
const APPROVAL_TTL_MS = 60 * 60 * 1000; // 1 hour
|
|
7
|
+
function paramsKey(params) {
|
|
8
|
+
try {
|
|
9
|
+
return JSON.stringify(params, Object.keys(params).sort());
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return String(params);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
const store = new Map();
|
|
16
|
+
const paramsToUuid = new Map(); // toolName:paramsKey -> uuid for lookup
|
|
17
|
+
export function createApprovalRequest(toolName, params, reason) {
|
|
18
|
+
const uuid = randomUUID();
|
|
19
|
+
const key = `${toolName}:${paramsKey(params)}`;
|
|
20
|
+
const approval = {
|
|
21
|
+
uuid,
|
|
22
|
+
toolName,
|
|
23
|
+
params,
|
|
24
|
+
status: 'pending',
|
|
25
|
+
reason,
|
|
26
|
+
createdAt: Date.now(),
|
|
27
|
+
};
|
|
28
|
+
store.set(uuid, approval);
|
|
29
|
+
paramsToUuid.set(key, uuid);
|
|
30
|
+
return uuid;
|
|
31
|
+
}
|
|
32
|
+
export function resolveApproval(uuid, decision) {
|
|
33
|
+
const approval = store.get(uuid);
|
|
34
|
+
if (!approval || approval.status !== 'pending')
|
|
35
|
+
return false;
|
|
36
|
+
approval.status = decision === 'APPROVE' ? 'approved' : 'denied';
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Consume an approved approval for this tool call. One-use: deletes after use.
|
|
41
|
+
* Returns true if an approved matching approval was found and consumed.
|
|
42
|
+
*/
|
|
43
|
+
export function consumeApprovalIfExists(toolName, params) {
|
|
44
|
+
const key = `${toolName}:${paramsKey(params)}`;
|
|
45
|
+
const uuid = paramsToUuid.get(key);
|
|
46
|
+
if (!uuid)
|
|
47
|
+
return false;
|
|
48
|
+
const approval = store.get(uuid);
|
|
49
|
+
if (!approval || approval.status !== 'approved')
|
|
50
|
+
return false;
|
|
51
|
+
// Consume: remove so it cannot be used again
|
|
52
|
+
store.delete(uuid);
|
|
53
|
+
paramsToUuid.delete(key);
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
export function getApprovalStatus(uuid) {
|
|
57
|
+
const approval = store.get(uuid);
|
|
58
|
+
if (!approval)
|
|
59
|
+
return 'unknown';
|
|
60
|
+
return approval.status;
|
|
61
|
+
}
|
|
62
|
+
function cleanupExpired() {
|
|
63
|
+
const now = Date.now();
|
|
64
|
+
for (const [uuid, approval] of store.entries()) {
|
|
65
|
+
if (approval.status === 'pending' && now - approval.createdAt > APPROVAL_TTL_MS) {
|
|
66
|
+
store.delete(uuid);
|
|
67
|
+
paramsToUuid.delete(`${approval.toolName}:${paramsKey(approval.params)}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const UUID_REGEX = '[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}';
|
|
72
|
+
export function parseApprovalFromMessage(content) {
|
|
73
|
+
const trimmed = (content || '').trim();
|
|
74
|
+
const approveMatch = trimmed.match(new RegExp(`(?:^|\\s)(?:/approve\\s+)?approve\\s+(${UUID_REGEX})`, 'i'));
|
|
75
|
+
if (approveMatch)
|
|
76
|
+
return { uuid: approveMatch[1].toLowerCase(), decision: 'APPROVE' };
|
|
77
|
+
const denyMatch = trimmed.match(new RegExp(`(?:^|\\s)(?:/deny\\s+)?deny\\s+(${UUID_REGEX})`, 'i'));
|
|
78
|
+
if (denyMatch)
|
|
79
|
+
return { uuid: denyMatch[1].toLowerCase(), decision: 'DENY' };
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
// Run cleanup periodically
|
|
83
|
+
setInterval(cleanupExpired, 5 * 60 * 1000); // every 5 min
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI Permissions Layer - OpenClaw Plugin
|
|
3
|
+
*
|
|
4
|
+
* Intercepts tool calls via before_tool_call hook, applies user-defined rules,
|
|
5
|
+
* returns ALLOW | BLOCK | REQUIRES_APPROVAL. Zero-setup: uses OpenClaw's model config.
|
|
6
|
+
* REQUIRES_APPROVAL: generates one-use UUID, prompts user; APPROVE/DENY consumed via message_received.
|
|
7
|
+
*/
|
|
8
|
+
import { type DefaultWhenNoMatch } from 'daniel-ai-permissions-layer';
|
|
9
|
+
interface PluginConfig {
|
|
10
|
+
rulesPath?: string;
|
|
11
|
+
defaultWhenNoMatch?: DefaultWhenNoMatch;
|
|
12
|
+
pathProtection?: {
|
|
13
|
+
enabled?: boolean;
|
|
14
|
+
dangerousTools?: string[];
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export default function aiPermissionsPlugin(api: {
|
|
18
|
+
on: (hook: string, handler: (...args: unknown[]) => unknown) => void;
|
|
19
|
+
logger: {
|
|
20
|
+
info: (msg: string) => void;
|
|
21
|
+
warn: (msg: string) => void;
|
|
22
|
+
};
|
|
23
|
+
pluginConfig?: PluginConfig;
|
|
24
|
+
}): void;
|
|
25
|
+
export {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI Permissions Layer - OpenClaw Plugin
|
|
3
|
+
*
|
|
4
|
+
* Intercepts tool calls via before_tool_call hook, applies user-defined rules,
|
|
5
|
+
* returns ALLOW | BLOCK | REQUIRES_APPROVAL. Zero-setup: uses OpenClaw's model config.
|
|
6
|
+
* REQUIRES_APPROVAL: generates one-use UUID, prompts user; APPROVE/DENY consumed via message_received.
|
|
7
|
+
*/
|
|
8
|
+
import { match, compile, createOpenClawAdapter, isProtectedPathViolation, OPENCLAW_DANGEROUS_TOOLS, DEFAULT_PROTECTED_PATTERNS, } from 'daniel-ai-permissions-layer';
|
|
9
|
+
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import os from 'node:os';
|
|
12
|
+
import { createApprovalRequest, consumeApprovalIfExists, resolveApproval, parseApprovalFromMessage, } from './approval-store.js';
|
|
13
|
+
function resolvePath(p) {
|
|
14
|
+
if (p.startsWith('~')) {
|
|
15
|
+
return path.join(os.homedir(), p.slice(1));
|
|
16
|
+
}
|
|
17
|
+
return path.resolve(p);
|
|
18
|
+
}
|
|
19
|
+
function extractCommand(args) {
|
|
20
|
+
for (const key of ['command', 'cmd', 'script', 'code', 'args']) {
|
|
21
|
+
const val = args[key];
|
|
22
|
+
if (typeof val === 'string' && val.length > 0)
|
|
23
|
+
return val;
|
|
24
|
+
if (Array.isArray(val))
|
|
25
|
+
return val.join(' ');
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
const DEFAULT_RULES_YAML = resolvePath('~/.openclaw/rules.yaml');
|
|
30
|
+
const STARTER_RULES = `# AI Permissions - edit and run: openclaw ai-permissions compile
|
|
31
|
+
- block gmail.delete and gmail.batchDelete - never auto-delete emails
|
|
32
|
+
- require approval before exec, bash, or process - ask before running commands
|
|
33
|
+
- require approval before write, edit, apply_patch - ask before file changes
|
|
34
|
+
- allow read, search, list - safe read-only operations
|
|
35
|
+
`;
|
|
36
|
+
function loadRules(rulesPath) {
|
|
37
|
+
const resolved = resolvePath(rulesPath);
|
|
38
|
+
if (!existsSync(resolved)) {
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
const raw = readFileSync(resolved, 'utf-8');
|
|
43
|
+
const parsed = JSON.parse(raw);
|
|
44
|
+
return parsed.rules ?? [];
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export default function aiPermissionsPlugin(api) {
|
|
51
|
+
const logger = api.logger;
|
|
52
|
+
const config = {
|
|
53
|
+
rulesPath: '~/.openclaw/ai-permissions-rules.json',
|
|
54
|
+
defaultWhenNoMatch: 'require_approval',
|
|
55
|
+
pathProtection: { enabled: true },
|
|
56
|
+
...api.pluginConfig,
|
|
57
|
+
};
|
|
58
|
+
const pathConfig = config.pathProtection?.enabled !== false
|
|
59
|
+
? {
|
|
60
|
+
dangerousTools: config.pathProtection?.dangerousTools ?? OPENCLAW_DANGEROUS_TOOLS,
|
|
61
|
+
protectedPatterns: DEFAULT_PROTECTED_PATTERNS,
|
|
62
|
+
}
|
|
63
|
+
: null;
|
|
64
|
+
const INTERNAL_TOOL_PATTERNS = [
|
|
65
|
+
/^pairing$/i,
|
|
66
|
+
/^device[-_]?pair/i,
|
|
67
|
+
/^pair\b/i,
|
|
68
|
+
/internal/i,
|
|
69
|
+
/^openclaw\./i,
|
|
70
|
+
];
|
|
71
|
+
const hookHandler = async (...args) => {
|
|
72
|
+
const event = args[0];
|
|
73
|
+
const toolName = event.toolName;
|
|
74
|
+
const params = (event.params ?? {});
|
|
75
|
+
if (INTERNAL_TOOL_PATTERNS.some((p) => p.test(toolName))) {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
const toolCall = { toolName, args: params };
|
|
79
|
+
const intent = { text: '' };
|
|
80
|
+
if (pathConfig && isProtectedPathViolation(toolCall, pathConfig)) {
|
|
81
|
+
logger.warn(`[ai-permissions-openclaw] BLOCKED: Protected path - ${toolName}`);
|
|
82
|
+
return {
|
|
83
|
+
block: true,
|
|
84
|
+
blockReason: 'Protected path: rules cannot be modified by agent',
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
const rules = loadRules(config.rulesPath);
|
|
88
|
+
const result = match(toolCall, intent, rules, {
|
|
89
|
+
defaultWhenNoMatch: config.defaultWhenNoMatch,
|
|
90
|
+
});
|
|
91
|
+
if (result.decision === 'BLOCK') {
|
|
92
|
+
logger.warn(`[ai-permissions-openclaw] BLOCKED: ${result.reason ?? 'Rule matched'}`);
|
|
93
|
+
return {
|
|
94
|
+
block: true,
|
|
95
|
+
blockReason: result.reason ?? 'Blocked by permissions rule',
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
if (result.decision === 'REQUIRES_APPROVAL') {
|
|
99
|
+
if (consumeApprovalIfExists(toolName, params)) {
|
|
100
|
+
logger.info(`[ai-permissions-openclaw] ALLOWED: User approved (one-use consumed)`);
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
const cmd = extractCommand(params);
|
|
104
|
+
const cmdLine = cmd ? `\nCommand: ${cmd}` : '';
|
|
105
|
+
const uuid = createApprovalRequest(toolName, params, result.reason ?? 'No matching rule');
|
|
106
|
+
logger.warn(`[ai-permissions-openclaw] REQUIRES_APPROVAL: ${result.reason ?? 'No matching rule'} (uuid=${uuid})`);
|
|
107
|
+
return {
|
|
108
|
+
block: true,
|
|
109
|
+
blockReason: `[Approval required] ${result.reason ?? 'No matching rule'}${cmdLine}\n\n` +
|
|
110
|
+
`Request ID: ${uuid}\n\n` +
|
|
111
|
+
`Ask the user: Reply APPROVE ${uuid} to allow this action, or DENY ${uuid} to block it. ` +
|
|
112
|
+
`This is a one-use approval; after APPROVE, retry the same action.`,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
return undefined;
|
|
116
|
+
};
|
|
117
|
+
const messageReceivedHandler = (...args) => {
|
|
118
|
+
const event = args[0];
|
|
119
|
+
const parsed = parseApprovalFromMessage(event?.content ?? '');
|
|
120
|
+
if (!parsed)
|
|
121
|
+
return;
|
|
122
|
+
const ok = resolveApproval(parsed.uuid, parsed.decision);
|
|
123
|
+
if (ok) {
|
|
124
|
+
logger.info(`[ai-permissions-openclaw] User ${parsed.decision}d request ${parsed.uuid}`);
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
const apiAny = api;
|
|
128
|
+
if (typeof apiAny.on === 'function') {
|
|
129
|
+
apiAny.on('before_tool_call', hookHandler);
|
|
130
|
+
apiAny.on('message_received', messageReceivedHandler);
|
|
131
|
+
}
|
|
132
|
+
else if (typeof apiAny.registerHook === 'function') {
|
|
133
|
+
apiAny.registerHook('before_tool_call', hookHandler);
|
|
134
|
+
apiAny.registerHook('message_received', messageReceivedHandler);
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
logger.warn('[ai-permissions-openclaw] No hook API found (api.on or api.registerHook)');
|
|
138
|
+
}
|
|
139
|
+
if (typeof apiAny.registerCli === 'function') {
|
|
140
|
+
const compileHandler = async (input, output) => {
|
|
141
|
+
const inputPath = input ? resolvePath(input) : DEFAULT_RULES_YAML;
|
|
142
|
+
const outputPath = output ? resolvePath(output) : resolvePath(config.rulesPath);
|
|
143
|
+
if (!existsSync(inputPath)) {
|
|
144
|
+
if (!input) {
|
|
145
|
+
writeFileSync(DEFAULT_RULES_YAML, STARTER_RULES);
|
|
146
|
+
console.log(`Created ${DEFAULT_RULES_YAML} with starter rules.`);
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
console.error(`Input file not found: ${inputPath}`);
|
|
150
|
+
process.exit(1);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const llm = createOpenClawAdapter(process.env.OPENAI_API_KEY);
|
|
154
|
+
if (!llm) {
|
|
155
|
+
console.error('OpenClaw config not found or model unresolved. Run openclaw onboard first.');
|
|
156
|
+
process.exit(1);
|
|
157
|
+
}
|
|
158
|
+
const content = readFileSync(inputPath, 'utf-8');
|
|
159
|
+
const rules = content
|
|
160
|
+
.split('\n')
|
|
161
|
+
.filter((l) => l.trim().startsWith('-'))
|
|
162
|
+
.map((l) => l.replace(/^-\s*["']?|["']?$/g, '').trim());
|
|
163
|
+
const { rules: compiled } = await compile(rules, llm);
|
|
164
|
+
writeFileSync(outputPath, JSON.stringify({ rules: compiled }, null, 2));
|
|
165
|
+
console.log(`Compiled ${compiled.length} rules to ${outputPath}`);
|
|
166
|
+
};
|
|
167
|
+
apiAny.registerCli((opts) => {
|
|
168
|
+
const ap = opts.program.command('ai-permissions');
|
|
169
|
+
ap.command('compile [input] [output]').action((a, b) => {
|
|
170
|
+
void compileHandler(a, b);
|
|
171
|
+
});
|
|
172
|
+
}, { commands: ['ai-permissions'] });
|
|
173
|
+
}
|
|
174
|
+
logger.info('[ai-permissions-openclaw] Plugin loaded - tool call interception active');
|
|
175
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "ai-permissions-openclaw",
|
|
3
|
+
"name": "AI Permissions Layer",
|
|
4
|
+
"configSchema": {
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"properties": {
|
|
8
|
+
"rulesPath": {
|
|
9
|
+
"type": "string",
|
|
10
|
+
"default": "~/.openclaw/ai-permissions-rules.json",
|
|
11
|
+
"description": "Path to compiled rules JSON file"
|
|
12
|
+
},
|
|
13
|
+
"defaultWhenNoMatch": {
|
|
14
|
+
"type": "string",
|
|
15
|
+
"enum": [
|
|
16
|
+
"allow",
|
|
17
|
+
"require_approval",
|
|
18
|
+
"block"
|
|
19
|
+
],
|
|
20
|
+
"default": "require_approval",
|
|
21
|
+
"description": "Behavior when no rule matches: allow (permit), require_approval (ask), block (deny)"
|
|
22
|
+
},
|
|
23
|
+
"pathProtection": {
|
|
24
|
+
"type": "object",
|
|
25
|
+
"properties": {
|
|
26
|
+
"enabled": {
|
|
27
|
+
"type": "boolean",
|
|
28
|
+
"default": true
|
|
29
|
+
},
|
|
30
|
+
"dangerousTools": {
|
|
31
|
+
"type": "array",
|
|
32
|
+
"items": {
|
|
33
|
+
"type": "string"
|
|
34
|
+
},
|
|
35
|
+
"description": "Tool names that can write files (default: write, edit, apply_patch)"
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"default": {
|
|
39
|
+
"enabled": true
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "daniel-ai-permissions-openclaw",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "AI Permissions Layer plugin for OpenClaw. Intercepts tool calls, applies rules, approval flow via chat.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"openclaw": {
|
|
8
|
+
"extensions": [
|
|
9
|
+
"./dist/index.js"
|
|
10
|
+
]
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"build": "tsc",
|
|
14
|
+
"prepublishOnly": "npm run build"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist",
|
|
18
|
+
"openclaw.plugin.json"
|
|
19
|
+
],
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "https://github.com/wei292224644/my-ai-permissions-layer.git",
|
|
23
|
+
"directory": "openclaw-plugin"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"openclaw",
|
|
27
|
+
"ai",
|
|
28
|
+
"permissions",
|
|
29
|
+
"plugin"
|
|
30
|
+
],
|
|
31
|
+
"license": "GPL-3.0-or-later",
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"openclaw": ">=2026.1.0"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"daniel-ai-permissions-layer": "^0.2.1"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"openclaw": "latest",
|
|
40
|
+
"typescript": "^5.9.3"
|
|
41
|
+
}
|
|
42
|
+
}
|