@agentapprove/openclaw 0.1.2 → 0.1.4

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/index.js CHANGED
@@ -1,13 +1,228 @@
1
1
  // src/index.ts
2
2
  import { fileURLToPath } from "url";
3
- import { randomBytes } from "crypto";
3
+ import { createHash as createHash2, randomBytes as randomBytes2 } from "crypto";
4
4
 
5
5
  // src/config.ts
6
- import { readFileSync, existsSync, statSync } from "fs";
7
- import { join } from "path";
8
- import { homedir } from "os";
6
+ import { readFileSync as readFileSync3, existsSync as existsSync3, statSync as statSync2 } from "fs";
7
+ import { join as join3 } from "path";
8
+ import { homedir as homedir3 } from "os";
9
9
  import { execSync } from "child_process";
10
- var CONFIG_PATH = join(homedir(), ".agentapprove", "env");
10
+
11
+ // src/e2e-crypto.ts
12
+ import { createHash, createHmac, createCipheriv, randomBytes } from "crypto";
13
+ import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, existsSync as existsSync2, copyFileSync } from "fs";
14
+ import { join as join2 } from "path";
15
+ import { homedir as homedir2 } from "os";
16
+
17
+ // src/debug.ts
18
+ import { appendFileSync, existsSync, mkdirSync, statSync, readFileSync, writeFileSync } from "fs";
19
+ import { join, dirname } from "path";
20
+ import { homedir } from "os";
21
+ var DEBUG_LOG_PATH = join(homedir(), ".agentapprove", "hook-debug.log");
22
+ var MAX_SIZE = 5 * 1024 * 1024;
23
+ var KEEP_SIZE = 2 * 1024 * 1024;
24
+ function ensureLogFile() {
25
+ const dir = dirname(DEBUG_LOG_PATH);
26
+ if (!existsSync(dir)) {
27
+ mkdirSync(dir, { recursive: true });
28
+ }
29
+ if (!existsSync(DEBUG_LOG_PATH)) {
30
+ writeFileSync(DEBUG_LOG_PATH, "", { mode: 384 });
31
+ return;
32
+ }
33
+ try {
34
+ const stat = statSync(DEBUG_LOG_PATH);
35
+ if (stat.size > MAX_SIZE) {
36
+ const content = readFileSync(DEBUG_LOG_PATH, "utf-8");
37
+ writeFileSync(DEBUG_LOG_PATH, content.slice(-KEEP_SIZE), { mode: 384 });
38
+ const ts = (/* @__PURE__ */ new Date()).toISOString().replace("T", " ").slice(0, 19);
39
+ appendFileSync(DEBUG_LOG_PATH, `[${ts}] [openclaw-plugin] Log rotated (exceeded 5MB)
40
+ `);
41
+ }
42
+ } catch {
43
+ }
44
+ }
45
+ function debugLog(message, hookName = "openclaw-plugin") {
46
+ try {
47
+ ensureLogFile();
48
+ const ts = (/* @__PURE__ */ new Date()).toISOString().replace("T", " ").slice(0, 19);
49
+ appendFileSync(DEBUG_LOG_PATH, `[${ts}] [${hookName}] ${message}
50
+ `);
51
+ } catch {
52
+ }
53
+ }
54
+
55
+ // src/e2e-crypto.ts
56
+ function keyId(keyHex) {
57
+ const keyBytes = Buffer.from(keyHex, "hex");
58
+ const hash = createHash("sha256").update(keyBytes).digest();
59
+ return hash.subarray(0, 4).toString("hex");
60
+ }
61
+ function deriveEncKey(keyHex) {
62
+ const prefix = Buffer.from("agentapprove-e2e-enc:");
63
+ const keyBytes = Buffer.from(keyHex, "hex");
64
+ return createHash("sha256").update(Buffer.concat([prefix, keyBytes])).digest();
65
+ }
66
+ function deriveMacKey(keyHex) {
67
+ const prefix = Buffer.from("agentapprove-e2e-mac:");
68
+ const keyBytes = Buffer.from(keyHex, "hex");
69
+ return createHash("sha256").update(Buffer.concat([prefix, keyBytes])).digest();
70
+ }
71
+ function e2eEncrypt(keyHex, plaintext) {
72
+ const kid = keyId(keyHex);
73
+ const encKey = deriveEncKey(keyHex);
74
+ const macKey = deriveMacKey(keyHex);
75
+ const iv = randomBytes(16);
76
+ const cipher = createCipheriv("aes-256-ctr", encKey, iv);
77
+ const ciphertext = Buffer.concat([cipher.update(plaintext, "utf-8"), cipher.final()]);
78
+ const ivHex = iv.toString("hex");
79
+ const ciphertextBase64 = ciphertext.toString("base64");
80
+ const hmac = createHmac("sha256", macKey).update(Buffer.concat([iv, ciphertext])).digest("hex");
81
+ return `E2E:v1:${kid}:${ivHex}:${ciphertextBase64}:${hmac}`;
82
+ }
83
+ function applyApprovalE2E(payload, userKey, serverKey) {
84
+ const sensitiveFields = {};
85
+ for (const field of ["command", "toolInput", "cwd"]) {
86
+ if (payload[field] != null) {
87
+ sensitiveFields[field] = payload[field];
88
+ }
89
+ }
90
+ if (Object.keys(sensitiveFields).length === 0) {
91
+ return payload;
92
+ }
93
+ const sensitiveJson = JSON.stringify(sensitiveFields);
94
+ const result = { ...payload };
95
+ delete result.command;
96
+ delete result.toolInput;
97
+ delete result.cwd;
98
+ const e2e = {
99
+ user: e2eEncrypt(userKey, sensitiveJson)
100
+ };
101
+ if (serverKey) {
102
+ e2e.server = e2eEncrypt(serverKey, sensitiveJson);
103
+ }
104
+ result.e2e = e2e;
105
+ return result;
106
+ }
107
+ function applyEventE2E(payload, userKey) {
108
+ const contentFields = {};
109
+ for (const field of [
110
+ "command",
111
+ "toolInput",
112
+ "response",
113
+ "responsePreview",
114
+ "text",
115
+ "textPreview",
116
+ "prompt",
117
+ "output",
118
+ "cwd"
119
+ ]) {
120
+ if (payload[field] != null) {
121
+ contentFields[field] = payload[field];
122
+ }
123
+ }
124
+ if (Object.keys(contentFields).length === 0) {
125
+ return payload;
126
+ }
127
+ const e2ePayload = e2eEncrypt(userKey, JSON.stringify(contentFields));
128
+ const result = { ...payload };
129
+ delete result.command;
130
+ delete result.toolInput;
131
+ delete result.response;
132
+ delete result.responsePreview;
133
+ delete result.text;
134
+ delete result.textPreview;
135
+ delete result.prompt;
136
+ delete result.output;
137
+ delete result.cwd;
138
+ result.e2ePayload = e2ePayload;
139
+ return result;
140
+ }
141
+ var AA_DIR = join2(homedir2(), ".agentapprove");
142
+ var E2E_KEY_FILE = join2(AA_DIR, "e2e-key");
143
+ var E2E_ROOT_KEY_FILE = join2(AA_DIR, "e2e-root-key");
144
+ var E2E_ROTATION_FILE = join2(AA_DIR, "e2e-rotation.json");
145
+ function rotateE2eKey(oldKeyHex) {
146
+ const prefix = Buffer.from("agentapprove-e2e-rotate:");
147
+ const keyBytes = Buffer.from(oldKeyHex, "hex");
148
+ return createHash("sha256").update(Buffer.concat([prefix, keyBytes])).digest("hex");
149
+ }
150
+ function deriveEpochKey(rootKeyHex, epoch) {
151
+ let current = rootKeyHex;
152
+ for (let i = 0; i < epoch; i++) {
153
+ current = rotateE2eKey(current);
154
+ }
155
+ return current;
156
+ }
157
+ function migrateRootKey() {
158
+ if (!existsSync2(E2E_KEY_FILE) || existsSync2(E2E_ROOT_KEY_FILE)) return;
159
+ try {
160
+ copyFileSync(E2E_KEY_FILE, E2E_ROOT_KEY_FILE);
161
+ try {
162
+ writeFileSync2(E2E_ROOT_KEY_FILE, readFileSync2(E2E_ROOT_KEY_FILE), { mode: 384 });
163
+ } catch {
164
+ }
165
+ if (!existsSync2(E2E_ROTATION_FILE)) {
166
+ const keyHex = readFileSync2(E2E_KEY_FILE, "utf-8").trim();
167
+ const kid = keyId(keyHex);
168
+ const config = {
169
+ rootKeyId: kid,
170
+ epoch: 0,
171
+ periodSeconds: 0,
172
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
173
+ };
174
+ writeFileSync2(E2E_ROTATION_FILE, JSON.stringify(config, null, 2), { mode: 384 });
175
+ }
176
+ debugLog("Migrated e2e-key to e2e-root-key");
177
+ } catch {
178
+ }
179
+ }
180
+ function checkAndRotateKeys(currentKeyHex) {
181
+ migrateRootKey();
182
+ if (!existsSync2(E2E_ROTATION_FILE) || !existsSync2(E2E_ROOT_KEY_FILE)) {
183
+ return currentKeyHex;
184
+ }
185
+ let rotCfg;
186
+ try {
187
+ rotCfg = JSON.parse(readFileSync2(E2E_ROTATION_FILE, "utf-8"));
188
+ } catch {
189
+ return currentKeyHex;
190
+ }
191
+ const periodSeconds = rotCfg.periodSeconds || 0;
192
+ if (periodSeconds <= 0 || !rotCfg.startedAt) {
193
+ return currentKeyHex;
194
+ }
195
+ const startedTs = Math.floor(new Date(rotCfg.startedAt).getTime() / 1e3);
196
+ if (isNaN(startedTs)) return currentKeyHex;
197
+ const nowTs = Math.floor(Date.now() / 1e3);
198
+ const elapsed = nowTs - startedTs;
199
+ if (elapsed < 0) return currentKeyHex;
200
+ const expectedEpoch = Math.floor(elapsed / periodSeconds);
201
+ const currentEpoch = rotCfg.epoch || 0;
202
+ if (expectedEpoch <= currentEpoch) {
203
+ return currentKeyHex;
204
+ }
205
+ let rootKeyHex;
206
+ try {
207
+ rootKeyHex = readFileSync2(E2E_ROOT_KEY_FILE, "utf-8").trim();
208
+ } catch {
209
+ return currentKeyHex;
210
+ }
211
+ if (rootKeyHex.length !== 64) return currentKeyHex;
212
+ const newKey = deriveEpochKey(rootKeyHex, expectedEpoch);
213
+ if (!newKey) return currentKeyHex;
214
+ try {
215
+ writeFileSync2(E2E_KEY_FILE, newKey, { mode: 384 });
216
+ rotCfg.epoch = expectedEpoch;
217
+ writeFileSync2(E2E_ROTATION_FILE, JSON.stringify(rotCfg, null, 2), { mode: 384 });
218
+ } catch {
219
+ }
220
+ debugLog(`E2E key rotated: epoch ${currentEpoch} -> ${expectedEpoch}`);
221
+ return newKey;
222
+ }
223
+
224
+ // src/config.ts
225
+ var CONFIG_PATH = join3(homedir3(), ".agentapprove", "env");
11
226
  var KEYCHAIN_SERVICE = "com.agentapprove";
