@agentapprove/openclaw 0.1.7 → 0.1.9

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.
Files changed (3) hide show
  1. package/README.md +12 -0
  2. package/dist/index.js +178 -29
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -10,6 +10,13 @@
10
10
 
11
11
  You decide the rules. Agent Approve enforces them.
12
12
 
13
+ ## Important
14
+
15
+ - A valid Agent Approve subscription is required to use this plugin.
16
+ - You must download the iOS app, run `npx agentapprove`, and pair your device.
17
+ - This plugin is currently pre-launch software. Public App Store availability is not open yet.
18
+ - If a valid token is not configured, tool calls are blocked with setup guidance.
19
+
13
20
  ## How it works
14
21
 
15
22
  This plugin hooks into OpenClaw's agent loop to enforce your approval policy:
@@ -67,6 +74,11 @@ Plugin settings in `~/.openclaw/openclaw.json`:
67
74
  | `privacyTier` | `full` | What tool data is stored in event logs: `minimal`, `summary`, or `full` |
68
75
  | `debug` | `false` | Write debug logs to `~/.agentapprove/hook-debug.log` |
69
76
 
77
+ ## Requirements
78
+
79
+ - OpenClaw runtime
80
+ - Agent Approve iOS app and active subscription ($9.99/month or $99/year, 7-day free trial)
81
+
70
82
  ## Supported agents
71
83
 
72
84
  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.
package/dist/index.js CHANGED
@@ -9,7 +9,7 @@ import { homedir as homedir3 } from "os";
9
9
  import { execSync } from "child_process";
10
10
 
11
11
  // src/e2e-crypto.ts
12
- import { createHash, createHmac, createCipheriv, randomBytes } from "crypto";
12
+ import { createHash, createHmac, createCipheriv, createDecipheriv, randomBytes, timingSafeEqual } from "crypto";
13
13
  import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, existsSync as existsSync2, copyFileSync } from "fs";
14
14
  import { join as join2 } from "path";
15
15
  import { homedir as homedir2 } from "os";
@@ -135,6 +135,35 @@ function e2eEncrypt(keyHex, plaintext) {
135
135
  const hmac = createHmac("sha256", macKey).update(Buffer.concat([iv, ciphertext])).digest("hex");
136
136
  return `E2E:v1:${kid}:${ivHex}:${ciphertextBase64}:${hmac}`;
137
137
  }
