@agentapprove/openclaw 0.1.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 +13 -0
- package/README.md +77 -0
- package/dist/index.js +468 -0
- package/openclaw.plugin.json +49 -0
- package/package.json +43 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Copyright (c) 2026 Agent Approve LLC. All rights reserved.
|
|
2
|
+
|
|
3
|
+
This software is proprietary and confidential. Unauthorized copying,
|
|
4
|
+
modification, distribution, or use of this software, via any medium,
|
|
5
|
+
is strictly prohibited without the express written permission of
|
|
6
|
+
Agent Approve LLC.
|
|
7
|
+
|
|
8
|
+
Use of this software is subject to the Agent Approve Terms of Service:
|
|
9
|
+
https://www.agentapprove.com/terms
|
|
10
|
+
|
|
11
|
+
Privacy Policy: https://www.agentapprove.com/privacy
|
|
12
|
+
|
|
13
|
+
For licensing inquiries, contact: hello@agentapprove.com
|
package/README.md
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# @agentapprove/openclaw
|
|
2
|
+
|
|
3
|
+
[Agent Approve](https://agentapprove.com) plugin for [OpenClaw](https://openclaw.ai). Approve or deny AI agent tool calls from your iPhone and Apple Watch.
|
|
4
|
+
|
|
5
|
+
## Why Agent Approve?
|
|
6
|
+
|
|
7
|
+
- **Visibility** -- see what your OpenClaw agent is doing in real time from your mobile device
|
|
8
|
+
- **Centralized policy** -- define your default stance (allow, deny, or ask) and manage custom allowlists and denylists for tools, parameters, and commands
|
|
9
|
+
- **Mobile approval** -- when human approval is required, review and respond from your iPhone or Apple Watch with a tap
|
|
10
|
+
|
|
11
|
+
You decide the rules. Agent Approve enforces them.
|
|
12
|
+
|
|
13
|
+
## How it works
|
|
14
|
+
|
|
15
|
+
This plugin hooks into OpenClaw's agent loop to enforce your approval policy:
|
|
16
|
+
|
|
17
|
+
- **before_tool_call** -- evaluates each tool call against your policy. Auto-allows or auto-denies based on your lists, or sends an approval request to your device when manual review is needed.
|
|
18
|
+
- **after_tool_call** -- logs tool completion to your activity feed
|
|
19
|
+
- **command/message events** -- monitors session activity
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
openclaw plugins install @agentapprove/openclaw
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Setup
|
|
28
|
+
|
|
29
|
+
1. **Download Agent Approve** from the [App Store](https://agentapprove.com) and start your 7-day free trial
|
|
30
|
+
2. **Follow the in-app onboarding** which will prompt you to run the installer on each machine where you have agents:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
npx agentapprove
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The installer handles pairing, token setup, and plugin activation. Select OpenClaw when prompted for which agents to configure.
|
|
37
|
+
|
|
38
|
+
3. **Restart the OpenClaw gateway** to load the plugin
|
|
39
|
+
|
|
40
|
+
That's it. Tool calls will now be evaluated against your policy, and approval requests will appear on your paired device when needed.
|
|
41
|
+
|
|
42
|
+
## Configuration
|
|
43
|
+
|
|
44
|
+
Plugin settings in `~/.openclaw/openclaw.json`:
|
|
45
|
+
|
|
46
|
+
```json
|
|
47
|
+
{
|
|
48
|
+
"plugins": {
|
|
49
|
+
"entries": {
|
|
50
|
+
"agentapprove": {
|
|
51
|
+
"enabled": true,
|
|
52
|
+
"config": {
|
|
53
|
+
"timeout": 300,
|
|
54
|
+
"failBehavior": "ask",
|
|
55
|
+
"privacyTier": "full"
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
| Setting | Default | Description |
|
|
64
|
+
|---------|---------|-------------|
|
|
65
|
+
| `timeout` | `300` | Seconds to wait for an approval response |
|
|
66
|
+
| `failBehavior` | `ask` | Default policy when API is unreachable: `allow`, `deny`, or `ask` |
|
|
67
|
+
| `privacyTier` | `full` | What tool data is stored in event logs: `minimal`, `summary`, or `full` |
|
|
68
|
+
| `debug` | `false` | Write debug logs to `~/.agentapprove/hook-debug.log` |
|
|
69
|
+
|
|
70
|
+
## Supported agents
|
|
71
|
+
|
|
72
|
+
Agent Approve works with OpenClaw, Claude Code, Cursor, Gemini CLI, VS Code Agent, Copilot CLI, OpenAI Codex (coming soon), and more. Run `npx agentapprove` to configure multiple agents at once.
|
|
73
|
+
|
|
74
|
+
## Links
|
|
75
|
+
|
|
76
|
+
- [Agent Approve](https://agentapprove.com)
|
|
77
|
+
- [OpenClaw](https://openclaw.ai)
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { fileURLToPath } from "url";
|
|
3
|
+
|
|
4
|
+
// src/config.ts
|
|
5
|
+
import { readFileSync, existsSync, statSync } from "fs";
|
|
6
|
+
import { join } from "path";
|
|
7
|
+
import { homedir } from "os";
|
|
8
|
+
import { execSync } from "child_process";
|
|
9
|
+
var CONFIG_PATH = join(homedir(), ".agentapprove", "env");
|
|
10
|
+
var KEYCHAIN_SERVICE = "com.agentapprove";
|
|
11
|
+
var KEYCHAIN_ACCOUNT = "api-token";
|
|
12
|
+
function parseConfigValue(content, key) {
|
|
13
|
+
const lines = content.split("\n");
|
|
14
|
+
let value;
|
|
15
|
+
for (const raw of lines) {
|
|
16
|
+
const line = raw.replace(/^export\s+/, "").trim();
|
|
17
|
+
if (!line.startsWith(`${key}=`)) continue;
|
|
18
|
+
let v = line.slice(key.length + 1);
|
|
19
|
+
if (v.startsWith('"') && v.endsWith('"') || v.startsWith("'") && v.endsWith("'")) {
|
|
20
|
+
v = v.slice(1, -1);
|
|
21
|
+
}
|
|
22
|
+
if (/[`$(){};<>|&!\\]/.test(v)) continue;
|
|
23
|
+
value = v;
|
|
24
|
+
}
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
function normalizeApiUrl(url) {
|
|
28
|
+
let result = url.trim();
|
|
29
|
+
while (result.endsWith("/")) {
|
|
30
|
+
result = result.slice(0, -1);
|
|
31
|
+
}
|
|
32
|
+
return result;
|
|
33
|
+
}
|
|
34
|
+
function normalizeApiVersion(version) {
|
|
35
|
+
const cleaned = version.trim().replace(/^\/+/, "").replace(/\/+$/, "");
|
|
36
|
+
return cleaned || "v001";
|
|
37
|
+
}
|
|
38
|
+
function getKeychainToken() {
|
|
39
|
+
if (process.platform !== "darwin") return void 0;
|
|
40
|
+
try {
|
|
41
|
+
const result = execSync(
|
|
42
|
+
`security find-generic-password -s "${KEYCHAIN_SERVICE}" -a "${KEYCHAIN_ACCOUNT}" -w 2>/dev/null`,
|
|
43
|
+
{ encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }
|
|
44
|
+
).trim();
|
|
45
|
+
return result || void 0;
|
|
46
|
+
} catch {
|
|
47
|
+
return void 0;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function checkPermissions(logger) {
|
|
51
|
+
if (!existsSync(CONFIG_PATH)) return;
|
|
52
|
+
try {
|
|
53
|
+
const stat = statSync(CONFIG_PATH);
|
|
54
|
+
const mode = stat.mode & 511;
|
|
55
|
+
if (mode & 54) {
|
|
56
|
+
logger?.warn(
|
|
57
|
+
`Config file ${CONFIG_PATH} is group or world readable/writable. Run: chmod 600 ${CONFIG_PATH}`
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
} catch {
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function loadConfig(openclawConfig, logger) {
|
|
64
|
+
checkPermissions(logger);
|
|
65
|
+
let fileContent = "";
|
|
66
|
+
if (existsSync(CONFIG_PATH)) {
|
|
67
|
+
fileContent = readFileSync(CONFIG_PATH, "utf-8");
|
|
68
|
+
}
|
|
69
|
+
let token = process.env.AGENTAPPROVE_TOKEN || getKeychainToken() || parseConfigValue(fileContent, "AGENTAPPROVE_TOKEN") || "";
|
|
70
|
+
if (!token) {
|
|
71
|
+
logger?.warn("No Agent Approve token found. Run the Agent Approve installer to set up.");
|
|
72
|
+
}
|
|
73
|
+
const rawApiUrl = openclawConfig?.apiUrl || process.env.AGENTAPPROVE_API || parseConfigValue(fileContent, "AGENTAPPROVE_API") || "https://api.agentapprove.com";
|
|
74
|
+
const rawApiVersion = process.env.AGENTAPPROVE_API_VERSION || parseConfigValue(fileContent, "AGENTAPPROVE_API_VERSION") || "v001";
|
|
75
|
+
const apiUrl = normalizeApiUrl(rawApiUrl);
|
|
76
|
+
const apiVersion = normalizeApiVersion(rawApiVersion);
|
|
77
|
+
const timeout = openclawConfig?.timeout || parseInt(process.env.AGENTAPPROVE_TIMEOUT || "", 10) || parseInt(parseConfigValue(fileContent, "AGENTAPPROVE_TIMEOUT") || "", 10) || 300;
|
|
78
|
+
const failBehavior = openclawConfig?.failBehavior || process.env.AGENTAPPROVE_FAIL_BEHAVIOR || parseConfigValue(fileContent, "AGENTAPPROVE_FAIL_BEHAVIOR") || "ask";
|
|
79
|
+
const privacyTier = openclawConfig?.privacyTier || process.env.AGENTAPPROVE_PRIVACY || parseConfigValue(fileContent, "AGENTAPPROVE_PRIVACY") || "full";
|
|
80
|
+
const debug = openclawConfig?.debug || process.env.AGENTAPPROVE_DEBUG === "true" || parseConfigValue(fileContent, "AGENTAPPROVE_DEBUG") === "true" || false;
|
|
81
|
+
const agentName = process.env.AGENTAPPROVE_AGENT_NAME || parseConfigValue(fileContent, "AGENTAPPROVE_OPENCLAW_NAME") || "OpenClaw";
|
|
82
|
+
const e2eEnabled = parseConfigValue(fileContent, "AGENTAPPROVE_E2E_ENABLED") === "true";
|
|
83
|
+
const e2eKeyPath = join(homedir(), ".agentapprove", "e2e-key");
|
|
84
|
+
let e2eUserKey;
|
|
85
|
+
let e2eServerKey;
|
|
86
|
+
if (e2eEnabled && existsSync(e2eKeyPath)) {
|
|
87
|
+
try {
|
|
88
|
+
const keyData = JSON.parse(readFileSync(e2eKeyPath, "utf-8"));
|
|
89
|
+
e2eUserKey = keyData.userKey;
|
|
90
|
+
e2eServerKey = keyData.serverKey;
|
|
91
|
+
} catch {
|
|
92
|
+
logger?.warn("Failed to load E2E encryption keys");
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
apiUrl,
|
|
97
|
+
apiVersion,
|
|
98
|
+
token,
|
|
99
|
+
timeout,
|
|
100
|
+
failBehavior,
|
|
101
|
+
privacyTier,
|
|
102
|
+
debug,
|
|
103
|
+
hookVersion: "1.1.0",
|
|
104
|
+
agentName,
|
|
105
|
+
e2eEnabled,
|
|
106
|
+
e2eUserKey,
|
|
107
|
+
e2eServerKey
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// src/api-client.ts
|
|
112
|
+
import { request as httpsRequest } from "https";
|
|
113
|
+
import { request as httpRequest } from "http";
|
|
114
|
+
import { URL } from "url";
|
|
115
|
+
|
|
116
|
+
// src/hmac.ts
|
|
117
|
+
import crypto from "crypto";
|
|
118
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
119
|
+
function generateHMACSignature(body, token, hookVersion) {
|
|
120
|
+
const timestamp = Math.floor(Date.now() / 1e3);
|
|
121
|
+
const message = `${hookVersion}:${timestamp}:${body}`;
|
|
122
|
+
const signature = crypto.createHmac("sha256", token).update(message).digest("hex");
|
|
123
|
+
return { timestamp, signature };
|
|
124
|
+
}
|
|
125
|
+
function computePluginHash(pluginPath) {
|
|
126
|
+
try {
|
|
127
|
+
const content = readFileSync2(pluginPath, "utf-8");
|
|
128
|
+
return crypto.createHash("sha256").update(content).digest("hex");
|
|
129
|
+
} catch {
|
|
130
|
+
return "";
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
function buildHMACHeaders(body, token, hookVersion, pluginHash) {
|
|
134
|
+
const { timestamp, signature } = generateHMACSignature(body, token, hookVersion);
|
|
135
|
+
return {
|
|
136
|
+
"X-Hook-Version": hookVersion,
|
|
137
|
+
"X-Hook-Timestamp": String(timestamp),
|
|
138
|
+
"X-Hook-Signature": signature,
|
|
139
|
+
"X-Hook-Hash": pluginHash
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// src/privacy.ts
|
|
144
|
+
var SUMMARY_MAX_LENGTH = 50;
|
|
145
|
+
function applyPrivacyFilter(request, privacyTier) {
|
|
146
|
+
if (privacyTier === "full") {
|
|
147
|
+
return request;
|
|
148
|
+
}
|
|
149
|
+
const filtered = { ...request };
|
|
150
|
+
if (privacyTier === "minimal") {
|
|
151
|
+
delete filtered.command;
|
|
152
|
+
filtered.toolInput = void 0;
|
|
153
|
+
delete filtered.cwd;
|
|
154
|
+
return filtered;
|
|
155
|
+
}
|
|
156
|
+
if (filtered.command && filtered.command.length > SUMMARY_MAX_LENGTH) {
|
|
157
|
+
filtered.command = filtered.command.slice(0, SUMMARY_MAX_LENGTH) + "...";
|
|
158
|
+
}
|
|
159
|
+
if (filtered.toolInput) {
|
|
160
|
+
const inputStr = JSON.stringify(filtered.toolInput);
|
|
161
|
+
if (inputStr.length > SUMMARY_MAX_LENGTH) {
|
|
162
|
+
filtered.toolInput = { _summary: inputStr.slice(0, SUMMARY_MAX_LENGTH) + "..." };
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return filtered;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// src/debug.ts
|
|
169
|
+
import { appendFileSync, existsSync as existsSync2, mkdirSync, statSync as statSync2, readFileSync as readFileSync3, writeFileSync } from "fs";
|
|
170
|
+
import { join as join2, dirname } from "path";
|
|
171
|
+
import { homedir as homedir2 } from "os";
|
|
172
|
+
var DEBUG_LOG_PATH = join2(homedir2(), ".agentapprove", "hook-debug.log");
|
|
173
|
+
var MAX_SIZE = 5 * 1024 * 1024;
|
|
174
|
+
var KEEP_SIZE = 2 * 1024 * 1024;
|
|
175
|
+
function ensureLogFile() {
|
|
176
|
+
const dir = dirname(DEBUG_LOG_PATH);
|
|
177
|
+
if (!existsSync2(dir)) {
|
|
178
|
+
mkdirSync(dir, { recursive: true });
|
|
179
|
+
}
|
|
180
|
+
if (!existsSync2(DEBUG_LOG_PATH)) {
|
|
181
|
+
writeFileSync(DEBUG_LOG_PATH, "", { mode: 384 });
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
try {
|
|
185
|
+
const stat = statSync2(DEBUG_LOG_PATH);
|
|
186
|
+
if (stat.size > MAX_SIZE) {
|
|
187
|
+
const content = readFileSync3(DEBUG_LOG_PATH, "utf-8");
|
|
188
|
+
writeFileSync(DEBUG_LOG_PATH, content.slice(-KEEP_SIZE), { mode: 384 });
|
|
189
|
+
const ts = (/* @__PURE__ */ new Date()).toISOString().replace("T", " ").slice(0, 19);
|
|
190
|
+
appendFileSync(DEBUG_LOG_PATH, `[${ts}] [openclaw-plugin] Log rotated (exceeded 5MB)
|
|
191
|
+
`);
|
|
192
|
+
}
|
|
193
|
+
} catch {
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
function debugLog(message, hookName = "openclaw-plugin") {
|
|
197
|
+
try {
|
|
198
|
+
ensureLogFile();
|
|
199
|
+
const ts = (/* @__PURE__ */ new Date()).toISOString().replace("T", " ").slice(0, 19);
|
|
200
|
+
appendFileSync(DEBUG_LOG_PATH, `[${ts}] [${hookName}] ${message}
|
|
201
|
+
`);
|
|
202
|
+
} catch {
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// src/api-client.ts
|
|
207
|
+
var cachedPluginHash;
|
|
208
|
+
function getPluginHash(pluginPath) {
|
|
209
|
+
if (!cachedPluginHash) {
|
|
210
|
+
cachedPluginHash = computePluginHash(pluginPath);
|
|
211
|
+
if (cachedPluginHash) {
|
|
212
|
+
debugLog(`Plugin hash computed: ${cachedPluginHash.slice(0, 16)}...`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return cachedPluginHash || "";
|
|
216
|
+
}
|
|
217
|
+
function httpPost(url, body, headers, timeoutMs) {
|
|
218
|
+
return new Promise((resolve, reject) => {
|
|
219
|
+
const parsed = new URL(url);
|
|
220
|
+
const isHttps = parsed.protocol === "https:";
|
|
221
|
+
const reqFn = isHttps ? httpsRequest : httpRequest;
|
|
222
|
+
const req = reqFn(
|
|
223
|
+
{
|
|
224
|
+
hostname: parsed.hostname,
|
|
225
|
+
port: parsed.port || (isHttps ? 443 : 80),
|
|
226
|
+
path: parsed.pathname + parsed.search,
|
|
227
|
+
method: "POST",
|
|
228
|
+
headers: {
|
|
229
|
+
"Content-Type": "application/json",
|
|
230
|
+
"Content-Length": Buffer.byteLength(body),
|
|
231
|
+
...headers
|
|
232
|
+
},
|
|
233
|
+
timeout: timeoutMs
|
|
234
|
+
},
|
|
235
|
+
(res) => {
|
|
236
|
+
let data = "";
|
|
237
|
+
res.on("data", (chunk) => {
|
|
238
|
+
data += chunk;
|
|
239
|
+
});
|
|
240
|
+
res.on("end", () => {
|
|
241
|
+
resolve({ status: res.statusCode || 0, body: data });
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
);
|
|
245
|
+
req.on("error", reject);
|
|
246
|
+
req.on("timeout", () => {
|
|
247
|
+
req.destroy();
|
|
248
|
+
reject(new Error(`Request timed out after ${timeoutMs}ms`));
|
|
249
|
+
});
|
|
250
|
+
req.write(body);
|
|
251
|
+
req.end();
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
async function sendApprovalRequest(request, config, pluginPath) {
|
|
255
|
+
if (!config.token) {
|
|
256
|
+
throw new Error("No Agent Approve token configured");
|
|
257
|
+
}
|
|
258
|
+
const filtered = applyPrivacyFilter(request, config.privacyTier);
|
|
259
|
+
const bodyStr = JSON.stringify(filtered);
|
|
260
|
+
const pluginHash = getPluginHash(pluginPath);
|
|
261
|
+
const hmacHeaders = buildHMACHeaders(bodyStr, config.token, config.hookVersion, pluginHash);
|
|
262
|
+
const headers = {
|
|
263
|
+
"Authorization": `Bearer ${config.token}`,
|
|
264
|
+
...hmacHeaders
|
|
265
|
+
};
|
|
266
|
+
const url = `${config.apiUrl}/${config.apiVersion}/approve`;
|
|
267
|
+
if (config.debug) {
|
|
268
|
+
debugLog(`Sending approval request to ${url} for tool: ${request.toolName}`);
|
|
269
|
+
}
|
|
270
|
+
const response = await httpPost(url, bodyStr, headers, config.timeout * 1e3);
|
|
271
|
+
if (config.debug) {
|
|
272
|
+
debugLog(`Response status: ${response.status}, body: ${response.body.slice(0, 200)}`);
|
|
273
|
+
}
|
|
274
|
+
if (response.status !== 200) {
|
|
275
|
+
throw new Error(`API returned status ${response.status}: ${response.body.slice(0, 200)}`);
|
|
276
|
+
}
|
|
277
|
+
try {
|
|
278
|
+
return JSON.parse(response.body);
|
|
279
|
+
} catch {
|
|
280
|
+
throw new Error(`Failed to parse API response: ${response.body.slice(0, 200)}`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
async function sendEvent(event, config, pluginPath) {
|
|
284
|
+
if (!config.token) return;
|
|
285
|
+
try {
|
|
286
|
+
const bodyStr = JSON.stringify(event);
|
|
287
|
+
const pluginHash = getPluginHash(pluginPath);
|
|
288
|
+
const hmacHeaders = buildHMACHeaders(bodyStr, config.token, config.hookVersion, pluginHash);
|
|
289
|
+
const headers = {
|
|
290
|
+
"Authorization": `Bearer ${config.token}`,
|
|
291
|
+
...hmacHeaders
|
|
292
|
+
};
|
|
293
|
+
const url = `${config.apiUrl}/${config.apiVersion}/events`;
|
|
294
|
+
await httpPost(url, bodyStr, headers, 5e3);
|
|
295
|
+
} catch {
|
|
296
|
+
if (config.debug) {
|
|
297
|
+
debugLog(`Failed to send event: ${JSON.stringify(event).slice(0, 100)}`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// src/index.ts
|
|
303
|
+
var pluginFilePath;
|
|
304
|
+
try {
|
|
305
|
+
pluginFilePath = fileURLToPath(import.meta.url);
|
|
306
|
+
} catch {
|
|
307
|
+
pluginFilePath = __filename;
|
|
308
|
+
}
|
|
309
|
+
function classifyTool(toolName) {
|
|
310
|
+
const lower = toolName.toLowerCase();
|
|
311
|
+
if (lower === "exec" || lower === "process") {
|
|
312
|
+
return { toolType: "shell_command", displayName: toolName };
|
|
313
|
+
}
|
|
314
|
+
if (lower === "write" || lower === "edit" || lower === "apply_patch") {
|
|
315
|
+
return { toolType: "file_write", displayName: toolName };
|
|
316
|
+
}
|
|
317
|
+
if (lower === "read") {
|
|
318
|
+
return { toolType: "file_read", displayName: toolName };
|
|
319
|
+
}
|
|
320
|
+
if (lower === "browser" || lower.startsWith("browser_")) {
|
|
321
|
+
return { toolType: "browser", displayName: toolName };
|
|
322
|
+
}
|
|
323
|
+
if (lower === "message" || lower === "agent_send") {
|
|
324
|
+
return { toolType: "message", displayName: toolName };
|
|
325
|
+
}
|
|
326
|
+
if (lower === "sessions_spawn" || lower === "sessions_send") {
|
|
327
|
+
return { toolType: "session", displayName: toolName };
|
|
328
|
+
}
|
|
329
|
+
if (lower === "llm_task") {
|
|
330
|
+
return { toolType: "llm", displayName: toolName };
|
|
331
|
+
}
|
|
332
|
+
return { toolType: "tool_use", displayName: toolName };
|
|
333
|
+
}
|
|
334
|
+
function extractCommand(toolName, params) {
|
|
335
|
+
const lower = toolName.toLowerCase();
|
|
336
|
+
if (lower === "exec" || lower === "process") {
|
|
337
|
+
return params.command || void 0;
|
|
338
|
+
}
|
|
339
|
+
if (lower === "write" || lower === "edit") {
|
|
340
|
+
return params.file_path || params.path || void 0;
|
|
341
|
+
}
|
|
342
|
+
if (lower === "read") {
|
|
343
|
+
return params.file_path || params.path || void 0;
|
|
344
|
+
}
|
|
345
|
+
if (lower === "apply_patch") {
|
|
346
|
+
return "apply_patch";
|
|
347
|
+
}
|
|
348
|
+
return void 0;
|
|
349
|
+
}
|
|
350
|
+
function handleFailBehavior(config, error, toolName, logger) {
|
|
351
|
+
logger.warn(`Agent Approve API error for tool "${toolName}": ${error.message}`);
|
|
352
|
+
debugLog(`API error: ${error.message}, failBehavior: ${config.failBehavior}`);
|
|
353
|
+
switch (config.failBehavior) {
|
|
354
|
+
case "deny":
|
|
355
|
+
return { block: true, blockReason: "Agent Approve unavailable, denying by policy" };
|
|
356
|
+
case "allow":
|
|
357
|
+
return void 0;
|
|
358
|
+
// Don't block
|
|
359
|
+
case "ask":
|
|
360
|
+
default:
|
|
361
|
+
return void 0;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
function register(api) {
|
|
365
|
+
const config = loadConfig(api.pluginConfig, api.logger);
|
|
366
|
+
if (!config.token) {
|
|
367
|
+
api.logger.warn(
|
|
368
|
+
"Agent Approve: No token found. Run the Agent Approve installer to pair with your account."
|
|
369
|
+
);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
api.logger.info(`Agent Approve: Plugin loaded (privacy: ${config.privacyTier}, fail: ${config.failBehavior})`);
|
|
373
|
+
debugLog(`Plugin loaded, API: ${config.apiUrl}, agent: ${config.agentName}`);
|
|
374
|
+
api.on("before_tool_call", async (event, ctx) => {
|
|
375
|
+
const { toolType, displayName } = classifyTool(event.toolName);
|
|
376
|
+
const command = extractCommand(event.toolName, event.params);
|
|
377
|
+
const request = {
|
|
378
|
+
toolName: displayName,
|
|
379
|
+
toolType,
|
|
380
|
+
command,
|
|
381
|
+
toolInput: event.params,
|
|
382
|
+
agent: config.agentName,
|
|
383
|
+
hookType: "before_tool_call",
|
|
384
|
+
cwd: event.params.workdir || void 0,
|
|
385
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
386
|
+
};
|
|
387
|
+
try {
|
|
388
|
+
const response = await sendApprovalRequest(request, config, pluginFilePath);
|
|
389
|
+
if (response.decision === "approve") {
|
|
390
|
+
debugLog(`Tool "${event.toolName}" approved${response.reason ? ": " + response.reason : ""}`);
|
|
391
|
+
return void 0;
|
|
392
|
+
}
|
|
393
|
+
debugLog(`Tool "${event.toolName}" denied${response.reason ? ": " + response.reason : ""}`);
|
|
394
|
+
return {
|
|
395
|
+
block: true,
|
|
396
|
+
blockReason: response.reason || "Denied by Agent Approve"
|
|
397
|
+
};
|
|
398
|
+
} catch (error) {
|
|
399
|
+
return handleFailBehavior(
|
|
400
|
+
config,
|
|
401
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
402
|
+
event.toolName,
|
|
403
|
+
api.logger
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
});
|
|
407
|
+
api.on("after_tool_call", async (event, _ctx) => {
|
|
408
|
+
const { toolType } = classifyTool(event.toolName);
|
|
409
|
+
void sendEvent({
|
|
410
|
+
toolName: event.toolName,
|
|
411
|
+
toolType,
|
|
412
|
+
agent: config.agentName,
|
|
413
|
+
hookType: "after_tool_call",
|
|
414
|
+
status: event.error ? "error" : "success",
|
|
415
|
+
error: event.error,
|
|
416
|
+
durationMs: event.durationMs,
|
|
417
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
418
|
+
}, config, pluginFilePath);
|
|
419
|
+
});
|
|
420
|
+
api.registerHook(
|
|
421
|
+
["command:new", "command:stop", "command:reset"],
|
|
422
|
+
async (event) => {
|
|
423
|
+
void sendEvent({
|
|
424
|
+
toolName: `command:${event.action}`,
|
|
425
|
+
toolType: "command",
|
|
426
|
+
agent: config.agentName,
|
|
427
|
+
hookType: "command_event",
|
|
428
|
+
timestamp: event.timestamp.toISOString(),
|
|
429
|
+
metadata: { sessionKey: event.sessionKey, action: event.action }
|
|
430
|
+
}, config, pluginFilePath);
|
|
431
|
+
},
|
|
432
|
+
{ name: "agentapprove-command-monitor", description: "Log command events to Agent Approve" }
|
|
433
|
+
);
|
|
434
|
+
api.registerHook(
|
|
435
|
+
["message:received", "message:sent"],
|
|
436
|
+
async (event) => {
|
|
437
|
+
const direction = event.action === "received" ? "inbound" : "outbound";
|
|
438
|
+
const payload = {
|
|
439
|
+
toolName: `message:${event.action}`,
|
|
440
|
+
toolType: "message_event",
|
|
441
|
+
agent: config.agentName,
|
|
442
|
+
hookType: "session_event",
|
|
443
|
+
timestamp: event.timestamp.toISOString(),
|
|
444
|
+
metadata: {
|
|
445
|
+
direction,
|
|
446
|
+
channelId: event.context.channelId,
|
|
447
|
+
sessionKey: event.sessionKey
|
|
448
|
+
}
|
|
449
|
+
};
|
|
450
|
+
if (config.privacyTier === "full" && event.context.content) {
|
|
451
|
+
payload.metadata.contentPreview = event.context.content.slice(0, 100);
|
|
452
|
+
}
|
|
453
|
+
void sendEvent(payload, config, pluginFilePath);
|
|
454
|
+
},
|
|
455
|
+
{ name: "agentapprove-session-monitor", description: "Log message events to Agent Approve" }
|
|
456
|
+
);
|
|
457
|
+
api.logger.info("Agent Approve: Registered before_tool_call, after_tool_call, and event monitoring hooks");
|
|
458
|
+
}
|
|
459
|
+
var index_default = {
|
|
460
|
+
id: "agentapprove",
|
|
461
|
+
name: "Agent Approve",
|
|
462
|
+
description: "Mobile approval for AI agent tool execution",
|
|
463
|
+
register
|
|
464
|
+
};
|
|
465
|
+
export {
|
|
466
|
+
index_default as default,
|
|
467
|
+
register
|
|
468
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "agentapprove",
|
|
3
|
+
"name": "Agent Approve",
|
|
4
|
+
"description": "Mobile approval for AI agent tool execution. Approve or deny tool calls from your iPhone and Apple Watch.",
|
|
5
|
+
"version": "0.1.0",
|
|
6
|
+
"homepage": "https://agentapprove.com",
|
|
7
|
+
"configSchema": {
|
|
8
|
+
"type": "object",
|
|
9
|
+
"additionalProperties": false,
|
|
10
|
+
"properties": {
|
|
11
|
+
"apiUrl": {
|
|
12
|
+
"type": "string",
|
|
13
|
+
"description": "Agent Approve API URL",
|
|
14
|
+
"default": "https://api.agentapprove.com"
|
|
15
|
+
},
|
|
16
|
+
"timeout": {
|
|
17
|
+
"type": "number",
|
|
18
|
+
"description": "Max seconds to wait for approval response",
|
|
19
|
+
"default": 300,
|
|
20
|
+
"minimum": 10,
|
|
21
|
+
"maximum": 600
|
|
22
|
+
},
|
|
23
|
+
"failBehavior": {
|
|
24
|
+
"type": "string",
|
|
25
|
+
"description": "Action when API is unreachable",
|
|
26
|
+
"enum": ["allow", "deny", "ask"],
|
|
27
|
+
"default": "ask"
|
|
28
|
+
},
|
|
29
|
+
"privacyTier": {
|
|
30
|
+
"type": "string",
|
|
31
|
+
"description": "What tool data to send to the API",
|
|
32
|
+
"enum": ["minimal", "summary", "full"],
|
|
33
|
+
"default": "full"
|
|
34
|
+
},
|
|
35
|
+
"debug": {
|
|
36
|
+
"type": "boolean",
|
|
37
|
+
"description": "Enable debug logging to ~/.agentapprove/hook-debug.log",
|
|
38
|
+
"default": false
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
"uiHints": {
|
|
43
|
+
"apiUrl": { "label": "API URL", "placeholder": "https://api.agentapprove.com" },
|
|
44
|
+
"timeout": { "label": "Approval Timeout (seconds)" },
|
|
45
|
+
"failBehavior": { "label": "On API Error" },
|
|
46
|
+
"privacyTier": { "label": "Privacy Tier" },
|
|
47
|
+
"debug": { "label": "Debug Logging" }
|
|
48
|
+
}
|
|
49
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@agentapprove/openclaw",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Agent Approve plugin for OpenClaw - approve or deny AI agent tool calls from your iPhone and Apple Watch",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"build": "esbuild src/index.ts --bundle --platform=node --target=node18 --outfile=dist/index.js --format=esm --external:crypto --external:fs --external:path --external:os --external:https --external:http --external:url --external:child_process",
|
|
8
|
+
"hash": "shasum -a 256 dist/index.js | cut -d' ' -f1",
|
|
9
|
+
"dev": "esbuild src/index.ts --bundle --platform=node --target=node18 --outfile=dist/index.js --format=esm --external:crypto --external:fs --external:path --external:os --external:https --external:http --external:url --external:child_process --watch"
|
|
10
|
+
},
|
|
11
|
+
"openclaw": {
|
|
12
|
+
"extensions": ["./dist/index.js"]
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist/",
|
|
16
|
+
"openclaw.plugin.json",
|
|
17
|
+
"README.md"
|
|
18
|
+
],
|
|
19
|
+
"keywords": [
|
|
20
|
+
"openclaw",
|
|
21
|
+
"agentapprove",
|
|
22
|
+
"agent-approve",
|
|
23
|
+
"approval",
|
|
24
|
+
"governance",
|
|
25
|
+
"ai-safety",
|
|
26
|
+
"iphone",
|
|
27
|
+
"apple-watch",
|
|
28
|
+
"tool-approval"
|
|
29
|
+
],
|
|
30
|
+
"author": "Agent Approve LLC <hello@agentapprove.com> (https://agentapprove.com)",
|
|
31
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
32
|
+
"homepage": "https://www.agentapprove.com",
|
|
33
|
+
"bugs": {
|
|
34
|
+
"url": "https://github.com/agentapprove/support/issues"
|
|
35
|
+
},
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=18"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"esbuild": "^0.24.0",
|
|
41
|
+
"typescript": "^5.0.0"
|
|
42
|
+
}
|
|
43
|
+
}
|