@agentapprove/opencode 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 +75 -0
- package/dist/index.js +1062 -0
- package/package.json +39 -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,75 @@
|
|
|
1
|
+
# @agentapprove/opencode
|
|
2
|
+
|
|
3
|
+
[Agent Approve](https://agentapprove.com) plugin for [OpenCode](https://opencode.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 OpenCode 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 OpenCode's event system to enforce your approval policy:
|
|
16
|
+
|
|
17
|
+
- **permission.ask** -- evaluates each tool call against your policy and blocks until allowed/denied
|
|
18
|
+
- **tool.execute.before / tool.execute.after** -- logs tool lifecycle events
|
|
19
|
+
- **chat.message** -- logs user prompt activity
|
|
20
|
+
- **event / experimental.session.compacting** -- logs session lifecycle and compaction events
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
1. **Download Agent Approve** from the [App Store](https://agentapprove.com) and start your 7-day free trial
|
|
25
|
+
2. **Run the installer** on each machine where you use OpenCode:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npx agentapprove
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
The installer handles pairing, token setup, and OpenCode plugin activation. Select OpenCode when prompted for which agents to configure.
|
|
32
|
+
|
|
33
|
+
3. **Restart OpenCode** to load the plugin
|
|
34
|
+
|
|
35
|
+
That's it. Tool calls will now be evaluated against your policy, and approval requests will appear on your paired device when needed.
|
|
36
|
+
|
|
37
|
+
## Manual setup (advanced)
|
|
38
|
+
|
|
39
|
+
If you already have Agent Approve configured and just need to add the OpenCode plugin, add `@agentapprove/opencode` to your OpenCode config:
|
|
40
|
+
|
|
41
|
+
`~/.config/opencode/opencode.json`
|
|
42
|
+
|
|
43
|
+
```json
|
|
44
|
+
{
|
|
45
|
+
"plugin": [
|
|
46
|
+
"@agentapprove/opencode"
|
|
47
|
+
]
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
OpenCode automatically installs npm plugins listed in the `plugin` array at startup. Restart OpenCode to activate.
|
|
52
|
+
|
|
53
|
+
## Configuration
|
|
54
|
+
|
|
55
|
+
Plugin behavior is configured through Agent Approve environment settings in:
|
|
56
|
+
|
|
57
|
+
`~/.agentapprove/env`
|
|
58
|
+
|
|
59
|
+
| Setting | Default | Description |
|
|
60
|
+
|---------|---------|-------------|
|
|
61
|
+
| `AGENTAPPROVE_TIMEOUT` | `300` | Seconds to wait for an approval response |
|
|
62
|
+
| `AGENTAPPROVE_FAIL_BEHAVIOR` | `ask` | Default policy when API is unreachable: `allow`, `deny`, or `ask` |
|
|
63
|
+
| `AGENTAPPROVE_PRIVACY` | `full` | What tool data is stored in event logs: `minimal`, `summary`, or `full` |
|
|
64
|
+
| `AGENTAPPROVE_DEBUG` | `false` | Write debug logs to `~/.agentapprove/hook-debug.log` |
|
|
65
|
+
| `AGENTAPPROVE_AGENT_NAME` / `AGENTAPPROVE_OPENCODE_NAME` | `OpenCode` | Display name for this agent |
|
|
66
|
+
| `AGENTAPPROVE_E2E_ENABLED` | `false` | Enable end-to-end encryption for non-approval event content |
|
|
67
|
+
|
|
68
|
+
## Supported agents
|
|
69
|
+
|
|
70
|
+
Agent Approve works with OpenCode, 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.
|
|
71
|
+
|
|
72
|
+
## Links
|
|
73
|
+
|
|
74
|
+
- [Agent Approve](https://agentapprove.com)
|
|
75
|
+
- [OpenCode](https://opencode.ai)
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,1062 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { fileURLToPath } from "url";
|
|
3
|
+
import { randomBytes as randomBytes2 } from "crypto";
|
|
4
|
+
|
|
5
|
+
// src/config.ts
|
|
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
|
+
import { execSync } from "child_process";
|
|
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
|
+
appendFileSync(DEBUG_LOG_PATH, `[${localTimestamp()}] [debug] Log rotated (exceeded 5MB, kept last 2MB)
|
|
39
|
+
`);
|
|
40
|
+
}
|
|
41
|
+
} catch {
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function localTimestamp() {
|
|
45
|
+
const d = /* @__PURE__ */ new Date();
|
|
46
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
47
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
|
48
|
+
}
|
|
49
|
+
function debugLog(message, hookName = "opencode-plugin") {
|
|
50
|
+
try {
|
|
51
|
+
ensureLogFile();
|
|
52
|
+
appendFileSync(DEBUG_LOG_PATH, `[${localTimestamp()}] [${hookName}] ${message}
|
|
53
|
+
`);
|
|
54
|
+
} catch {
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function debugLogRawInline(data) {
|
|
58
|
+
try {
|
|
59
|
+
ensureLogFile();
|
|
60
|
+
appendFileSync(DEBUG_LOG_PATH, `${data}
|
|
61
|
+
`);
|
|
62
|
+
} catch {
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// src/e2e-crypto.ts
|
|
67
|
+
function keyId(keyHex) {
|
|
68
|
+
const keyBytes = Buffer.from(keyHex, "hex");
|
|
69
|
+
const hash = createHash("sha256").update(keyBytes).digest();
|
|
70
|
+
return hash.subarray(0, 4).toString("hex");
|
|
71
|
+
}
|
|
72
|
+
function deriveEncKey(keyHex) {
|
|
73
|
+
const prefix = Buffer.from("agentapprove-e2e-enc:");
|
|
74
|
+
const keyBytes = Buffer.from(keyHex, "hex");
|
|
75
|
+
return createHash("sha256").update(Buffer.concat([prefix, keyBytes])).digest();
|
|
76
|
+
}
|
|
77
|
+
function deriveMacKey(keyHex) {
|
|
78
|
+
const prefix = Buffer.from("agentapprove-e2e-mac:");
|
|
79
|
+
const keyBytes = Buffer.from(keyHex, "hex");
|
|
80
|
+
return createHash("sha256").update(Buffer.concat([prefix, keyBytes])).digest();
|
|
81
|
+
}
|
|
82
|
+
function e2eEncrypt(keyHex, plaintext) {
|
|
83
|
+
const kid = keyId(keyHex);
|
|
84
|
+
const encKey = deriveEncKey(keyHex);
|
|
85
|
+
const macKey = deriveMacKey(keyHex);
|
|
86
|
+
const iv = randomBytes(16);
|
|
87
|
+
const cipher = createCipheriv("aes-256-ctr", encKey, iv);
|
|
88
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext, "utf-8"), cipher.final()]);
|
|
89
|
+
const ivHex = iv.toString("hex");
|
|
90
|
+
const ciphertextBase64 = ciphertext.toString("base64");
|
|
91
|
+
const hmac = createHmac("sha256", macKey).update(Buffer.concat([iv, ciphertext])).digest("hex");
|
|
92
|
+
return `E2E:v1:${kid}:${ivHex}:${ciphertextBase64}:${hmac}`;
|
|
93
|
+
}
|
|
94
|
+
function applyApprovalE2E(payload, userKey, serverKey) {
|
|
95
|
+
const sensitiveFields = {};
|
|
96
|
+
for (const field of ["command", "toolInput", "cwd"]) {
|
|
97
|
+
if (payload[field] != null) {
|
|
98
|
+
sensitiveFields[field] = payload[field];
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (Object.keys(sensitiveFields).length === 0) {
|
|
102
|
+
return payload;
|
|
103
|
+
}
|
|
104
|
+
const sensitiveJson = JSON.stringify(sensitiveFields);
|
|
105
|
+
const result = { ...payload };
|
|
106
|
+
delete result.command;
|
|
107
|
+
delete result.toolInput;
|
|
108
|
+
delete result.cwd;
|
|
109
|
+
const e2e = {
|
|
110
|
+
user: e2eEncrypt(userKey, sensitiveJson)
|
|
111
|
+
};
|
|
112
|
+
if (serverKey) {
|
|
113
|
+
e2e.server = e2eEncrypt(serverKey, sensitiveJson);
|
|
114
|
+
}
|
|
115
|
+
result.e2e = e2e;
|
|
116
|
+
return result;
|
|
117
|
+
}
|
|
118
|
+
function applyEventE2E(payload, userKey) {
|
|
119
|
+
const contentFields = {};
|
|
120
|
+
for (const field of [
|
|
121
|
+
"command",
|
|
122
|
+
"toolInput",
|
|
123
|
+
"response",
|
|
124
|
+
"responsePreview",
|
|
125
|
+
"text",
|
|
126
|
+
"textPreview",
|
|
127
|
+
"prompt",
|
|
128
|
+
"output",
|
|
129
|
+
"cwd"
|
|
130
|
+
]) {
|
|
131
|
+
if (payload[field] != null) {
|
|
132
|
+
contentFields[field] = payload[field];
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (Object.keys(contentFields).length === 0) {
|
|
136
|
+
return payload;
|
|
137
|
+
}
|
|
138
|
+
const e2ePayload = e2eEncrypt(userKey, JSON.stringify(contentFields));
|
|
139
|
+
const result = { ...payload };
|
|
140
|
+
delete result.command;
|
|
141
|
+
delete result.toolInput;
|
|
142
|
+
delete result.response;
|
|
143
|
+
delete result.responsePreview;
|
|
144
|
+
delete result.text;
|
|
145
|
+
delete result.textPreview;
|
|
146
|
+
delete result.prompt;
|
|
147
|
+
delete result.output;
|
|
148
|
+
delete result.cwd;
|
|
149
|
+
result.e2ePayload = e2ePayload;
|
|
150
|
+
return result;
|
|
151
|
+
}
|
|
152
|
+
var AA_DIR = join2(homedir2(), ".agentapprove");
|
|
153
|
+
var E2E_KEY_FILE = join2(AA_DIR, "e2e-key");
|
|
154
|
+
var E2E_ROOT_KEY_FILE = join2(AA_DIR, "e2e-root-key");
|
|
155
|
+
var E2E_ROTATION_FILE = join2(AA_DIR, "e2e-rotation.json");
|
|
156
|
+
function rotateE2eKey(oldKeyHex) {
|
|
157
|
+
const prefix = Buffer.from("agentapprove-e2e-rotate:");
|
|
158
|
+
const keyBytes = Buffer.from(oldKeyHex, "hex");
|
|
159
|
+
return createHash("sha256").update(Buffer.concat([prefix, keyBytes])).digest("hex");
|
|
160
|
+
}
|
|
161
|
+
function deriveEpochKey(rootKeyHex, epoch) {
|
|
162
|
+
let current = rootKeyHex;
|
|
163
|
+
for (let i = 0; i < epoch; i++) {
|
|
164
|
+
current = rotateE2eKey(current);
|
|
165
|
+
}
|
|
166
|
+
return current;
|
|
167
|
+
}
|
|
168
|
+
function migrateRootKey(debug = false) {
|
|
169
|
+
if (!existsSync2(E2E_KEY_FILE) || existsSync2(E2E_ROOT_KEY_FILE)) return;
|
|
170
|
+
try {
|
|
171
|
+
copyFileSync(E2E_KEY_FILE, E2E_ROOT_KEY_FILE);
|
|
172
|
+
try {
|
|
173
|
+
writeFileSync2(E2E_ROOT_KEY_FILE, readFileSync2(E2E_ROOT_KEY_FILE), { mode: 384 });
|
|
174
|
+
} catch {
|
|
175
|
+
}
|
|
176
|
+
if (!existsSync2(E2E_ROTATION_FILE)) {
|
|
177
|
+
const keyHex = readFileSync2(E2E_KEY_FILE, "utf-8").trim();
|
|
178
|
+
const kid = keyId(keyHex);
|
|
179
|
+
const config = {
|
|
180
|
+
rootKeyId: kid,
|
|
181
|
+
epoch: 0,
|
|
182
|
+
periodSeconds: 0,
|
|
183
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
184
|
+
};
|
|
185
|
+
writeFileSync2(E2E_ROTATION_FILE, JSON.stringify(config, null, 2), { mode: 384 });
|
|
186
|
+
}
|
|
187
|
+
if (debug) debugLog("Migrated e2e-key to e2e-root-key");
|
|
188
|
+
} catch {
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
function checkAndRotateKeys(currentKeyHex, debug = false) {
|
|
192
|
+
migrateRootKey(debug);
|
|
193
|
+
if (!existsSync2(E2E_ROTATION_FILE) || !existsSync2(E2E_ROOT_KEY_FILE)) {
|
|
194
|
+
return currentKeyHex;
|
|
195
|
+
}
|
|
196
|
+
let rotCfg;
|
|
197
|
+
try {
|
|
198
|
+
rotCfg = JSON.parse(readFileSync2(E2E_ROTATION_FILE, "utf-8"));
|
|
199
|
+
} catch {
|
|
200
|
+
return currentKeyHex;
|
|
201
|
+
}
|
|
202
|
+
const periodSeconds = rotCfg.periodSeconds || 0;
|
|
203
|
+
if (periodSeconds <= 0 || !rotCfg.startedAt) {
|
|
204
|
+
return currentKeyHex;
|
|
205
|
+
}
|
|
206
|
+
const startedTs = Math.floor(new Date(rotCfg.startedAt).getTime() / 1e3);
|
|
207
|
+
if (isNaN(startedTs)) return currentKeyHex;
|
|
208
|
+
const nowTs = Math.floor(Date.now() / 1e3);
|
|
209
|
+
const elapsed = nowTs - startedTs;
|
|
210
|
+
if (elapsed < 0) return currentKeyHex;
|
|
211
|
+
const expectedEpoch = Math.floor(elapsed / periodSeconds);
|
|
212
|
+
const currentEpoch = rotCfg.epoch || 0;
|
|
213
|
+
if (expectedEpoch <= currentEpoch) {
|
|
214
|
+
return currentKeyHex;
|
|
215
|
+
}
|
|
216
|
+
let rootKeyHex;
|
|
217
|
+
try {
|
|
218
|
+
rootKeyHex = readFileSync2(E2E_ROOT_KEY_FILE, "utf-8").trim();
|
|
219
|
+
} catch {
|
|
220
|
+
return currentKeyHex;
|
|
221
|
+
}
|
|
222
|
+
if (rootKeyHex.length !== 64) return currentKeyHex;
|
|
223
|
+
const newKey = deriveEpochKey(rootKeyHex, expectedEpoch);
|
|
224
|
+
if (!newKey) return currentKeyHex;
|
|
225
|
+
try {
|
|
226
|
+
writeFileSync2(E2E_KEY_FILE, newKey, { mode: 384 });
|
|
227
|
+
rotCfg.epoch = expectedEpoch;
|
|
228
|
+
writeFileSync2(E2E_ROTATION_FILE, JSON.stringify(rotCfg, null, 2), { mode: 384 });
|
|
229
|
+
} catch {
|
|
230
|
+
}
|
|
231
|
+
if (debug) debugLog(`E2E key rotated: epoch ${currentEpoch} -> ${expectedEpoch}`);
|
|
232
|
+
return newKey;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// src/config.ts
|
|
236
|
+
var CONFIG_PATH = join3(homedir3(), ".agentapprove", "env");
|
|
237
|
+
var KEYCHAIN_SERVICE = "com.agentapprove";
|
|
238
|
+
var KEYCHAIN_ACCOUNT = "api-token";
|
|
239
|
+
function parseConfigValue(content, key) {
|
|
240
|
+
const lines = content.split("\n");
|
|
241
|
+
let value;
|
|
242
|
+
for (const raw of lines) {
|
|
243
|
+
const line = raw.replace(/^export\s+/, "").trim();
|
|
244
|
+
if (!line.startsWith(`${key}=`)) continue;
|
|
245
|
+
let v = line.slice(key.length + 1);
|
|
246
|
+
if (v.startsWith('"') && v.endsWith('"') || v.startsWith("'") && v.endsWith("'")) {
|
|
247
|
+
v = v.slice(1, -1);
|
|
248
|
+
}
|
|
249
|
+
if (/[`$(){};<>|&!\\]/.test(v)) continue;
|
|
250
|
+
value = v;
|
|
251
|
+
}
|
|
252
|
+
return value;
|
|
253
|
+
}
|
|
254
|
+
function normalizeApiUrl(url) {
|
|
255
|
+
let result = url.trim();
|
|
256
|
+
while (result.endsWith("/")) {
|
|
257
|
+
result = result.slice(0, -1);
|
|
258
|
+
}
|
|
259
|
+
return result;
|
|
260
|
+
}
|
|
261
|
+
function normalizeApiVersion(version) {
|
|
262
|
+
const cleaned = version.trim().replace(/^\/+/, "").replace(/\/+$/, "");
|
|
263
|
+
return cleaned || "v001";
|
|
264
|
+
}
|
|
265
|
+
function getKeychainToken() {
|
|
266
|
+
if (process.platform !== "darwin") return void 0;
|
|
267
|
+
try {
|
|
268
|
+
const result = execSync(
|
|
269
|
+
`security find-generic-password -s "${KEYCHAIN_SERVICE}" -a "${KEYCHAIN_ACCOUNT}" -w 2>/dev/null`,
|
|
270
|
+
{ encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }
|
|
271
|
+
).trim();
|
|
272
|
+
return result || void 0;
|
|
273
|
+
} catch {
|
|
274
|
+
return void 0;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
function checkPermissions(logger) {
|
|
278
|
+
if (!existsSync3(CONFIG_PATH)) return;
|
|
279
|
+
try {
|
|
280
|
+
const stat = statSync2(CONFIG_PATH);
|
|
281
|
+
const mode = stat.mode & 511;
|
|
282
|
+
if (mode & 54) {
|
|
283
|
+
logger?.warn(
|
|
284
|
+
`Config file ${CONFIG_PATH} is group or world readable/writable. Run: chmod 600 ${CONFIG_PATH}`
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
} catch {
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
function loadConfig(logger) {
|
|
291
|
+
checkPermissions(logger);
|
|
292
|
+
let fileContent = "";
|
|
293
|
+
if (existsSync3(CONFIG_PATH)) {
|
|
294
|
+
fileContent = readFileSync3(CONFIG_PATH, "utf-8");
|
|
295
|
+
}
|
|
296
|
+
const token = process.env.AGENTAPPROVE_TOKEN || getKeychainToken() || parseConfigValue(fileContent, "AGENTAPPROVE_TOKEN") || "";
|
|
297
|
+
if (!token) {
|
|
298
|
+
logger?.warn("No Agent Approve token found. Run the Agent Approve installer to set up.");
|
|
299
|
+
}
|
|
300
|
+
const rawApiUrl = process.env.AGENTAPPROVE_API || parseConfigValue(fileContent, "AGENTAPPROVE_API") || "https://api.agentapprove.com";
|
|
301
|
+
const rawApiVersion = process.env.AGENTAPPROVE_API_VERSION || parseConfigValue(fileContent, "AGENTAPPROVE_API_VERSION") || "v001";
|
|
302
|
+
const apiUrl = normalizeApiUrl(rawApiUrl);
|
|
303
|
+
const apiVersion = normalizeApiVersion(rawApiVersion);
|
|
304
|
+
const timeout = parseInt(process.env.AGENTAPPROVE_TIMEOUT || "", 10) || parseInt(parseConfigValue(fileContent, "AGENTAPPROVE_TIMEOUT") || "", 10) || 300;
|
|
305
|
+
const failBehavior = process.env.AGENTAPPROVE_FAIL_BEHAVIOR || parseConfigValue(fileContent, "AGENTAPPROVE_FAIL_BEHAVIOR") || "ask";
|
|
306
|
+
const privacyTier = process.env.AGENTAPPROVE_PRIVACY || parseConfigValue(fileContent, "AGENTAPPROVE_PRIVACY") || "full";
|
|
307
|
+
const debug = process.env.AGENTAPPROVE_DEBUG === "true" || process.env.AGENTAPPROVE_DEBUG_LOG === "true" || parseConfigValue(fileContent, "AGENTAPPROVE_DEBUG") === "true" || parseConfigValue(fileContent, "AGENTAPPROVE_DEBUG_LOG") === "true" || false;
|
|
308
|
+
const agentName = process.env.AGENTAPPROVE_AGENT_NAME || parseConfigValue(fileContent, "AGENTAPPROVE_OPENCODE_NAME") || "OpenCode";
|
|
309
|
+
const e2eEnabled = process.env.AGENTAPPROVE_E2E_ENABLED === "true" || parseConfigValue(fileContent, "AGENTAPPROVE_E2E_ENABLED") === "true";
|
|
310
|
+
const e2eUserKeyPath = join3(homedir3(), ".agentapprove", "e2e-key");
|
|
311
|
+
const e2eServerKeyPath = join3(homedir3(), ".agentapprove", "e2e-server-key");
|
|
312
|
+
let e2eUserKey;
|
|
313
|
+
let e2eServerKey;
|
|
314
|
+
if (e2eEnabled) {
|
|
315
|
+
if (existsSync3(e2eUserKeyPath)) {
|
|
316
|
+
try {
|
|
317
|
+
e2eUserKey = readFileSync3(e2eUserKeyPath, "utf-8").trim();
|
|
318
|
+
} catch {
|
|
319
|
+
logger?.warn("Failed to load E2E user key");
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
if (existsSync3(e2eServerKeyPath)) {
|
|
323
|
+
try {
|
|
324
|
+
e2eServerKey = readFileSync3(e2eServerKeyPath, "utf-8").trim();
|
|
325
|
+
} catch {
|
|
326
|
+
logger?.warn("Failed to load E2E server key");
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
if (e2eUserKey) {
|
|
330
|
+
e2eUserKey = checkAndRotateKeys(e2eUserKey, debug) || e2eUserKey;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
if (debug) {
|
|
334
|
+
const e2eStatus = !e2eEnabled ? "disabled" : !e2eUserKey ? "enabled but user key missing" : `enabled, keyId=${keyId(e2eUserKey)}`;
|
|
335
|
+
debugLog(`Config loaded: e2e=${e2eStatus}, serverKey=${e2eServerKey ? "present" : "missing"}, api=${apiUrl}`);
|
|
336
|
+
}
|
|
337
|
+
return {
|
|
338
|
+
apiUrl,
|
|
339
|
+
apiVersion,
|
|
340
|
+
token,
|
|
341
|
+
timeout,
|
|
342
|
+
failBehavior,
|
|
343
|
+
privacyTier,
|
|
344
|
+
debug,
|
|
345
|
+
hookVersion: "1.1.0",
|
|
346
|
+
agentType: "opencode",
|
|
347
|
+
agentName,
|
|
348
|
+
e2eEnabled,
|
|
349
|
+
e2eUserKey,
|
|
350
|
+
e2eServerKey
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// src/api-client.ts
|
|
355
|
+
import { request as httpsRequest } from "https";
|
|
356
|
+
import { request as httpRequest } from "http";
|
|
357
|
+
import { URL } from "url";
|
|
358
|
+
|
|
359
|
+
// src/hmac.ts
|
|
360
|
+
import crypto from "crypto";
|
|
361
|
+
import { readFileSync as readFileSync4 } from "fs";
|
|
362
|
+
function generateHMACSignature(body, token, hookVersion) {
|
|
363
|
+
const timestamp = Math.floor(Date.now() / 1e3);
|
|
364
|
+
const message = `${hookVersion}:${timestamp}:${body}`;
|
|
365
|
+
const signature = crypto.createHmac("sha256", token).update(message).digest("hex");
|
|
366
|
+
return { timestamp, signature };
|
|
367
|
+
}
|
|
368
|
+
function computePluginHash(pluginPath) {
|
|
369
|
+
try {
|
|
370
|
+
const content = readFileSync4(pluginPath);
|
|
371
|
+
return crypto.createHash("sha256").update(content).digest("hex");
|
|
372
|
+
} catch {
|
|
373
|
+
return "";
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
function buildHMACHeaders(body, token, hookVersion, pluginHash) {
|
|
377
|
+
const { timestamp, signature } = generateHMACSignature(body, token, hookVersion);
|
|
378
|
+
return {
|
|
379
|
+
"X-Hook-Version": hookVersion,
|
|
380
|
+
"X-Hook-Timestamp": String(timestamp),
|
|
381
|
+
"X-Hook-Signature": signature,
|
|
382
|
+
"X-Hook-Hash": pluginHash
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// src/privacy.ts
|
|
387
|
+
var SUMMARY_MAX_LENGTH = 50;
|
|
388
|
+
var FILEPATH_MAX_LENGTH = 100;
|
|
389
|
+
var SENSITIVE_PATTERNS = [
|
|
390
|
+
/(?:password|secret|token|key|api_key|auth)=[^\s&]+/gi,
|
|
391
|
+
/Bearer [a-zA-Z0-9_-]+/gi
|
|
392
|
+
];
|
|
393
|
+
function redactSensitive(value) {
|
|
394
|
+
let result = value;
|
|
395
|
+
for (const pattern of SENSITIVE_PATTERNS) {
|
|
396
|
+
result = result.replace(pattern, "***REDACTED***");
|
|
397
|
+
}
|
|
398
|
+
return result;
|
|
399
|
+
}
|
|
400
|
+
function truncate(value, maxLen) {
|
|
401
|
+
if (!value) return value;
|
|
402
|
+
if (value.length <= maxLen) return value;
|
|
403
|
+
return value.slice(0, maxLen) + "...";
|
|
404
|
+
}
|
|
405
|
+
function summarizeToolInput(value) {
|
|
406
|
+
const inputStr = redactSensitive(JSON.stringify(value));
|
|
407
|
+
const summary = inputStr.length > SUMMARY_MAX_LENGTH ? inputStr.slice(0, SUMMARY_MAX_LENGTH) + "..." : inputStr;
|
|
408
|
+
return { _summary: summary };
|
|
409
|
+
}
|
|
410
|
+
function applyPrivacyFilter(request, privacyTier) {
|
|
411
|
+
if (privacyTier === "full") {
|
|
412
|
+
return request;
|
|
413
|
+
}
|
|
414
|
+
const filtered = { ...request };
|
|
415
|
+
if (privacyTier === "minimal") {
|
|
416
|
+
delete filtered.command;
|
|
417
|
+
filtered.toolInput = void 0;
|
|
418
|
+
delete filtered.cwd;
|
|
419
|
+
return filtered;
|
|
420
|
+
}
|
|
421
|
+
if (filtered.command) {
|
|
422
|
+
filtered.command = truncate(redactSensitive(filtered.command), SUMMARY_MAX_LENGTH);
|
|
423
|
+
}
|
|
424
|
+
if (filtered.toolInput) {
|
|
425
|
+
filtered.toolInput = summarizeToolInput(filtered.toolInput);
|
|
426
|
+
}
|
|
427
|
+
return filtered;
|
|
428
|
+
}
|
|
429
|
+
var CONTENT_FIELDS = [
|
|
430
|
+
"command",
|
|
431
|
+
"toolInput",
|
|
432
|
+
"response",
|
|
433
|
+
"responsePreview",
|
|
434
|
+
"text",
|
|
435
|
+
"textPreview",
|
|
436
|
+
"prompt",
|
|
437
|
+
"output",
|
|
438
|
+
"cwd"
|
|
439
|
+
];
|
|
440
|
+
function applyEventPrivacyFilter(event, privacyTier) {
|
|
441
|
+
if (privacyTier === "full") {
|
|
442
|
+
return event;
|
|
443
|
+
}
|
|
444
|
+
const filtered = { ...event };
|
|
445
|
+
if (privacyTier === "minimal") {
|
|
446
|
+
for (const field of CONTENT_FIELDS) {
|
|
447
|
+
delete filtered[field];
|
|
448
|
+
}
|
|
449
|
+
if (typeof filtered.textLength === "undefined" && typeof event.text === "string") {
|
|
450
|
+
filtered.textLength = event.text.length;
|
|
451
|
+
}
|
|
452
|
+
return filtered;
|
|
453
|
+
}
|
|
454
|
+
for (const field of CONTENT_FIELDS) {
|
|
455
|
+
const val = filtered[field];
|
|
456
|
+
if (val === void 0 || val === null) continue;
|
|
457
|
+
if (field === "toolInput") {
|
|
458
|
+
filtered[field] = summarizeToolInput(val);
|
|
459
|
+
} else if (typeof val === "string") {
|
|
460
|
+
filtered[field] = truncate(redactSensitive(val), SUMMARY_MAX_LENGTH);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
if (typeof filtered.filePath === "string") {
|
|
464
|
+
filtered.filePath = truncate(filtered.filePath, FILEPATH_MAX_LENGTH);
|
|
465
|
+
}
|
|
466
|
+
return filtered;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// src/config-sync.ts
|
|
470
|
+
import { readFileSync as readFileSync5, writeFileSync as writeFileSync3, existsSync as existsSync4, renameSync } from "fs";
|
|
471
|
+
import { join as join4 } from "path";
|
|
472
|
+
import { homedir as homedir4 } from "os";
|
|
473
|
+
function getAADir() {
|
|
474
|
+
return process.env.__AGENTAPPROVE_TEST_DIR || join4(homedir4(), ".agentapprove");
|
|
475
|
+
}
|
|
476
|
+
function getEnvPath() {
|
|
477
|
+
return join4(getAADir(), "env");
|
|
478
|
+
}
|
|
479
|
+
function getRotationFile() {
|
|
480
|
+
return join4(getAADir(), "e2e-rotation.json");
|
|
481
|
+
}
|
|
482
|
+
var VALID_PRIVACY = /* @__PURE__ */ new Set(["minimal", "summary", "full"]);
|
|
483
|
+
var VALID_FAIL = /* @__PURE__ */ new Set(["allow", "deny", "ask"]);
|
|
484
|
+
function updateEnvValues(updates) {
|
|
485
|
+
const envPath = getEnvPath();
|
|
486
|
+
if (!existsSync4(envPath)) return;
|
|
487
|
+
const validUpdates = Object.fromEntries(
|
|
488
|
+
Object.entries(updates).filter(([, v]) => !/[`$(){};<>|&!\\]/.test(v))
|
|
489
|
+
);
|
|
490
|
+
const updateKeys = new Set(Object.keys(validUpdates));
|
|
491
|
+
if (updateKeys.size === 0) return;
|
|
492
|
+
try {
|
|
493
|
+
const content = readFileSync5(envPath, "utf-8");
|
|
494
|
+
const lines = content.split("\n");
|
|
495
|
+
const filtered = lines.filter((line) => {
|
|
496
|
+
const trimmed = line.replace(/^export\s+/, "").trim();
|
|
497
|
+
const eqIdx = trimmed.indexOf("=");
|
|
498
|
+
if (eqIdx <= 0) return true;
|
|
499
|
+
return !updateKeys.has(trimmed.slice(0, eqIdx));
|
|
500
|
+
});
|
|
501
|
+
for (const [key, value] of Object.entries(validUpdates)) {
|
|
502
|
+
filtered.push(`export ${key}=${value}`);
|
|
503
|
+
}
|
|
504
|
+
const tmpPath = `${envPath}.tmp.${process.pid}`;
|
|
505
|
+
writeFileSync3(tmpPath, filtered.join("\n"), { mode: 384 });
|
|
506
|
+
renameSync(tmpPath, envPath);
|
|
507
|
+
} catch {
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
function syncRotationConfig(serverPeriod, serverStartedAt, debug) {
|
|
511
|
+
const rotationFile = getRotationFile();
|
|
512
|
+
if (!existsSync4(rotationFile)) return;
|
|
513
|
+
try {
|
|
514
|
+
const raw = readFileSync5(rotationFile, "utf-8");
|
|
515
|
+
const config = JSON.parse(raw);
|
|
516
|
+
const currentPeriod = config.periodSeconds ?? 0;
|
|
517
|
+
const newPeriod = serverPeriod ?? 0;
|
|
518
|
+
let needsUpdate = false;
|
|
519
|
+
if (typeof newPeriod === "number" && newPeriod !== currentPeriod) {
|
|
520
|
+
config.periodSeconds = newPeriod;
|
|
521
|
+
needsUpdate = true;
|
|
522
|
+
}
|
|
523
|
+
if (serverStartedAt && (!config.startedAt || config.startedAt === "null")) {
|
|
524
|
+
config.startedAt = serverStartedAt;
|
|
525
|
+
needsUpdate = true;
|
|
526
|
+
}
|
|
527
|
+
if (needsUpdate) {
|
|
528
|
+
const tmpPath = `${rotationFile}.tmp.${process.pid}`;
|
|
529
|
+
writeFileSync3(tmpPath, JSON.stringify(config, null, 2), { mode: 384 });
|
|
530
|
+
renameSync(tmpPath, rotationFile);
|
|
531
|
+
if (debug) {
|
|
532
|
+
debugLog(`E2E rotation synced: periodSeconds=${newPeriod}`);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
} catch {
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
function processConfigSync(parsed, config) {
|
|
539
|
+
const sync = parsed.configSync;
|
|
540
|
+
if (!sync || !sync.configSetAt) return;
|
|
541
|
+
const localSetAt = getLocalConfigSetAt();
|
|
542
|
+
if (sync.configSetAt <= localSetAt) return;
|
|
543
|
+
if (config.debug) {
|
|
544
|
+
debugLog(`Config sync: server=${sync.configSetAt} > local=${localSetAt}, updating...`);
|
|
545
|
+
}
|
|
546
|
+
const envUpdates = {};
|
|
547
|
+
if (sync.privacyTier && VALID_PRIVACY.has(sync.privacyTier)) {
|
|
548
|
+
envUpdates["AGENTAPPROVE_PRIVACY"] = sync.privacyTier;
|
|
549
|
+
config.privacyTier = sync.privacyTier;
|
|
550
|
+
if (config.debug) debugLog(`Config synced: privacy=${sync.privacyTier}`);
|
|
551
|
+
}
|
|
552
|
+
if (sync.timeoutSeconds != null) {
|
|
553
|
+
const t = sync.timeoutSeconds;
|
|
554
|
+
if (t >= 30 && t <= 600) {
|
|
555
|
+
envUpdates["AGENTAPPROVE_TIMEOUT"] = String(t);
|
|
556
|
+
config.timeout = t;
|
|
557
|
+
if (config.debug) debugLog(`Config synced: timeout=${t}`);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
if (sync.failBehavior && VALID_FAIL.has(sync.failBehavior)) {
|
|
561
|
+
envUpdates["AGENTAPPROVE_FAIL_BEHAVIOR"] = sync.failBehavior;
|
|
562
|
+
config.failBehavior = sync.failBehavior;
|
|
563
|
+
if (config.debug) debugLog(`Config synced: failBehavior=${sync.failBehavior}`);
|
|
564
|
+
}
|
|
565
|
+
if (sync.e2eEnabled === true || sync.e2eEnabled === false) {
|
|
566
|
+
envUpdates["AGENTAPPROVE_E2E_ENABLED"] = String(sync.e2eEnabled);
|
|
567
|
+
config.e2eEnabled = sync.e2eEnabled;
|
|
568
|
+
if (config.debug) debugLog(`Config synced: e2eEnabled=${sync.e2eEnabled}`);
|
|
569
|
+
}
|
|
570
|
+
syncRotationConfig(sync.e2eRotationPeriod, sync.e2eRotationStartedAt, config.debug);
|
|
571
|
+
if (config.e2eEnabled && config.e2eUserKey) {
|
|
572
|
+
const rotatedKey = checkAndRotateKeys(config.e2eUserKey, config.debug);
|
|
573
|
+
if (rotatedKey && rotatedKey !== config.e2eUserKey) {
|
|
574
|
+
config.e2eUserKey = rotatedKey;
|
|
575
|
+
if (config.debug) debugLog("Key rotated after config sync");
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
envUpdates["AGENTAPPROVE_CONFIG_SET_AT"] = String(sync.configSetAt);
|
|
579
|
+
updateEnvValues(envUpdates);
|
|
580
|
+
}
|
|
581
|
+
function getLocalConfigSetAt() {
|
|
582
|
+
const envPath = getEnvPath();
|
|
583
|
+
if (!existsSync4(envPath)) return 0;
|
|
584
|
+
try {
|
|
585
|
+
const content = readFileSync5(envPath, "utf-8");
|
|
586
|
+
for (const raw of content.split("\n")) {
|
|
587
|
+
const line = raw.replace(/^export\s+/, "").trim();
|
|
588
|
+
if (line.startsWith("AGENTAPPROVE_CONFIG_SET_AT=")) {
|
|
589
|
+
const val = line.slice("AGENTAPPROVE_CONFIG_SET_AT=".length).replace(/^["']|["']$/g, "");
|
|
590
|
+
const num = parseInt(val, 10);
|
|
591
|
+
return isNaN(num) ? 0 : num;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
} catch {
|
|
595
|
+
}
|
|
596
|
+
return 0;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// src/api-client.ts
|
|
600
|
+
var cachedPluginHash;
|
|
601
|
+
function getPluginHash(pluginPath, debug = false) {
|
|
602
|
+
if (!cachedPluginHash) {
|
|
603
|
+
cachedPluginHash = computePluginHash(pluginPath);
|
|
604
|
+
if (cachedPluginHash && debug) {
|
|
605
|
+
debugLog(`Plugin hash computed: ${cachedPluginHash.slice(0, 16)}...`);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
return cachedPluginHash || "";
|
|
609
|
+
}
|
|
610
|
+
function httpPost(url, body, headers, timeoutMs) {
|
|
611
|
+
return new Promise((resolve, reject) => {
|
|
612
|
+
const parsed = new URL(url);
|
|
613
|
+
const isHttps = parsed.protocol === "https:";
|
|
614
|
+
const reqFn = isHttps ? httpsRequest : httpRequest;
|
|
615
|
+
const req = reqFn(
|
|
616
|
+
{
|
|
617
|
+
hostname: parsed.hostname,
|
|
618
|
+
port: parsed.port || (isHttps ? 443 : 80),
|
|
619
|
+
path: parsed.pathname + parsed.search,
|
|
620
|
+
method: "POST",
|
|
621
|
+
headers: {
|
|
622
|
+
"Content-Type": "application/json",
|
|
623
|
+
"Content-Length": Buffer.byteLength(body),
|
|
624
|
+
...headers
|
|
625
|
+
},
|
|
626
|
+
timeout: timeoutMs
|
|
627
|
+
},
|
|
628
|
+
(res) => {
|
|
629
|
+
let data = "";
|
|
630
|
+
res.on("data", (chunk) => {
|
|
631
|
+
data += chunk;
|
|
632
|
+
});
|
|
633
|
+
res.on("end", () => {
|
|
634
|
+
resolve({ status: res.statusCode || 0, body: data });
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
);
|
|
638
|
+
req.on("error", reject);
|
|
639
|
+
req.on("timeout", () => {
|
|
640
|
+
req.destroy();
|
|
641
|
+
reject(new Error(`Request timed out after ${timeoutMs}ms`));
|
|
642
|
+
});
|
|
643
|
+
req.write(body);
|
|
644
|
+
req.end();
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
async function sendApprovalRequest(request, config, pluginPath) {
|
|
648
|
+
if (!config.token) {
|
|
649
|
+
throw new Error("No Agent Approve token configured");
|
|
650
|
+
}
|
|
651
|
+
let payload;
|
|
652
|
+
if (config.e2eEnabled && config.e2eUserKey) {
|
|
653
|
+
payload = applyApprovalE2E(request, config.e2eUserKey, config.e2eServerKey);
|
|
654
|
+
if (config.debug) {
|
|
655
|
+
debugLog("E2E encryption applied to approval request");
|
|
656
|
+
}
|
|
657
|
+
} else {
|
|
658
|
+
payload = applyPrivacyFilter(request, config.privacyTier);
|
|
659
|
+
}
|
|
660
|
+
const bodyStr = JSON.stringify(payload);
|
|
661
|
+
const pluginHash = getPluginHash(pluginPath, config.debug);
|
|
662
|
+
const hmacHeaders = buildHMACHeaders(bodyStr, config.token, config.hookVersion, pluginHash);
|
|
663
|
+
const headers = {
|
|
664
|
+
"Authorization": `Bearer ${config.token}`,
|
|
665
|
+
...hmacHeaders
|
|
666
|
+
};
|
|
667
|
+
const url = `${config.apiUrl}/${config.apiVersion}/approve`;
|
|
668
|
+
if (config.debug) {
|
|
669
|
+
debugLog(`Requesting approval from ${url}`);
|
|
670
|
+
debugLog(`=== SENT TO ${url} ===`);
|
|
671
|
+
debugLogRawInline(bodyStr);
|
|
672
|
+
debugLog("=== END SENT ===");
|
|
673
|
+
}
|
|
674
|
+
const response = await httpPost(url, bodyStr, headers, config.timeout * 1e3);
|
|
675
|
+
if (config.debug) {
|
|
676
|
+
debugLog(`Response: ${response.body || "<empty>"}`);
|
|
677
|
+
}
|
|
678
|
+
if (response.status !== 200) {
|
|
679
|
+
throw new Error(`API returned status ${response.status}: ${response.body.slice(0, 200)}`);
|
|
680
|
+
}
|
|
681
|
+
let parsed;
|
|
682
|
+
try {
|
|
683
|
+
parsed = JSON.parse(response.body);
|
|
684
|
+
} catch {
|
|
685
|
+
throw new Error(`Failed to parse API response: ${response.body.slice(0, 200)}`);
|
|
686
|
+
}
|
|
687
|
+
processConfigSync(parsed, config);
|
|
688
|
+
return parsed;
|
|
689
|
+
}
|
|
690
|
+
async function sendEvent(event, config, pluginPath) {
|
|
691
|
+
if (!config.token) return;
|
|
692
|
+
const eventType = event.eventType;
|
|
693
|
+
const toolName = event.toolName;
|
|
694
|
+
if (config.debug) {
|
|
695
|
+
debugLog(`Sending ${eventType || "event"}${toolName ? ` (${toolName})` : ""} (privacy: ${config.privacyTier})`);
|
|
696
|
+
}
|
|
697
|
+
try {
|
|
698
|
+
let payload = applyEventPrivacyFilter(event, config.privacyTier);
|
|
699
|
+
if (config.e2eEnabled && config.e2eUserKey) {
|
|
700
|
+
payload = applyEventE2E(payload, config.e2eUserKey);
|
|
701
|
+
if (config.debug) {
|
|
702
|
+
debugLog(`E2E applied to event (type=${eventType})`);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
const bodyStr = JSON.stringify(payload);
|
|
706
|
+
const pluginHash = getPluginHash(pluginPath, config.debug);
|
|
707
|
+
const hmacHeaders = buildHMACHeaders(bodyStr, config.token, config.hookVersion, pluginHash);
|
|
708
|
+
const headers = {
|
|
709
|
+
"Authorization": `Bearer ${config.token}`,
|
|
710
|
+
...hmacHeaders
|
|
711
|
+
};
|
|
712
|
+
const url = `${config.apiUrl}/${config.apiVersion}/events`;
|
|
713
|
+
if (config.debug) {
|
|
714
|
+
debugLog(`=== SENT TO ${url} ===`);
|
|
715
|
+
debugLogRawInline(bodyStr);
|
|
716
|
+
debugLog("=== END SENT ===");
|
|
717
|
+
}
|
|
718
|
+
const response = await httpPost(url, bodyStr, headers, 5e3);
|
|
719
|
+
if (config.debug) {
|
|
720
|
+
debugLog(`send_event response: ${response.body || "<empty>"}`);
|
|
721
|
+
}
|
|
722
|
+
if (response.status === 200) {
|
|
723
|
+
try {
|
|
724
|
+
const parsed = JSON.parse(response.body);
|
|
725
|
+
processConfigSync(parsed, config);
|
|
726
|
+
} catch {
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
} catch (err) {
|
|
730
|
+
if (config.debug) {
|
|
731
|
+
debugLog(`Failed to send ${eventType || "event"}: ${err instanceof Error ? err.message : String(err)}`);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// src/index.ts
|
|
737
|
+
var pluginFilePath;
|
|
738
|
+
try {
|
|
739
|
+
pluginFilePath = fileURLToPath(import.meta.url);
|
|
740
|
+
} catch {
|
|
741
|
+
pluginFilePath = __filename;
|
|
742
|
+
}
|
|
743
|
+
var sessionId = randomBytes2(12).toString("hex");
|
|
744
|
+
var COMPACTION_DEDUP_WINDOW_MS = 2e3;
|
|
745
|
+
var openCodeClient = void 0;
|
|
746
|
+
var lastCompactionEventAt = 0;
|
|
747
|
+
function classifyTool(toolName) {
|
|
748
|
+
const lower = toolName.toLowerCase();
|
|
749
|
+
if (lower === "bash") {
|
|
750
|
+
return { toolType: "shell_command", displayName: toolName };
|
|
751
|
+
}
|
|
752
|
+
if (lower === "write" || lower === "edit" || lower === "patch") {
|
|
753
|
+
return { toolType: "file_write", displayName: toolName };
|
|
754
|
+
}
|
|
755
|
+
if (lower === "read" || lower === "grep" || lower === "glob" || lower === "list") {
|
|
756
|
+
return { toolType: "file_read", displayName: toolName };
|
|
757
|
+
}
|
|
758
|
+
if (lower === "webfetch" || lower === "websearch") {
|
|
759
|
+
return { toolType: "network", displayName: toolName };
|
|
760
|
+
}
|
|
761
|
+
if (lower === "question") {
|
|
762
|
+
return { toolType: "user_question", displayName: toolName };
|
|
763
|
+
}
|
|
764
|
+
if (lower.startsWith("mcp__") || lower.startsWith("mcp_")) {
|
|
765
|
+
return { toolType: "mcp_tool", displayName: toolName };
|
|
766
|
+
}
|
|
767
|
+
return { toolType: "tool_use", displayName: toolName };
|
|
768
|
+
}
|
|
769
|
+
function extractCommand(toolName, params) {
|
|
770
|
+
const lower = toolName.toLowerCase();
|
|
771
|
+
if (lower === "bash") {
|
|
772
|
+
return params.command || void 0;
|
|
773
|
+
}
|
|
774
|
+
if (lower === "write" || lower === "edit" || lower === "patch") {
|
|
775
|
+
return params.file_path || params.path || void 0;
|
|
776
|
+
}
|
|
777
|
+
if (lower === "read") {
|
|
778
|
+
return params.file_path || params.path || void 0;
|
|
779
|
+
}
|
|
780
|
+
if (lower === "grep") {
|
|
781
|
+
return params.pattern || void 0;
|
|
782
|
+
}
|
|
783
|
+
if (lower === "glob") {
|
|
784
|
+
return params.pattern || void 0;
|
|
785
|
+
}
|
|
786
|
+
if (lower === "webfetch") {
|
|
787
|
+
return params.url || void 0;
|
|
788
|
+
}
|
|
789
|
+
if (lower === "websearch") {
|
|
790
|
+
return params.query || void 0;
|
|
791
|
+
}
|
|
792
|
+
if (lower === "question") {
|
|
793
|
+
return params.question || void 0;
|
|
794
|
+
}
|
|
795
|
+
return void 0;
|
|
796
|
+
}
|
|
797
|
+
function handleFailBehavior(config, error, toolName) {
|
|
798
|
+
if (config.debug) {
|
|
799
|
+
debugLog(`API error: ${error.message}, failBehavior: ${config.failBehavior}`);
|
|
800
|
+
}
|
|
801
|
+
switch (config.failBehavior) {
|
|
802
|
+
case "deny":
|
|
803
|
+
return { status: "deny" };
|
|
804
|
+
case "allow":
|
|
805
|
+
return { status: "allow" };
|
|
806
|
+
case "ask":
|
|
807
|
+
default:
|
|
808
|
+
return void 0;
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
function mapEventType(eventName) {
|
|
812
|
+
switch (eventName) {
|
|
813
|
+
case "session.created":
|
|
814
|
+
return "session_start";
|
|
815
|
+
case "session.idle":
|
|
816
|
+
return "stop";
|
|
817
|
+
case "session.compacted":
|
|
818
|
+
return "context_compact";
|
|
819
|
+
case "session.error":
|
|
820
|
+
return "error";
|
|
821
|
+
case "message.updated":
|
|
822
|
+
return "response";
|
|
823
|
+
default:
|
|
824
|
+
return void 0;
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
function shouldSkipCompactionEvent() {
|
|
828
|
+
const now = Date.now();
|
|
829
|
+
if (now - lastCompactionEventAt < COMPACTION_DEDUP_WINDOW_MS) {
|
|
830
|
+
return true;
|
|
831
|
+
}
|
|
832
|
+
lastCompactionEventAt = now;
|
|
833
|
+
return false;
|
|
834
|
+
}
|
|
835
|
+
function plugin(context) {
|
|
836
|
+
const config = loadConfig();
|
|
837
|
+
openCodeClient = context.client;
|
|
838
|
+
if (!config.token) {
|
|
839
|
+
console.warn(
|
|
840
|
+
"Agent Approve: No token found. Run the Agent Approve installer to pair with your account."
|
|
841
|
+
);
|
|
842
|
+
return {};
|
|
843
|
+
}
|
|
844
|
+
if (config.debug) {
|
|
845
|
+
debugLog(`Plugin loaded, API: ${config.apiUrl}, agent: ${config.agentName}`);
|
|
846
|
+
}
|
|
847
|
+
return {
|
|
848
|
+
// -------------------------------------------------------------------
|
|
849
|
+
// permission.ask: Primary approval gate (blocking)
|
|
850
|
+
// -------------------------------------------------------------------
|
|
851
|
+
"permission.ask": async (input, output) => {
|
|
852
|
+
const toolName = input.tool.name;
|
|
853
|
+
const params = input.tool.input || {};
|
|
854
|
+
const { toolType, displayName } = classifyTool(toolName);
|
|
855
|
+
const command = extractCommand(toolName, params);
|
|
856
|
+
const request = {
|
|
857
|
+
toolName: displayName,
|
|
858
|
+
toolType,
|
|
859
|
+
command,
|
|
860
|
+
toolInput: params,
|
|
861
|
+
agent: config.agentType,
|
|
862
|
+
agentName: config.agentName,
|
|
863
|
+
hookType: "permission_ask",
|
|
864
|
+
sessionId,
|
|
865
|
+
conversationId: sessionId,
|
|
866
|
+
cwd: params.workdir || params.cwd || void 0,
|
|
867
|
+
projectPath: process.cwd(),
|
|
868
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
869
|
+
};
|
|
870
|
+
try {
|
|
871
|
+
const response = await sendApprovalRequest(request, config, pluginFilePath);
|
|
872
|
+
if (response.decision === "approve" || response.decision === "allow") {
|
|
873
|
+
if (config.debug) {
|
|
874
|
+
debugLog(`Tool "${toolName}" approved${response.reason ? ": " + response.reason : ""}`);
|
|
875
|
+
}
|
|
876
|
+
output.status = "allow";
|
|
877
|
+
if (response.reason) output.reason = response.reason;
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
if (config.debug) {
|
|
881
|
+
debugLog(`Tool "${toolName}" denied${response.reason ? ": " + response.reason : ""}`);
|
|
882
|
+
}
|
|
883
|
+
output.status = "deny";
|
|
884
|
+
output.reason = response.reason || "Denied by Agent Approve";
|
|
885
|
+
} catch (error) {
|
|
886
|
+
const result = handleFailBehavior(
|
|
887
|
+
config,
|
|
888
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
889
|
+
toolName
|
|
890
|
+
);
|
|
891
|
+
if (result) {
|
|
892
|
+
output.status = result.status;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
},
|
|
896
|
+
// -------------------------------------------------------------------
|
|
897
|
+
// tool.execute.before: Tool start monitoring (non-blocking)
|
|
898
|
+
// -------------------------------------------------------------------
|
|
899
|
+
"tool.execute.before": async (input) => {
|
|
900
|
+
const toolName = input.tool.name;
|
|
901
|
+
const params = input.tool.input || {};
|
|
902
|
+
const { toolType } = classifyTool(toolName);
|
|
903
|
+
void sendEvent({
|
|
904
|
+
toolName,
|
|
905
|
+
toolType,
|
|
906
|
+
eventType: "tool_start",
|
|
907
|
+
agent: config.agentType,
|
|
908
|
+
agentName: config.agentName,
|
|
909
|
+
hookType: "tool_execute_before",
|
|
910
|
+
sessionId,
|
|
911
|
+
conversationId: sessionId,
|
|
912
|
+
command: extractCommand(toolName, params),
|
|
913
|
+
toolInput: params,
|
|
914
|
+
cwd: params.workdir || params.cwd || void 0,
|
|
915
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
916
|
+
}, config, pluginFilePath);
|
|
917
|
+
},
|
|
918
|
+
// -------------------------------------------------------------------
|
|
919
|
+
// tool.execute.after: Tool completion logging (non-blocking)
|
|
920
|
+
// -------------------------------------------------------------------
|
|
921
|
+
"tool.execute.after": async (input) => {
|
|
922
|
+
const toolName = input.tool.name;
|
|
923
|
+
const params = input.tool.input || {};
|
|
924
|
+
const { toolType } = classifyTool(toolName);
|
|
925
|
+
const resultStr = input.result != null ? typeof input.result === "string" ? input.result : JSON.stringify(input.result) : void 0;
|
|
926
|
+
const response = input.error || resultStr || void 0;
|
|
927
|
+
const responsePreview = resultStr && resultStr.length > 1e3 ? resultStr.slice(0, 1e3) + "..." : resultStr;
|
|
928
|
+
void sendEvent({
|
|
929
|
+
toolName,
|
|
930
|
+
toolType,
|
|
931
|
+
eventType: "tool_complete",
|
|
932
|
+
agent: config.agentType,
|
|
933
|
+
agentName: config.agentName,
|
|
934
|
+
hookType: "tool_execute_after",
|
|
935
|
+
sessionId,
|
|
936
|
+
conversationId: sessionId,
|
|
937
|
+
command: extractCommand(toolName, params),
|
|
938
|
+
toolInput: params,
|
|
939
|
+
status: input.error ? "error" : "success",
|
|
940
|
+
response,
|
|
941
|
+
responsePreview,
|
|
942
|
+
durationMs: input.durationMs,
|
|
943
|
+
cwd: params.workdir || params.cwd || void 0,
|
|
944
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
945
|
+
}, config, pluginFilePath);
|
|
946
|
+
},
|
|
947
|
+
// -------------------------------------------------------------------
|
|
948
|
+
// chat.message: User prompt capture (non-blocking)
|
|
949
|
+
// -------------------------------------------------------------------
|
|
950
|
+
"chat.message": async (input) => {
|
|
951
|
+
if (input.role && input.role !== "user") return;
|
|
952
|
+
const text = input.content || "";
|
|
953
|
+
void sendEvent({
|
|
954
|
+
eventType: "user_prompt",
|
|
955
|
+
agent: config.agentType,
|
|
956
|
+
agentName: config.agentName,
|
|
957
|
+
hookType: "chat_message",
|
|
958
|
+
sessionId,
|
|
959
|
+
conversationId: sessionId,
|
|
960
|
+
prompt: text,
|
|
961
|
+
textLength: text.length,
|
|
962
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
963
|
+
}, config, pluginFilePath);
|
|
964
|
+
},
|
|
965
|
+
// -------------------------------------------------------------------
|
|
966
|
+
// event: Catch-all session lifecycle events (blocking for session.idle)
|
|
967
|
+
// -------------------------------------------------------------------
|
|
968
|
+
"event": async (input) => {
|
|
969
|
+
const eventName = input.type;
|
|
970
|
+
const agEventType = mapEventType(eventName);
|
|
971
|
+
if (!agEventType) {
|
|
972
|
+
if (config.debug) {
|
|
973
|
+
debugLog(`Ignoring unmapped event: ${eventName}`);
|
|
974
|
+
}
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
if (agEventType === "context_compact" && shouldSkipCompactionEvent()) {
|
|
978
|
+
if (config.debug) {
|
|
979
|
+
debugLog(`Skipping duplicate context_compact from ${eventName}`);
|
|
980
|
+
}
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
983
|
+
if (eventName === "session.idle") {
|
|
984
|
+
if (config.debug) {
|
|
985
|
+
debugLog("Session idle detected, sending blocking stop event");
|
|
986
|
+
}
|
|
987
|
+
const stopRequest = {
|
|
988
|
+
toolName: "session_idle",
|
|
989
|
+
toolType: "session",
|
|
990
|
+
agent: config.agentType,
|
|
991
|
+
agentName: config.agentName,
|
|
992
|
+
hookType: "stop",
|
|
993
|
+
sessionId,
|
|
994
|
+
conversationId: sessionId,
|
|
995
|
+
projectPath: process.cwd(),
|
|
996
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
997
|
+
};
|
|
998
|
+
try {
|
|
999
|
+
const response = await sendApprovalRequest(stopRequest, config, pluginFilePath);
|
|
1000
|
+
if ((response.decision === "approve" || response.decision === "allow") && response.reason && openCodeClient) {
|
|
1001
|
+
if (config.debug) {
|
|
1002
|
+
debugLog(`Stop approved with follow-up input: ${response.reason.slice(0, 100)}`);
|
|
1003
|
+
}
|
|
1004
|
+
try {
|
|
1005
|
+
await openCodeClient.tui.appendPrompt({ body: { text: response.reason } });
|
|
1006
|
+
await openCodeClient.tui.submitPrompt();
|
|
1007
|
+
} catch (injectionError) {
|
|
1008
|
+
if (config.debug) {
|
|
1009
|
+
debugLog(`Failed to inject follow-up input: ${injectionError instanceof Error ? injectionError.message : String(injectionError)}`);
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
} catch (error) {
|
|
1014
|
+
if (config.debug) {
|
|
1015
|
+
debugLog(`Stop event error: ${error instanceof Error ? error.message : String(error)}`);
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
void sendEvent({
|
|
1021
|
+
eventType: agEventType,
|
|
1022
|
+
agent: config.agentType,
|
|
1023
|
+
agentName: config.agentName,
|
|
1024
|
+
hookType: "event",
|
|
1025
|
+
sessionId,
|
|
1026
|
+
conversationId: sessionId,
|
|
1027
|
+
trigger: eventName,
|
|
1028
|
+
text: input.data ? JSON.stringify(input.data).slice(0, 500) : void 0,
|
|
1029
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
1030
|
+
}, config, pluginFilePath);
|
|
1031
|
+
},
|
|
1032
|
+
// -------------------------------------------------------------------
|
|
1033
|
+
// experimental.session.compacting: Pre-compaction event (non-blocking)
|
|
1034
|
+
// -------------------------------------------------------------------
|
|
1035
|
+
"experimental.session.compacting": async (input) => {
|
|
1036
|
+
if (shouldSkipCompactionEvent()) {
|
|
1037
|
+
if (config.debug) {
|
|
1038
|
+
debugLog("Skipping duplicate context_compact from experimental.session.compacting");
|
|
1039
|
+
}
|
|
1040
|
+
return;
|
|
1041
|
+
}
|
|
1042
|
+
if (config.debug) {
|
|
1043
|
+
debugLog(`Context compaction: ${input.messageCount ?? "?"} messages${input.tokenCount ? `, ${input.tokenCount} tokens` : ""}`);
|
|
1044
|
+
}
|
|
1045
|
+
void sendEvent({
|
|
1046
|
+
eventType: "context_compact",
|
|
1047
|
+
agent: config.agentType,
|
|
1048
|
+
agentName: config.agentName,
|
|
1049
|
+
hookType: "session_compacting",
|
|
1050
|
+
sessionId,
|
|
1051
|
+
conversationId: sessionId,
|
|
1052
|
+
trigger: `${input.messageCount ?? "?"} messages${input.tokenCount ? `, ${input.tokenCount} tokens` : ""}`,
|
|
1053
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
1054
|
+
}, config, pluginFilePath);
|
|
1055
|
+
}
|
|
1056
|
+
};
|
|
1057
|
+
}
|
|
1058
|
+
export {
|
|
1059
|
+
classifyTool,
|
|
1060
|
+
plugin as default,
|
|
1061
|
+
extractCommand
|
|
1062
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@agentapprove/opencode",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Agent Approve plugin for OpenCode - 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
|
+
"files": [
|
|
12
|
+
"dist/",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"keywords": [
|
|
16
|
+
"opencode",
|
|
17
|
+
"agentapprove",
|
|
18
|
+
"agent-approve",
|
|
19
|
+
"approval",
|
|
20
|
+
"governance",
|
|
21
|
+
"ai-safety",
|
|
22
|
+
"iphone",
|
|
23
|
+
"apple-watch",
|
|
24
|
+
"tool-approval"
|
|
25
|
+
],
|
|
26
|
+
"author": "Agent Approve LLC <hello@agentapprove.com> (https://agentapprove.com)",
|
|
27
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
28
|
+
"homepage": "https://www.agentapprove.com",
|
|
29
|
+
"bugs": {
|
|
30
|
+
"url": "https://github.com/agentapprove/support/issues"
|
|
31
|
+
},
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=18"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"esbuild": "^0.24.0",
|
|
37
|
+
"typescript": "^5.0.0"
|
|
38
|
+
}
|
|
39
|
+
}
|