138
+ function isE2EEncrypted(value) {
139
+ return value.startsWith("E2E:v1:");
140
+ }
141
+ function e2eDecrypt(keyHex, encrypted) {
142
+ try {
143
+ if (!isE2EEncrypted(encrypted)) return null;
144
+ const parts = encrypted.split(":");
145
+ if (parts.length !== 6) return null;
146
+ const kid = parts[2];
147
+ if (kid !== keyId(keyHex)) return null;
148
+ const ivHex = parts[3];
149
+ const ciphertextBase64 = parts[4];
150
+ const hmacHex = parts[5];
151
+ const encKey = deriveEncKey(keyHex);
152
+ const macKey = deriveMacKey(keyHex);
153
+ const iv = Buffer.from(ivHex, "hex");
154
+ const ciphertext = Buffer.from(ciphertextBase64, "base64");
155
+ const expectedHmac = createHmac("sha256", macKey).update(Buffer.concat([iv, ciphertext])).digest();
156
+ const actualHmac = Buffer.from(hmacHex, "hex");
157
+ if (expectedHmac.length !== actualHmac.length || !timingSafeEqual(expectedHmac, actualHmac)) {
158
+ return null;
159
+ }
160
+ const decipher = createDecipheriv("aes-256-ctr", encKey, iv);
161
+ const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
162
+ return plaintext.toString("utf-8");
163
+ } catch {
164
+ return null;
165
+ }
166
+ }
138
167
  function applyApprovalE2E(payload, userKey, serverKey) {
139
168
  const sensitiveFields = {};
140
169
  for (const field of ["command", "toolInput", "cwd"]) {
@@ -339,7 +368,7 @@ function loadConfig(openclawConfig, logger) {
339
368
  }
340
369
  let token = process.env.AGENTAPPROVE_TOKEN || getKeychainToken() || parseConfigValue(fileContent, "AGENTAPPROVE_TOKEN") || "";
341
370
  if (!token) {
342
- logger?.warn("No Agent Approve token found. Run the Agent Approve installer to set up.");
371
+ logger?.warn("Missing token. Download the iOS app, run npx agentapprove, and pair your device. Subscription required.");
343
372
  }
344
373
  const rawApiUrl = openclawConfig?.apiUrl || process.env.AGENTAPPROVE_API || parseConfigValue(fileContent, "AGENTAPPROVE_API") || "https://api.agentapprove.com";
345
374
  const rawApiVersion = process.env.AGENTAPPROVE_API_VERSION || parseConfigValue(fileContent, "AGENTAPPROVE_API_VERSION") || "v001";
@@ -386,7 +415,8 @@ function loadConfig(openclawConfig, logger) {
386
415
  failBehavior,
387
416
  privacyTier,
388
417
  debug,
389
- hookVersion: "1.1.4",
418
+ hookVersion: "1.1.5",
419
+ agentType: "openclaw",
390
420
  agentName,
391
421
  e2eEnabled,
392
422
  e2eUserKey,
@@ -473,6 +503,7 @@ var CONTENT_FIELDS = [
473
503
  "command",
474
504
  "toolInput",
475
505
  "response",
506
+ "responsePreview",
476
507
  "text",
477
508
  "textPreview",
478
509
  "prompt",
@@ -688,7 +719,7 @@ function httpPost(url, body, headers, timeoutMs) {
688
719
  }
689
720
  async function sendApprovalRequest(request, config, pluginPath, hookName = "openclaw-plugin") {
690
721
  if (!config.token) {
691
- throw new Error("No Agent Approve token configured");
722
+ throw new Error("Missing token. Download the iOS app, run npx agentapprove, and pair your device. Subscription required.");
692
723
  }
693
724
  let payload;
694
725
  if (config.e2eEnabled && config.e2eUserKey) {
@@ -729,6 +760,51 @@ async function sendApprovalRequest(request, config, pluginPath, hookName = "open
729
760
  processConfigSync(parsed, config);
730
761
  return parsed;
731
762
  }
763
+ async function sendStopRequest(request, config, pluginPath, hookName = "openclaw-plugin") {
764
+ if (!config.token) {
765
+ throw new Error("Missing token. Download the iOS app, run npx agentapprove, and pair your device. Subscription required.");
766
+ }
767
+ let payload = applyEventPrivacyFilter(
768
+ { ...request },
769
+ config.privacyTier
770
+ );
771
+ if (config.e2eEnabled && config.e2eUserKey) {
772
+ payload = applyEventE2E(payload, config.e2eUserKey);
773
+ if (config.debug) {
774
+ debugLog("E2E encryption applied to stop request", hookName);
775
+ }
776
+ }
777
+ const bodyStr = JSON.stringify(payload);
778
+ const pluginHash = getPluginHash(pluginPath, config.debug, hookName);
779
+ const hmacHeaders = buildHMACHeaders(bodyStr, config.token, config.hookVersion, pluginHash);
780
+ const headers = {
781
+ "Authorization": `Bearer ${config.token}`,
782
+ ...hmacHeaders
783
+ };
784
+ const url = `${config.apiUrl}/${config.apiVersion}/stop`;
785
+ const stopTimeoutMs = 3e5;
786
+ if (config.debug) {
787
+ debugLog(`Sending stop request to ${url}`, hookName);
788
+ debugLog(`=== SENT TO ${url} ===`, hookName);
789
+ debugLogRawInline(bodyStr);
790
+ debugLog("=== END SENT ===", hookName);
791
+ }
792
+ const response = await httpPost(url, bodyStr, headers, stopTimeoutMs);
793
+ if (config.debug) {
794
+ debugLog(`Stop response: ${response.body || "<empty>"}`, hookName);
795
+ }
796
+ if (response.status !== 200) {
797
+ throw new Error(`Stop API returned status ${response.status}: ${response.body.slice(0, 200)}`);
798
+ }
799
+ let parsed;
800
+ try {
801
+ parsed = JSON.parse(response.body);
802
+ } catch {
803
+ parsed = {};
804
+ }
805
+ processConfigSync(parsed, config);
806
+ return parsed;
807
+ }
732
808
  async function sendEvent(event, config, pluginPath, hookName = "openclaw-plugin") {
733
809
  if (!config.token) return;
734
810
  const eventType = event.eventType;
@@ -915,13 +991,13 @@ function handleFailBehavior(config, error, toolName, logger, hookName = HOOK_PLU
915
991
  }
916
992
  function register(api) {
917
993
  const config = loadConfig(api.pluginConfig, api.logger);
918
- if (!config.token) {
919
- api.logger.warn(
920
- "Agent Approve: No token found. Run the Agent Approve installer to pair with your account."
921
- );
922
- return;
994
+ const missingTokenMessage = "Missing token. Download the iOS app, run npx agentapprove, and pair your device. Subscription required.";
995
+ const noTokenConfigured = !config.token;
996
+ if (noTokenConfigured) {
997
+ api.logger.warn(`Agent Approve: ${missingTokenMessage}`);
998
+ } else {
999
+ api.logger.info(`Agent Approve: Plugin loaded (privacy: ${config.privacyTier}, fail: ${config.failBehavior})`);
923
1000
  }
924
- api.logger.info(`Agent Approve: Plugin loaded (privacy: ${config.privacyTier}, fail: ${config.failBehavior})`);
925
1001
  if (config.debug) {
926
1002
  const e2eStatus = !config.e2eEnabled ? "disabled" : !config.e2eUserKey ? "enabled (key missing)" : "enabled";
927
1003
  debugLog(
@@ -941,16 +1017,24 @@ function register(api) {
941
1017
  if (config.debug) {
942
1018
  debugLog(`Tool: ${displayName} (${toolType}) [agent: ${config.agentName}]`, HOOK_BEFORE_TOOL);
943
1019
  }
1020
+ if (noTokenConfigured) {
1021
+ return {
1022
+ block: true,
1023
+ blockReason: missingTokenMessage
1024
+ };
1025
+ }
944
1026
  const request = {
945
1027
  toolName: displayName,
946
1028
  toolType,
947
1029
  command,
948
1030
  toolInput: event.params,
949
- agent: config.agentName,
1031
+ agent: config.agentType,
1032
+ agentName: config.agentName,
950
1033
  hookType: "before_tool_call",
951
1034
  sessionId: conversationId,
952
1035
  conversationId,
953
1036
  cwd: event.params.workdir || void 0,
1037
+ projectPath: process.cwd(),
954
1038
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
955
1039
  };
956
1040
  try {
@@ -994,18 +1078,24 @@ function register(api) {
994
1078
  }
995
1079
  const { toolType } = classifyTool(event.toolName, event.params);
996
1080
  const resultPreview = extractResultPreview(event.toolName, event.params, event.result);
1081
+ const resultStr = event.result != null ? typeof event.result === "string" ? event.result : JSON.stringify(event.result) : void 0;
1082
+ const longPreview = resultStr && resultStr.length > 1e3 ? resultStr.slice(0, 1e3) + "..." : resultStr;
997
1083
  void sendEvent({
998
1084
  toolName: event.toolName,
999
1085
  toolType,
1000
1086
  eventType: "tool_complete",
1001
- agent: config.agentName,
1087
+ agent: config.agentType,
1088
+ agentName: config.agentName,
1002
1089
  hookType: "after_tool_call",
1003
1090
  sessionId: conversationId,
1004
1091
  conversationId,
1005
1092
  command: extractCommand(event.toolName, event.params),
1093
+ toolInput: event.params,
1006
1094
  status: event.error ? "error" : "success",
1007
1095
  response: event.error || resultPreview || void 0,
1096
+ responsePreview: longPreview,
1008
1097
  durationMs: event.durationMs,
1098
+ cwd: event.params?.workdir || void 0,
1009
1099
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
1010
1100
  }, config, pluginFilePath, HOOK_AFTER_TOOL);
1011
1101
  });
@@ -1017,7 +1107,8 @@ function register(api) {
1017
1107
  const conversationId = resolveConversationId();
1018
1108
  void sendEvent({
1019
1109
  eventType: "session_start",
1020
- agent: config.agentName,
1110
+ agent: config.agentType,
1111
+ agentName: config.agentName,
1021
1112
  hookType: "session_start",
1022
1113
  sessionId: conversationId,
1023
1114
  conversationId,
@@ -1032,7 +1123,8 @@ function register(api) {
1032
1123
  const conversationId = resolveConversationId();
1033
1124
  void sendEvent({
1034
1125
  eventType: "session_end",
1035
- agent: config.agentName,
1126
+ agent: config.agentType,
1127
+ agentName: config.agentName,
1036
1128
  hookType: "session_end",
1037
1129
  sessionId: conversationId,
1038
1130
  conversationId,
@@ -1049,7 +1141,8 @@ function register(api) {
1049
1141
  const conversationId = resolveConversationId();
1050
1142
  void sendEvent({
1051
1143
  eventType: "user_prompt",
1052
- agent: config.agentName,
1144
+ agent: config.agentType,
1145
+ agentName: config.agentName,
1053
1146
  hookType: "llm_input",
1054
1147
  sessionId: conversationId,
1055
1148
  conversationId,
@@ -1069,7 +1162,8 @@ function register(api) {
1069
1162
  }
1070
1163
  void sendEvent({
1071
1164
  eventType: "response",
1072
- agent: config.agentName,
1165
+ agent: config.agentType,
1166
+ agentName: config.agentName,
1073
1167
  hookType: "llm_output",
1074
1168
  sessionId: conversationId,
1075
1169
  conversationId,
@@ -1086,17 +1180,67 @@ function register(api) {
1086
1180
  debugLog(`Agent ended: success=${event.success}${event.durationMs ? `, duration=${event.durationMs}ms` : ""}${event.error ? `, error=${event.error}` : ""}`, HOOK_AGENT_END);
1087
1181
  }
1088
1182
  const conversationId = resolveConversationId();
1089
- void sendEvent({
1183
+ const stopRequest = {
1184
+ agent: config.agentType,
1185
+ agentName: config.agentName,
1090
1186
  eventType: "stop",
1091
- agent: config.agentName,
1092
- hookType: "agent_end",
1187
+ status: event.success ? "completed" : "error",
1188
+ loopCount: Array.isArray(event.messages) ? event.messages.length : 0,
1093
1189
  sessionId: conversationId,
1094
1190
  conversationId,
1095
- status: event.success ? "completed" : "error",
1096
- durationMs: event.durationMs,
1097
- response: event.error || void 0,
1098
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
1099
- }, config, pluginFilePath, HOOK_AGENT_END);
1191
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1192
+ ...config.e2eEnabled && config.e2eUserKey ? { e2eKeyId: keyId(config.e2eUserKey) } : {}
1193
+ };
1194
+ try {
1195
+ const response = await sendStopRequest(stopRequest, config, pluginFilePath, HOOK_AGENT_END);
1196
+ if (config.debug) {
1197
+ debugLog(`Stop response: followup=${response.followup_message ? "yes" : "no"}`, HOOK_AGENT_END);
1198
+ }
1199
+ if (response.followup_message) {
1200
+ let followupText = response.followup_message;
1201
+ if (config.e2eEnabled && config.e2eUserKey && isE2EEncrypted(followupText)) {
1202
+ const decrypted = e2eDecrypt(config.e2eUserKey, followupText);
1203
+ if (!decrypted) {
1204
+ api.logger.warn("Agent Approve: failed to decrypt follow-up message");
1205
+ return;
1206
+ }
1207
+ if (config.debug) {
1208
+ debugLog(`Decrypted follow-up: ${decrypted.slice(0, 50)}...`, HOOK_AGENT_END);
1209
+ }
1210
+ followupText = decrypted;
1211
+ }
1212
+ if (!api.runtime?.system?.enqueueSystemEvent) {
1213
+ api.logger.warn(
1214
+ "Agent Approve: follow-up received but runtime.system API unavailable (OpenClaw version may be too old)"
1215
+ );
1216
+ return;
1217
+ }
1218
+ try {
1219
+ const sessionKey = ctx.sessionKey || conversationId;
1220
+ api.runtime.system.enqueueSystemEvent(
1221
+ `Agent Approve follow-up from user: ${followupText}`,
1222
+ { sessionKey }
1223
+ );
1224
+ api.runtime.system.requestHeartbeatNow({
1225
+ sessionKey,
1226
+ reason: "agentapprove-followup"
1227
+ });
1228
+ if (config.debug) {
1229
+ debugLog(`Follow-up injected via system event, heartbeat requested`, HOOK_AGENT_END);
1230
+ }
1231
+ api.logger.info(`Agent Approve: follow-up injected, heartbeat requested`);
1232
+ } catch (injectionError) {
1233
+ const injMsg = injectionError instanceof Error ? injectionError.message : String(injectionError);
1234
+ api.logger.warn(`Agent Approve: follow-up injection failed \u2014 ${injMsg}`);
1235
+ }
1236
+ }
1237
+ } catch (error) {
1238
+ const msg = error instanceof Error ? error.message : String(error);
1239
+ if (config.debug) {
1240
+ debugLog(`Stop request error: ${msg}`, HOOK_AGENT_END);
1241
+ }
1242
+ api.logger.warn(`Agent Approve: stop request failed \u2014 ${msg}`);
1243
+ }
1100
1244
  });
1101
1245
  api.on("before_compaction", async (event, ctx) => {
1102
1246
  if (config.debug) {
@@ -1106,7 +1250,8 @@ function register(api) {
1106
1250
  const conversationId = resolveConversationId();
1107
1251
  void sendEvent({
1108
1252
  eventType: "context_compact",
1109
- agent: config.agentName,
1253
+ agent: config.agentType,
1254
+ agentName: config.agentName,
1110
1255
  hookType: "before_compaction",
1111
1256
  sessionId: conversationId,
1112
1257
  conversationId,
@@ -1122,7 +1267,8 @@ function register(api) {
1122
1267
  const conversationId = resolveConversationId();
1123
1268
  void sendEvent({
1124
1269
  eventType: "subagent_start",
1125
- agent: config.agentName,
1270
+ agent: config.agentType,
1271
+ agentName: config.agentName,
1126
1272
  hookType: "subagent_spawned",
1127
1273
  sessionId: conversationId,
1128
1274
  conversationId,
@@ -1140,7 +1286,8 @@ function register(api) {
1140
1286
  const conversationId = resolveConversationId();
1141
1287
  void sendEvent({
1142
1288
  eventType: "subagent_stop",
1143
- agent: config.agentName,
1289
+ agent: config.agentType,
1290
+ agentName: config.agentName,
1144
1291
  hookType: "subagent_ended",
1145
1292
  sessionId: conversationId,
1146
1293
  conversationId,
@@ -1166,7 +1313,8 @@ function register(api) {
1166
1313
  toolName: `command:${event.action}`,
1167
1314
  toolType: "command",
1168
1315
  eventType: eventTypeMap[event.action] || "command_event",
1169
- agent: config.agentName,
1316
+ agent: config.agentType,
1317
+ agentName: config.agentName,
1170
1318
  hookType: "command_event",
1171
1319
  sessionId: resolveConversationId(),
1172
1320
  conversationId: resolveConversationId(),
@@ -1190,7 +1338,8 @@ function register(api) {
1190
1338
  toolName: `message:${event.action}`,
1191
1339
  toolType: "message_event",
1192
1340
  eventType: isInbound ? "user_prompt" : "response",
1193
- agent: config.agentName,
1341
+ agent: config.agentType,
1342
+ agentName: config.agentName,
1194
1343
  hookType: "session_event",
1195
1344
  sessionId: resolveConversationId(),
1196
1345
  conversationId: resolveConversationId(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentapprove/openclaw",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
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": {