12
227
  var KEYCHAIN_ACCOUNT = "api-token";
13
228
  function parseConfigValue(content, key) {
@@ -49,9 +264,9 @@ function getKeychainToken() {
49
264
  }
50
265
  }
51
266
  function checkPermissions(logger) {
52
- if (!existsSync(CONFIG_PATH)) return;
267
+ if (!existsSync3(CONFIG_PATH)) return;
53
268
  try {
54
- const stat = statSync(CONFIG_PATH);
269
+ const stat = statSync2(CONFIG_PATH);
55
270
  const mode = stat.mode & 511;
56
271
  if (mode & 54) {
57
272
  logger?.warn(
@@ -64,8 +279,8 @@ function checkPermissions(logger) {
64
279
  function loadConfig(openclawConfig, logger) {
65
280
  checkPermissions(logger);
66
281
  let fileContent = "";
67
- if (existsSync(CONFIG_PATH)) {
68
- fileContent = readFileSync(CONFIG_PATH, "utf-8");
282
+ if (existsSync3(CONFIG_PATH)) {
283
+ fileContent = readFileSync3(CONFIG_PATH, "utf-8");
69
284
  }
70
285
  let token = process.env.AGENTAPPROVE_TOKEN || getKeychainToken() || parseConfigValue(fileContent, "AGENTAPPROVE_TOKEN") || "";
71
286
  if (!token) {
@@ -80,19 +295,34 @@ function loadConfig(openclawConfig, logger) {
80
295
  const privacyTier = openclawConfig?.privacyTier || process.env.AGENTAPPROVE_PRIVACY || parseConfigValue(fileContent, "AGENTAPPROVE_PRIVACY") || "full";
81
296
  const debug = openclawConfig?.debug || process.env.AGENTAPPROVE_DEBUG === "true" || parseConfigValue(fileContent, "AGENTAPPROVE_DEBUG") === "true" || false;
82
297
  const agentName = process.env.AGENTAPPROVE_AGENT_NAME || parseConfigValue(fileContent, "AGENTAPPROVE_OPENCLAW_NAME") || "OpenClaw";
83
- const e2eEnabled = parseConfigValue(fileContent, "AGENTAPPROVE_E2E_ENABLED") === "true";
84
- const e2eKeyPath = join(homedir(), ".agentapprove", "e2e-key");
298
+ const e2eEnabled = process.env.AGENTAPPROVE_E2E_ENABLED === "true" || parseConfigValue(fileContent, "AGENTAPPROVE_E2E_ENABLED") === "true";
299
+ const e2eUserKeyPath = join3(homedir3(), ".agentapprove", "e2e-key");
300
+ const e2eServerKeyPath = join3(homedir3(), ".agentapprove", "e2e-server-key");
85
301
  let e2eUserKey;
86
302
  let e2eServerKey;
87
- if (e2eEnabled && existsSync(e2eKeyPath)) {
88
- try {
89
- const keyData = JSON.parse(readFileSync(e2eKeyPath, "utf-8"));
90
- e2eUserKey = keyData.userKey;
91
- e2eServerKey = keyData.serverKey;
92
- } catch {
93
- logger?.warn("Failed to load E2E encryption keys");
303
+ if (e2eEnabled) {
304
+ if (existsSync3(e2eUserKeyPath)) {
305
+ try {
306
+ e2eUserKey = readFileSync3(e2eUserKeyPath, "utf-8").trim();
307
+ } catch {
308
+ logger?.warn("Failed to load E2E user key");
309
+ }
310
+ }
311
+ if (existsSync3(e2eServerKeyPath)) {
312
+ try {
313
+ e2eServerKey = readFileSync3(e2eServerKeyPath, "utf-8").trim();
314
+ } catch {
315
+ logger?.warn("Failed to load E2E server key");
316
+ }
317
+ }
318
+ if (e2eUserKey) {
319
+ e2eUserKey = checkAndRotateKeys(e2eUserKey) || e2eUserKey;
94
320
  }
95
321
  }
322
+ if (debug) {
323
+ const e2eStatus = !e2eEnabled ? "disabled" : !e2eUserKey ? "enabled but user key missing" : `enabled, keyId=${keyId(e2eUserKey)}`;
324
+ debugLog(`Config loaded: e2e=${e2eStatus}, serverKey=${e2eServerKey ? "present" : "missing"}, api=${apiUrl}`);
325
+ }
96
326
  return {
97
327
  apiUrl,
98
328
  apiVersion,
@@ -116,7 +346,7 @@ import { URL } from "url";
116
346
 
117
347
  // src/hmac.ts
118
348
  import crypto from "crypto";
119
- import { readFileSync as readFileSync2 } from "fs";
349
+ import { readFileSync as readFileSync4 } from "fs";
120
350
  function generateHMACSignature(body, token, hookVersion) {
121
351
  const timestamp = Math.floor(Date.now() / 1e3);
122
352
  const message = `${hookVersion}:${timestamp}:${body}`;
@@ -125,7 +355,7 @@ function generateHMACSignature(body, token, hookVersion) {
125
355
  }
126
356
  function computePluginHash(pluginPath) {
127
357
  try {
128
- const content = readFileSync2(pluginPath, "utf-8");
358
+ const content = readFileSync4(pluginPath, "utf-8");
129
359
  return crypto.createHash("sha256").update(content).digest("hex");
130
360
  } catch {
131
361
  return "";
@@ -143,6 +373,28 @@ function buildHMACHeaders(body, token, hookVersion, pluginHash) {
143
373
 
144
374
  // src/privacy.ts
145
375
  var SUMMARY_MAX_LENGTH = 50;
376
+ var FILEPATH_MAX_LENGTH = 100;
377
+ var SENSITIVE_PATTERNS = [
378
+ /(?:password|secret|token|key|api_key|auth)=[^\s&]+/gi,
379
+ /Bearer [a-zA-Z0-9_-]+/gi
380
+ ];
381
+ function redactSensitive(value) {
382
+ let result = value;
383
+ for (const pattern of SENSITIVE_PATTERNS) {
384
+ result = result.replace(pattern, "***REDACTED***");
385
+ }
386
+ return result;
387
+ }
388
+ function truncate(value, maxLen) {
389
+ if (!value) return value;
390
+ if (value.length <= maxLen) return value;
391
+ return value.slice(0, maxLen) + "...";
392
+ }
393
+ function summarizeToolInput(value) {
394
+ const inputStr = redactSensitive(JSON.stringify(value));
395
+ const summary = inputStr.length > SUMMARY_MAX_LENGTH ? inputStr.slice(0, SUMMARY_MAX_LENGTH) + "..." : inputStr;
396
+ return { _summary: summary };
397
+ }
146
398
  function applyPrivacyFilter(request, privacyTier) {
147
399
  if (privacyTier === "full") {
148
400
  return request;
@@ -154,54 +406,51 @@ function applyPrivacyFilter(request, privacyTier) {
154
406
  delete filtered.cwd;
155
407
  return filtered;
156
408
  }
157
- if (filtered.command && filtered.command.length > SUMMARY_MAX_LENGTH) {
158
- filtered.command = filtered.command.slice(0, SUMMARY_MAX_LENGTH) + "...";
409
+ if (filtered.command) {
410
+ filtered.command = truncate(redactSensitive(filtered.command), SUMMARY_MAX_LENGTH);
159
411
  }
160
412
  if (filtered.toolInput) {
161
- const inputStr = JSON.stringify(filtered.toolInput);
162
- if (inputStr.length > SUMMARY_MAX_LENGTH) {
163
- filtered.toolInput = { _summary: inputStr.slice(0, SUMMARY_MAX_LENGTH) + "..." };
164
- }
413
+ filtered.toolInput = summarizeToolInput(filtered.toolInput);
165
414
  }
166
415
  return filtered;
167
416
  }
168
-
169
- // src/debug.ts
170
- import { appendFileSync, existsSync as existsSync2, mkdirSync, statSync as statSync2, readFileSync as readFileSync3, writeFileSync } from "fs";
171
- import { join as join2, dirname } from "path";
172
- import { homedir as homedir2 } from "os";
173
- var DEBUG_LOG_PATH = join2(homedir2(), ".agentapprove", "hook-debug.log");
174
- var MAX_SIZE = 5 * 1024 * 1024;
175
- var KEEP_SIZE = 2 * 1024 * 1024;
176
- function ensureLogFile() {
177
- const dir = dirname(DEBUG_LOG_PATH);
178
- if (!existsSync2(dir)) {
179
- mkdirSync(dir, { recursive: true });
417
+ var CONTENT_FIELDS = [
418
+ "command",
419
+ "toolInput",
420
+ "response",
421
+ "text",
422
+ "textPreview",
423
+ "prompt",
424
+ "output",
425
+ "cwd"
426
+ ];
427
+ function applyEventPrivacyFilter(event, privacyTier) {
428
+ if (privacyTier === "full") {
429
+ return event;
180
430
  }
181
- if (!existsSync2(DEBUG_LOG_PATH)) {
182
- writeFileSync(DEBUG_LOG_PATH, "", { mode: 384 });
183
- return;
431
+ const filtered = { ...event };
432
+ if (privacyTier === "minimal") {
433
+ for (const field of CONTENT_FIELDS) {
434
+ delete filtered[field];
435
+ }
436
+ if (typeof filtered.textLength === "undefined" && typeof event.text === "string") {
437
+ filtered.textLength = event.text.length;
438
+ }
439
+ return filtered;
184
440
  }
185
- try {
186
- const stat = statSync2(DEBUG_LOG_PATH);
187
- if (stat.size > MAX_SIZE) {
188
- const content = readFileSync3(DEBUG_LOG_PATH, "utf-8");
189
- writeFileSync(DEBUG_LOG_PATH, content.slice(-KEEP_SIZE), { mode: 384 });
190
- const ts = (/* @__PURE__ */ new Date()).toISOString().replace("T", " ").slice(0, 19);
191
- appendFileSync(DEBUG_LOG_PATH, `[${ts}] [openclaw-plugin] Log rotated (exceeded 5MB)
192
- `);
441
+ for (const field of CONTENT_FIELDS) {
442
+ const val = filtered[field];
443
+ if (val === void 0 || val === null) continue;
444
+ if (field === "toolInput") {
445
+ filtered[field] = summarizeToolInput(val);
446
+ } else if (typeof val === "string") {
447
+ filtered[field] = truncate(redactSensitive(val), SUMMARY_MAX_LENGTH);
193
448
  }
194
- } catch {
195
449
  }
196
- }
197
- function debugLog(message, hookName = "openclaw-plugin") {
198
- try {
199
- ensureLogFile();
200
- const ts = (/* @__PURE__ */ new Date()).toISOString().replace("T", " ").slice(0, 19);
201
- appendFileSync(DEBUG_LOG_PATH, `[${ts}] [${hookName}] ${message}
202
- `);
203
- } catch {
450
+ if (typeof filtered.filePath === "string") {
451
+ filtered.filePath = truncate(filtered.filePath, FILEPATH_MAX_LENGTH);
204
452
  }
453
+ return filtered;
205
454
  }
206
455
 
207
456
  // src/api-client.ts
@@ -256,8 +505,16 @@ async function sendApprovalRequest(request, config, pluginPath) {
256
505
  if (!config.token) {
257
506
  throw new Error("No Agent Approve token configured");
258
507
  }
259
- const filtered = applyPrivacyFilter(request, config.privacyTier);
260
- const bodyStr = JSON.stringify(filtered);
508
+ let payload;
509
+ if (config.e2eEnabled && config.e2eUserKey) {
510
+ payload = applyApprovalE2E(request, config.e2eUserKey, config.e2eServerKey);
511
+ if (config.debug) {
512
+ debugLog("E2E encryption applied to approval request");
513
+ }
514
+ } else {
515
+ payload = applyPrivacyFilter(request, config.privacyTier);
516
+ }
517
+ const bodyStr = JSON.stringify(payload);
261
518
  const pluginHash = getPluginHash(pluginPath);
262
519
  const hmacHeaders = buildHMACHeaders(bodyStr, config.token, config.hookVersion, pluginHash);
263
520
  const headers = {
@@ -283,8 +540,20 @@ async function sendApprovalRequest(request, config, pluginPath) {
283
540
  }
284
541
  async function sendEvent(event, config, pluginPath) {
285
542
  if (!config.token) return;
543
+ const eventType = event.eventType;
544
+ const toolName = event.toolName;
545
+ if (config.debug) {
546
+ debugLog(`Sending ${eventType || "event"}${toolName ? ` (${toolName})` : ""} (privacy: ${config.privacyTier})`);
547
+ }
286
548
  try {
287
- const bodyStr = JSON.stringify(event);
549
+ let payload = applyEventPrivacyFilter(event, config.privacyTier);
550
+ if (config.e2eEnabled && config.e2eUserKey) {
551
+ payload = applyEventE2E(payload, config.e2eUserKey);
552
+ if (config.debug) {
553
+ debugLog(`E2E applied to event (type=${eventType})`);
554
+ }
555
+ }
556
+ const bodyStr = JSON.stringify(payload);
288
557
  const pluginHash = getPluginHash(pluginPath);
289
558
  const hmacHeaders = buildHMACHeaders(bodyStr, config.token, config.hookVersion, pluginHash);
290
559
  const headers = {
@@ -292,10 +561,18 @@ async function sendEvent(event, config, pluginPath) {
292
561
  ...hmacHeaders
293
562
  };
294
563
  const url = `${config.apiUrl}/${config.apiVersion}/events`;
295
- await httpPost(url, bodyStr, headers, 5e3);
296
- } catch {
297
564
  if (config.debug) {
298
- debugLog(`Failed to send event: ${JSON.stringify(event).slice(0, 100)}`);
565
+ debugLog(`=== SENT TO ${url} ===`);
566
+ debugLog(bodyStr.slice(0, 500));
567
+ debugLog("=== END SENT ===");
568
+ }
569
+ const response = await httpPost(url, bodyStr, headers, 5e3);
570
+ if (config.debug) {
571
+ debugLog(`send_event response: ${response.body.slice(0, 200)}`);
572
+ }
573
+ } catch (err) {
574
+ if (config.debug) {
575
+ debugLog(`Failed to send ${eventType || "event"}: ${err instanceof Error ? err.message : String(err)}`);
299
576
  }
300
577
  }
301
578
  }
@@ -307,19 +584,34 @@ try {
307
584
  } catch {
308
585
  pluginFilePath = __filename;
309
586
  }
310
- var gatewaySessionId = randomBytes(12).toString("hex");
311
- function extractSessionIdFromParams(params) {
312
- if (!params) return void 0;
313
- const raw = params.sessionId || params.session_id || params.conversationId || params.conversation_id;
314
- if (typeof raw === "string" && raw.trim().length > 0) {
315
- return raw.trim();
316
- }
317
- return void 0;
587
+ var gatewaySessionId = randomBytes2(12).toString("hex");
588
+ var DEDUP_WINDOW_MS = 1200;
589
+ var DEDUP_MAX_SIZE = 300;
590
+ var recentCompletions = /* @__PURE__ */ new Map();
591
+ function resolveConversationId(...candidates) {
592
+ for (const id of candidates) {
593
+ if (id && id.trim()) return id;
594
+ }
595
+ return gatewaySessionId;
318
596
  }
319
- function resolveSessionId(ctx, params) {
320
- return extractSessionIdFromParams(params) || ctx?.sessionKey || gatewaySessionId;
597
+ function isDuplicateCompletion(toolName, params) {
598
+ const paramsHash = createHash2("md5").update(JSON.stringify(params || {})).digest("hex").slice(0, 12);
599
+ const key = `${toolName}:${paramsHash}`;
600
+ const now = Date.now();
601
+ const last = recentCompletions.get(key);
602
+ if (last && now - last < DEDUP_WINDOW_MS) {
603
+ return true;
604
+ }
605
+ recentCompletions.set(key, now);
606
+ if (recentCompletions.size > DEDUP_MAX_SIZE) {
607
+ const cutoff = now - DEDUP_WINDOW_MS;
608
+ for (const [k, ts] of recentCompletions) {
609
+ if (ts < cutoff) recentCompletions.delete(k);
610
+ }
611
+ }
612
+ return false;
321
613
  }
322
- function classifyTool(toolName) {
614
+ function classifyTool(toolName, params) {
323
615
  const lower = toolName.toLowerCase();
324
616
  if (lower === "exec" || lower === "process") {
325
617
  return { toolType: "shell_command", displayName: toolName };
@@ -334,6 +626,9 @@ function classifyTool(toolName) {
334
626
  return { toolType: "browser", displayName: toolName };
335
627
  }
336
628
  if (lower === "message" || lower === "agent_send") {
629
+ const action = params?.action;
630
+ if (action === "send") return { toolType: "message_send", displayName: toolName };
631
+ if (action === "read") return { toolType: "message_read", displayName: toolName };
337
632
  return { toolType: "message", displayName: toolName };
338
633
  }
339
634
  if (lower === "sessions_spawn" || lower === "sessions_send") {
@@ -358,11 +653,47 @@ function extractCommand(toolName, params) {
358
653
  if (lower === "apply_patch") {
359
654
  return "apply_patch";
360
655
  }
656
+ if (lower === "message" || lower === "agent_send") {
657
+ const action = params.action;
658
+ const target = params.target || params.to || params.channelId || params.channel_id;
659
+ const provider = params.channel;
660
+ const channelLabel = target ? provider ? `${provider}:${target}` : target : provider || "channel";
661
+ if (action === "send") {
662
+ const msg = params.message;
663
+ return msg ? `[${channelLabel}] ${msg}` : `Send to ${channelLabel}`;
664
+ }
665
+ if (action === "read") {
666
+ return `Read from ${channelLabel}`;
667
+ }
668
+ return void 0;
669
+ }
361
670
  return void 0;
362
671
  }
672
+ function extractResultPreview(toolName, params, result, maxLen = 300) {
673
+ if (!result) return void 0;
674
+ const lower = toolName.toLowerCase();
675
+ if (lower === "message" && params.action === "read") {
676
+ const res = result;
677
+ const messages = res.messages;
678
+ if (Array.isArray(messages) && messages.length > 0) {
679
+ const previews = messages.slice(0, 5).map((m) => {
680
+ const author = m.user || "?";
681
+ const text = m.text || "";
682
+ return `${author}: ${text}`;
683
+ });
684
+ const summary = previews.join("\n");
685
+ return summary.length > maxLen ? summary.slice(0, maxLen) + "..." : summary;
686
+ }
687
+ }
688
+ const str = typeof result === "string" ? result : JSON.stringify(result);
689
+ if (str.length <= maxLen) return str;
690
+ return str.slice(0, maxLen) + "...";
691
+ }
363
692
  function handleFailBehavior(config, error, toolName, logger) {
364
693
  logger.warn(`Agent Approve API error for tool "${toolName}": ${error.message}`);
365
- debugLog(`API error: ${error.message}, failBehavior: ${config.failBehavior}`);
694
+ if (config.debug) {
695
+ debugLog(`API error: ${error.message}, failBehavior: ${config.failBehavior}`);
696
+ }
366
697
  switch (config.failBehavior) {
367
698
  case "deny":
368
699
  return { block: true, blockReason: "Agent Approve unavailable, denying by policy" };
@@ -383,9 +714,12 @@ function register(api) {
383
714
  return;
384
715
  }
385
716
  api.logger.info(`Agent Approve: Plugin loaded (privacy: ${config.privacyTier}, fail: ${config.failBehavior})`);
386
- debugLog(`Plugin loaded, API: ${config.apiUrl}, agent: ${config.agentName}`);
717
+ if (config.debug) {
718
+ debugLog(`Plugin loaded, API: ${config.apiUrl}, agent: ${config.agentName}`);
719
+ }
387
720
  api.on("before_tool_call", async (event, ctx) => {
388
- const { toolType, displayName } = classifyTool(event.toolName);
721
+ const conversationId = resolveConversationId(ctx.sessionKey);
722
+ const { toolType, displayName } = classifyTool(event.toolName, event.params);
389
723
  const command = extractCommand(event.toolName, event.params);
390
724
  const request = {
391
725
  toolName: displayName,
@@ -394,17 +728,22 @@ function register(api) {
394
728
  toolInput: event.params,
395
729
  agent: config.agentName,
396
730
  hookType: "before_tool_call",
397
- sessionId: resolveSessionId(ctx, event.params),
731
+ sessionId: conversationId,
732
+ conversationId,
398
733
  cwd: event.params.workdir || void 0,
399
734
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
400
735
  };
401
736
  try {
402
737
  const response = await sendApprovalRequest(request, config, pluginFilePath);
403
738
  if (response.decision === "approve" || response.decision === "allow") {
404
- debugLog(`Tool "${event.toolName}" approved${response.reason ? ": " + response.reason : ""}`);
739
+ if (config.debug) {
740
+ debugLog(`Tool "${event.toolName}" approved${response.reason ? ": " + response.reason : ""}`);
741
+ }
405
742
  return void 0;
406
743
  }
407
- debugLog(`Tool "${event.toolName}" denied${response.reason ? ": " + response.reason : ""}`);
744
+ if (config.debug) {
745
+ debugLog(`Tool "${event.toolName}" denied${response.reason ? ": " + response.reason : ""}`);
746
+ }
408
747
  return {
409
748
  block: true,
410
749
  blockReason: response.reason || "Denied by Agent Approve"
@@ -419,30 +758,183 @@ function register(api) {
419
758
  }
420
759
  });
421
760
  api.on("after_tool_call", async (event, ctx) => {
422
- const { toolType } = classifyTool(event.toolName);
761
+ const conversationId = resolveConversationId(ctx.sessionKey);
762
+ if (isDuplicateCompletion(event.toolName, event.params)) {
763
+ if (config.debug) {
764
+ debugLog(`Skipping duplicate tool_complete for "${event.toolName}"`);
765
+ }
766
+ return;
767
+ }
768
+ const { toolType } = classifyTool(event.toolName, event.params);
769
+ const resultPreview = extractResultPreview(event.toolName, event.params, event.result);
423
770
  void sendEvent({
424
771
  toolName: event.toolName,
425
772
  toolType,
773
+ eventType: "tool_complete",
426
774
  agent: config.agentName,
427
775
  hookType: "after_tool_call",
428
- sessionId: resolveSessionId(ctx, event.params),
776
+ sessionId: conversationId,
777
+ conversationId,
778
+ command: extractCommand(event.toolName, event.params),
429
779
  status: event.error ? "error" : "success",
430
- error: event.error,
780
+ response: event.error || resultPreview || void 0,
431
781
  durationMs: event.durationMs,
432
782
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
433
783
  }, config, pluginFilePath);
434
784
  });
785
+ api.on("session_start", async (event, ctx) => {
786
+ const conversationId = resolveConversationId(ctx.sessionId, event.sessionId);
787
+ if (config.debug) {
788
+ debugLog(`Session started: ${event.sessionId}${event.resumedFrom ? ` (resumed from ${event.resumedFrom})` : ""}`);
789
+ }
790
+ void sendEvent({
791
+ eventType: "session_start",
792
+ agent: config.agentName,
793
+ hookType: "session_start",
794
+ sessionId: conversationId,
795
+ conversationId,
796
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
797
+ }, config, pluginFilePath);
798
+ });
799
+ api.on("session_end", async (event, ctx) => {
800
+ const conversationId = resolveConversationId(ctx.sessionId, event.sessionId);
801
+ if (config.debug) {
802
+ debugLog(`Session ended: ${event.sessionId} (${event.messageCount} messages, ${event.durationMs ?? "?"}ms)`);
803
+ }
804
+ void sendEvent({
805
+ eventType: "session_end",
806
+ agent: config.agentName,
807
+ hookType: "session_end",
808
+ sessionId: conversationId,
809
+ conversationId,
810
+ durationMs: event.durationMs,
811
+ sessionStats: { messageCount: event.messageCount },
812
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
813
+ }, config, pluginFilePath);
814
+ });
815
+ api.on("llm_input", async (event, ctx) => {
816
+ const conversationId = resolveConversationId(ctx.sessionId, event.sessionId, ctx.sessionKey);
817
+ if (config.debug) {
818
+ debugLog(`LLM input: model=${event.model}, prompt length=${event.prompt?.length ?? 0}`);
819
+ }
820
+ void sendEvent({
821
+ eventType: "user_prompt",
822
+ agent: config.agentName,
823
+ hookType: "llm_input",
824
+ sessionId: conversationId,
825
+ conversationId,
826
+ model: event.model,
827
+ prompt: event.prompt,
828
+ textLength: event.prompt?.length ?? 0,
829
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
830
+ }, config, pluginFilePath);
831
+ });
832
+ api.on("llm_output", async (event, ctx) => {
833
+ const conversationId = resolveConversationId(ctx.sessionId, event.sessionId, ctx.sessionKey);
834
+ const responseText = event.assistantTexts?.join("\n") || "";
835
+ const textLength = responseText.length;
836
+ if (config.debug) {
837
+ debugLog(`LLM output: model=${event.model}, length=${textLength}${event.usage?.total ? `, tokens=${event.usage.total}` : ""}`);
838
+ }
839
+ void sendEvent({
840
+ eventType: "response",
841
+ agent: config.agentName,
842
+ hookType: "llm_output",
843
+ sessionId: conversationId,
844
+ conversationId,
845
+ model: event.model,
846
+ text: responseText,
847
+ textPreview: responseText.slice(0, 200),
848
+ textLength,
849
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
850
+ }, config, pluginFilePath);
851
+ });
852
+ api.on("agent_end", async (event, ctx) => {
853
+ const conversationId = resolveConversationId(ctx.sessionId, ctx.sessionKey);
854
+ if (config.debug) {
855
+ debugLog(`Agent ended: success=${event.success}${event.durationMs ? `, duration=${event.durationMs}ms` : ""}${event.error ? `, error=${event.error}` : ""}`);
856
+ }
857
+ void sendEvent({
858
+ eventType: "stop",
859
+ agent: config.agentName,
860
+ hookType: "agent_end",
861
+ sessionId: conversationId,
862
+ conversationId,
863
+ status: event.success ? "completed" : "error",
864
+ durationMs: event.durationMs,
865
+ response: event.error || void 0,
866
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
867
+ }, config, pluginFilePath);
868
+ });
869
+ api.on("before_compaction", async (event, ctx) => {
870
+ const conversationId = resolveConversationId(ctx.sessionId, ctx.sessionKey);
871
+ if (config.debug) {
872
+ debugLog(`Context compaction: ${event.messageCount} messages${event.tokenCount ? `, ${event.tokenCount} tokens` : ""}`);
873
+ }
874
+ void sendEvent({
875
+ eventType: "context_compact",
876
+ agent: config.agentName,
877
+ hookType: "before_compaction",
878
+ sessionId: conversationId,
879
+ conversationId,
880
+ trigger: `${event.messageCount} messages${event.tokenCount ? `, ${event.tokenCount} tokens` : ""}`,
881
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
882
+ }, config, pluginFilePath);
883
+ });
884
+ api.on("subagent_spawned", async (event, ctx) => {
885
+ const conversationId = resolveConversationId(ctx.requesterSessionKey, ctx.childSessionKey, event.childSessionKey);
886
+ if (config.debug) {
887
+ debugLog(`Subagent spawned: ${event.agentId} (${event.mode}${event.label ? `, label=${event.label}` : ""})`);
888
+ }
889
+ void sendEvent({
890
+ eventType: "subagent_start",
891
+ agent: config.agentName,
892
+ hookType: "subagent_spawned",
893
+ sessionId: conversationId,
894
+ conversationId,
895
+ subagentType: event.agentId,
896
+ subagentMode: event.mode,
897
+ subagentLabel: event.label,
898
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
899
+ }, config, pluginFilePath);
900
+ });
901
+ api.on("subagent_ended", async (event, ctx) => {
902
+ const conversationId = resolveConversationId(ctx.requesterSessionKey, ctx.childSessionKey, event.targetSessionKey);
903
+ if (config.debug) {
904
+ debugLog(`Subagent ended: ${event.targetKind} reason=${event.reason}${event.outcome ? `, outcome=${event.outcome}` : ""}`);
905
+ }
906
+ void sendEvent({
907
+ eventType: "subagent_stop",
908
+ agent: config.agentName,
909
+ hookType: "subagent_ended",
910
+ sessionId: conversationId,
911
+ conversationId,
912
+ status: event.outcome || event.reason,
913
+ subagentType: event.targetKind,
914
+ response: event.error || void 0,
915
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
916
+ }, config, pluginFilePath);
917
+ });
435
918
  api.registerHook(
436
919
  ["command:new", "command:stop", "command:reset"],
437
920
  async (event) => {
921
+ const eventTypeMap = {
922
+ new: "session_start",
923
+ stop: "session_end",
924
+ reset: "session_start"
925
+ };
926
+ if (config.debug) {
927
+ debugLog(`Command event: ${event.action}`);
928
+ }
438
929
  void sendEvent({
439
930
  toolName: `command:${event.action}`,
440
931
  toolType: "command",
932
+ eventType: eventTypeMap[event.action] || "command_event",
441
933
  agent: config.agentName,
442
934
  hookType: "command_event",
443
- sessionId: event.sessionKey || gatewaySessionId,
444
- timestamp: event.timestamp.toISOString(),
445
- metadata: { sessionKey: event.sessionKey, action: event.action }
935
+ sessionId: resolveConversationId(event.sessionKey),
936
+ conversationId: resolveConversationId(event.sessionKey),
937
+ timestamp: event.timestamp.toISOString()
446
938
  }, config, pluginFilePath);
447
939
  },
448
940
  { name: "agentapprove-command-monitor", description: "Log command events to Agent Approve" }
@@ -450,33 +942,44 @@ function register(api) {
450
942
  api.registerHook(
451
943
  ["message:received", "message:sent"],
452
944
  async (event) => {
453
- const direction = event.action === "received" ? "inbound" : "outbound";
945
+ const isInbound = event.action === "received";
946
+ const provider = event.context.channelId;
947
+ const peer = isInbound ? event.context.from : event.context.to;
948
+ const channelLabel = [provider, peer].filter(Boolean).join(":") || void 0;
949
+ if (config.debug) {
950
+ debugLog(`Message event: ${event.action} ${channelLabel || "(no channel)"}`);
951
+ }
454
952
  const payload = {
455
953
  toolName: `message:${event.action}`,
456
954
  toolType: "message_event",
955
+ eventType: isInbound ? "user_prompt" : "response",
457
956
  agent: config.agentName,
458
957
  hookType: "session_event",
459
- sessionId: event.sessionKey || gatewaySessionId,
958
+ sessionId: resolveConversationId(event.sessionKey),
959
+ conversationId: resolveConversationId(event.sessionKey),
460
960
  timestamp: event.timestamp.toISOString(),
461
- metadata: {
462
- direction,
463
- channelId: event.context.channelId,
464
- sessionKey: event.sessionKey
465
- }
961
+ cwd: channelLabel
466
962
  };
467
- if (config.privacyTier === "full" && event.context.content) {
468
- payload.metadata.contentPreview = event.context.content.slice(0, 100);
963
+ const content = event.context.content;
964
+ if (content) {
965
+ payload.text = content;
966
+ payload.textPreview = content.slice(0, 200);
967
+ payload.textLength = content.length;
968
+ if (isInbound && peer) {
969
+ payload.prompt = `${peer}: ${content}`;
970
+ }
469
971
  }
470
972
  void sendEvent(payload, config, pluginFilePath);
471
973
  },
472
974
  { name: "agentapprove-session-monitor", description: "Log message events to Agent Approve" }
473
975
  );
474
- api.logger.info("Agent Approve: Registered before_tool_call, after_tool_call, and event monitoring hooks");
976
+ const hookCount = 10;
977
+ api.logger.info(`Agent Approve: Registered ${hookCount} plugin hooks + event monitoring`);
475
978
  }
476
979
  var index_default = {
477
980
  id: "openclaw",
478
981
  name: "Agent Approve",
479
- description: "Mobile approval for AI agent tool execution",
982
+ description: "Agent Approve mobile approval for AI agent tool execution",
480
983
  register
481
984
  };
482
985
  export {
@@ -2,7 +2,7 @@
2
2
  "id": "openclaw",
3
3
  "name": "Agent Approve",
4
4
  "description": "Mobile approval for AI agent tool execution. Approve or deny tool calls from your iPhone and Apple Watch.",
5
- "version": "0.1.2",
5
+ "version": "0.1.4",
6
6
  "homepage": "https://agentapprove.com",
7
7
  "configSchema": {
8
8
  "type": "object",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentapprove/openclaw",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Agent Approve plugin for OpenClaw - approve or deny AI agent tool calls from your iPhone and Apple Watch",
5
5
  "main": "dist/index.js",
6
6
  "scripts": {