@inquiryon/openclaw-amp-governance 1.0.1 → 1.0.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/index.js +402 -6
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -1,16 +1,412 @@
|
|
|
1
|
-
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
|
|
3
|
+
console.log('[AMP Governance] Plugin module loaded — Phase 4.');
|
|
4
|
+
|
|
5
|
+
const SESSION_FILE = '/tmp/amp-session-state.json';
|
|
6
|
+
const CONFIG_FILE = `${process.env.HOME}/.openclaw/hooks/amp/amp_config.json`;
|
|
7
|
+
|
|
8
|
+
// Tools that are internal/noisy — skip logging and policy checks
|
|
9
|
+
const SKIP_TOOLS = new Set(['session_status', 'heartbeat']);
|
|
10
|
+
|
|
11
|
+
// HITL polling config
|
|
12
|
+
const HITL_POLL_INTERVAL_MS = 3000; // 3 seconds between polls
|
|
13
|
+
const HITL_TIMEOUT_MS = 10 * 60 * 1000; // 10 minute max wait for human
|
|
14
|
+
|
|
15
|
+
let config = null;
|
|
16
|
+
try {
|
|
17
|
+
config = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8'));
|
|
18
|
+
console.log('[AMP Governance] Config loaded. Backend:', config.AMP_BACKEND_URL);
|
|
19
|
+
} catch (err) {
|
|
20
|
+
console.error('[AMP Governance] Failed to load config:', err.message);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Module-level instance cache so we only init once per process lifetime
|
|
24
|
+
let _instanceId = null;
|
|
25
|
+
|
|
26
|
+
function readSession() {
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse(fs.readFileSync(SESSION_FILE, 'utf-8'));
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function writeSession(instanceId) {
|
|
35
|
+
try {
|
|
36
|
+
fs.writeFileSync(SESSION_FILE, JSON.stringify({ instanceId }), 'utf-8');
|
|
37
|
+
} catch (err) {
|
|
38
|
+
console.error('[AMP Governance] writeSession failed:', err.message);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ── INSTANCE MANAGEMENT ──────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Ensure an AMP instance exists for this session.
|
|
46
|
+
* Priority: in-memory cache → session file → create new via /api/agent/init.
|
|
47
|
+
*/
|
|
48
|
+
async function ensureInstance() {
|
|
49
|
+
if (!config) return null;
|
|
50
|
+
|
|
51
|
+
if (_instanceId) return _instanceId;
|
|
52
|
+
|
|
53
|
+
const session = readSession();
|
|
54
|
+
if (session?.instanceId) {
|
|
55
|
+
_instanceId = session.instanceId;
|
|
56
|
+
return _instanceId;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const res = await fetch(`${config.AMP_BACKEND_URL}/api/agent/init`, {
|
|
61
|
+
method: 'POST',
|
|
62
|
+
headers: { 'X-API-Key': config.AMP_API_KEY, 'Content-Type': 'application/json' },
|
|
63
|
+
body: JSON.stringify({
|
|
64
|
+
agent_name: config.AGENT_NAME,
|
|
65
|
+
org_id: config.AMP_ORG_ID,
|
|
66
|
+
username: config.AMP_USERNAME,
|
|
67
|
+
prompt: 'OpenClaw governance session',
|
|
68
|
+
auto_start: true,
|
|
69
|
+
config: { agent_mode: 'polling' },
|
|
70
|
+
metadata: { source: 'openclaw-amp-plugin', owner: config.AMP_USERNAME },
|
|
71
|
+
}),
|
|
72
|
+
});
|
|
73
|
+
if (!res.ok) {
|
|
74
|
+
console.error(`[AMP Governance] /api/agent/init returned ${res.status}`);
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
const data = await res.json();
|
|
78
|
+
const id = data.instance_id;
|
|
79
|
+
if (id) {
|
|
80
|
+
_instanceId = id;
|
|
81
|
+
writeSession(id);
|
|
82
|
+
console.log(`[AMP Governance] AMP instance created: ${id}`);
|
|
83
|
+
}
|
|
84
|
+
return _instanceId || null;
|
|
85
|
+
} catch (err) {
|
|
86
|
+
console.error('[AMP Governance] ensureInstance failed:', err.message);
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── AMP LOGGING ──────────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
async function ampLog(instanceId, message, level = 'INFO') {
|
|
94
|
+
if (!config) return;
|
|
95
|
+
try {
|
|
96
|
+
await fetch(`${config.AMP_BACKEND_URL}/api/log`, {
|
|
97
|
+
method: 'POST',
|
|
98
|
+
headers: { 'X-API-Key': config.AMP_API_KEY, 'Content-Type': 'application/json' },
|
|
99
|
+
body: JSON.stringify({
|
|
100
|
+
instance_id: instanceId,
|
|
101
|
+
service: config.AGENT_NAME,
|
|
102
|
+
level,
|
|
103
|
+
message,
|
|
104
|
+
timestamp: new Date().toISOString(),
|
|
105
|
+
org_id: config.AMP_ORG_ID,
|
|
106
|
+
username: config.AMP_USERNAME,
|
|
107
|
+
}),
|
|
108
|
+
});
|
|
109
|
+
} catch (err) {
|
|
110
|
+
console.error('[AMP Governance] ampLog failed:', err.message);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── HITL POLICY ENGINE ───────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Call /api/hitl/request with the tool call details.
|
|
118
|
+
* The AMP backend eval engine decides allow vs HITL.
|
|
119
|
+
*/
|
|
120
|
+
async function requestHitlEval(instanceId, tool, params) {
|
|
121
|
+
const res = await fetch(`${config.AMP_BACKEND_URL}/api/hitl/request`, {
|
|
122
|
+
method: 'POST',
|
|
123
|
+
headers: { 'X-API-Key': config.AMP_API_KEY, 'Content-Type': 'application/json' },
|
|
124
|
+
body: JSON.stringify({
|
|
125
|
+
caller_id: instanceId,
|
|
126
|
+
instance_id: instanceId,
|
|
127
|
+
org_id: config.AMP_ORG_ID,
|
|
128
|
+
agent_name: config.AGENT_NAME,
|
|
129
|
+
tool,
|
|
130
|
+
action: params?.action || '*',
|
|
131
|
+
context: params || {},
|
|
132
|
+
hitl: {
|
|
133
|
+
enable: true,
|
|
134
|
+
when: 'policy',
|
|
135
|
+
who: config.AMP_USERNAME,
|
|
136
|
+
what: 'approval',
|
|
137
|
+
where: 'amp',
|
|
138
|
+
},
|
|
139
|
+
}),
|
|
140
|
+
});
|
|
141
|
+
if (!res.ok) throw new Error(`/api/hitl/request returned HTTP ${res.status}`);
|
|
142
|
+
return res.json();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Poll /api/hitl/get-decision until we get a 'complete' status or time out.
|
|
147
|
+
*/
|
|
148
|
+
async function pollHitlDecision(callerId) {
|
|
149
|
+
const deadline = Date.now() + HITL_TIMEOUT_MS;
|
|
150
|
+
while (Date.now() < deadline) {
|
|
151
|
+
await new Promise(r => setTimeout(r, HITL_POLL_INTERVAL_MS));
|
|
152
|
+
const res = await fetch(
|
|
153
|
+
`${config.AMP_BACKEND_URL}/api/hitl/get-decision?caller_id=${encodeURIComponent(callerId)}`,
|
|
154
|
+
{ headers: { 'X-API-Key': config.AMP_API_KEY } }
|
|
155
|
+
);
|
|
156
|
+
if (!res.ok) {
|
|
157
|
+
console.warn(`[AMP Governance] get-decision returned ${res.status}, retrying...`);
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
const data = await res.json();
|
|
161
|
+
if (data.status === 'complete') return data;
|
|
162
|
+
console.log(`[AMP Governance] Waiting for human decision... (${data.status})`);
|
|
163
|
+
}
|
|
164
|
+
throw new Error(`HITL decision timed out after ${HITL_TIMEOUT_MS / 60000} minutes`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Full governance check for a single tool call.
|
|
169
|
+
* Returns normally if the call is allowed (or approved by a human).
|
|
170
|
+
* Throws an Error with a clear reason if the call is blocked or rejected.
|
|
171
|
+
* Fails open (returns without throwing) if AMP backend is unreachable.
|
|
172
|
+
*/
|
|
173
|
+
/**
|
|
174
|
+
* Returns {} to allow, or { block: true, blockReason } to block.
|
|
175
|
+
* Never throws — errors fail open so infra issues don't break the agent.
|
|
176
|
+
*/
|
|
177
|
+
async function checkToolPolicy(instanceId, tool, params) {
|
|
178
|
+
const paramSummary = formatInput(tool, params);
|
|
179
|
+
await ampLog(instanceId, `Policy check: ${tool} | ${paramSummary}`);
|
|
180
|
+
|
|
181
|
+
let hitlResponse;
|
|
182
|
+
try {
|
|
183
|
+
hitlResponse = await requestHitlEval(instanceId, tool, params);
|
|
184
|
+
} catch (err) {
|
|
185
|
+
console.warn(`[AMP Governance] HITL request failed (fail open): ${err.message}`);
|
|
186
|
+
await ampLog(instanceId, `AMP governance check failed (fail open): ${err.message}`, 'WARN');
|
|
187
|
+
return {};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const status = hitlResponse.status;
|
|
191
|
+
const reason = hitlResponse.reason || hitlResponse.information || '';
|
|
192
|
+
await ampLog(instanceId, `Policy decision: ${tool} | status=${status}${reason ? ` | ${reason}` : ''}`);
|
|
193
|
+
|
|
194
|
+
// ── No policy configured — block ─────────────────────────────────────────
|
|
195
|
+
if (status === 'no_policy') {
|
|
196
|
+
const msg = `Tool call "${tool}" blocked — no active governance policy for agent "${config.AGENT_NAME}". Contact your administrator.`;
|
|
197
|
+
await ampLog(instanceId, msg, 'WARN');
|
|
198
|
+
return { block: true, blockReason: msg };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ── Eval engine approved — allow ─────────────────────────────────────────
|
|
202
|
+
if (status === 'no-hitl') {
|
|
203
|
+
console.log(`[AMP Governance] Policy approved: ${tool} (${reason || 'ok'})`);
|
|
204
|
+
return {};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ── HITL required — wait for human decision ──────────────────────────────
|
|
208
|
+
if (status === 'pending' || status === 'waiting-for-response' || hitlResponse.workitem_id) {
|
|
209
|
+
console.log(`[AMP Governance] HITL required for "${tool}" — waiting for human decision...`);
|
|
210
|
+
await ampLog(instanceId, `HITL requested for tool: ${tool} — awaiting human approval in AMP`, 'WARN');
|
|
211
|
+
|
|
212
|
+
let decision;
|
|
213
|
+
try {
|
|
214
|
+
decision = await pollHitlDecision(instanceId);
|
|
215
|
+
} catch (err) {
|
|
216
|
+
const msg = `Tool call "${tool}" timed out waiting for human approval — blocked.`;
|
|
217
|
+
await ampLog(instanceId, msg, 'ERROR');
|
|
218
|
+
return { block: true, blockReason: msg };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const resolution = String(decision.resolution || '').toLowerCase().trim();
|
|
222
|
+
console.log(`[AMP Governance] HITL decision for "${tool}": ${resolution}`);
|
|
223
|
+
|
|
224
|
+
if (resolution === 'approve' || resolution === 'approved') {
|
|
225
|
+
await ampLog(instanceId, `HITL approved: ${tool} | workitem: ${decision.workitem_id}`);
|
|
226
|
+
return {};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (resolution === 'modify' || resolution === 'modified') {
|
|
230
|
+
await ampLog(instanceId, `HITL approved with modification: ${tool} | workitem: ${decision.workitem_id}`);
|
|
231
|
+
return {};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Rejected
|
|
235
|
+
const info = decision.information ? ` Reviewer note: ${decision.information}` : '';
|
|
236
|
+
const msg = `Tool call "${tool}" was rejected by a human reviewer.${info}`;
|
|
237
|
+
await ampLog(instanceId, `HITL rejected: ${tool} | workitem: ${decision.workitem_id}`, 'WARN');
|
|
238
|
+
return { block: true, blockReason: msg };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Unknown response — fail open
|
|
242
|
+
console.warn('[AMP Governance] Unexpected HITL response:', JSON.stringify(hitlResponse));
|
|
243
|
+
await ampLog(instanceId, `Unexpected HITL response for ${tool}: ${JSON.stringify(hitlResponse)}`, 'WARN');
|
|
244
|
+
return {};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ── TEXT FORMATTING HELPERS ──────────────────────────────────────────────────
|
|
248
|
+
|
|
249
|
+
function extractText(obj) {
|
|
250
|
+
if (obj === undefined || obj === null) return '(empty)';
|
|
251
|
+
if (typeof obj === 'string') {
|
|
252
|
+
try { return extractText(JSON.parse(obj)); } catch { return obj; }
|
|
253
|
+
}
|
|
254
|
+
if (Array.isArray(obj)) {
|
|
255
|
+
return obj.map(extractText).filter(Boolean).join('\n');
|
|
256
|
+
}
|
|
257
|
+
if (typeof obj === 'object') {
|
|
258
|
+
if (obj.content && Array.isArray(obj.content)) {
|
|
259
|
+
return obj.content
|
|
260
|
+
.filter(c => c.text !== undefined)
|
|
261
|
+
.map(c => extractText(c.text))
|
|
262
|
+
.join('\n');
|
|
263
|
+
}
|
|
264
|
+
if (typeof obj.text === 'string') return extractText(obj.text);
|
|
265
|
+
return Object.entries(obj)
|
|
266
|
+
.filter(([k]) => k !== 'type')
|
|
267
|
+
.map(([k, v]) => `${k}: ${extractText(v)}`)
|
|
268
|
+
.join(' | ');
|
|
269
|
+
}
|
|
270
|
+
return String(obj);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function formatInput(tool, params) {
|
|
274
|
+
if (!params) return '';
|
|
275
|
+
try {
|
|
276
|
+
switch (tool) {
|
|
277
|
+
case 'web_search':
|
|
278
|
+
return `query: "${params.query}"${params.count ? ` (top ${params.count})` : ''}`;
|
|
279
|
+
case 'read':
|
|
280
|
+
return `path: ${params.path || params.file_path || JSON.stringify(params)}`;
|
|
281
|
+
case 'write':
|
|
282
|
+
case 'edit':
|
|
283
|
+
return `path: ${params.path || params.file_path || '?'}`;
|
|
284
|
+
case 'bash':
|
|
285
|
+
case 'shell':
|
|
286
|
+
return `cmd: ${String(params.command || params.cmd || '').substring(0, 120)}`;
|
|
287
|
+
default:
|
|
288
|
+
return extractText(params).substring(0, 200);
|
|
289
|
+
}
|
|
290
|
+
} catch {
|
|
291
|
+
return String(params).substring(0, 200);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function stripWrapper(s) {
|
|
296
|
+
if (typeof s !== 'string') return s;
|
|
297
|
+
return s
|
|
298
|
+
.replace(/<<<EXTERNAL_UNTRUSTED_CONTENT[^>]*>>>/g, '')
|
|
299
|
+
.replace(/<<<END_EXTERNAL_UNTRUSTED_CONTENT[^>]*>>>/g, '')
|
|
300
|
+
.replace(/Source:\s*Web Search\s*---/g, '')
|
|
301
|
+
.replace(/\s+/g, ' ')
|
|
302
|
+
.trim();
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function getRawText(result) {
|
|
306
|
+
try {
|
|
307
|
+
const obj = typeof result === 'string' ? JSON.parse(result) : result;
|
|
308
|
+
if (obj?.content && Array.isArray(obj.content)) {
|
|
309
|
+
const item = obj.content.find(c => c.text !== undefined);
|
|
310
|
+
if (item) return String(item.text);
|
|
311
|
+
}
|
|
312
|
+
if (typeof obj?.text === 'string') return obj.text;
|
|
313
|
+
return typeof result === 'string' ? result : JSON.stringify(result);
|
|
314
|
+
} catch {
|
|
315
|
+
return typeof result === 'string' ? result : '';
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function formatOutput(tool, result) {
|
|
320
|
+
if (result === undefined || result === null) return '(empty)';
|
|
321
|
+
try {
|
|
322
|
+
switch (tool) {
|
|
323
|
+
case 'web_search': {
|
|
324
|
+
const raw = getRawText(result);
|
|
325
|
+
try {
|
|
326
|
+
const tavily = JSON.parse(raw);
|
|
327
|
+
const results = tavily?.externalContent?.results || tavily?.results || [];
|
|
328
|
+
if (results.length > 0) {
|
|
329
|
+
const lines = results.slice(0, 3).map((r, i) => {
|
|
330
|
+
const title = stripWrapper(r.title || '');
|
|
331
|
+
const url = r.url || '';
|
|
332
|
+
const snippet = stripWrapper(r.content || r.snippet || '').substring(0, 200);
|
|
333
|
+
return `[${i + 1}] ${title} › ${url} › ${snippet}`;
|
|
334
|
+
});
|
|
335
|
+
return `${results.length} result(s): ${lines.join(' ◆ ')}`;
|
|
336
|
+
}
|
|
337
|
+
} catch (e) {
|
|
338
|
+
console.error('[AMP Governance] web_search parse failed:', e.message);
|
|
339
|
+
}
|
|
340
|
+
return stripWrapper(raw).substring(0, 400);
|
|
341
|
+
}
|
|
342
|
+
case 'read': {
|
|
343
|
+
const text = extractText(result);
|
|
344
|
+
return text.substring(0, 300);
|
|
345
|
+
}
|
|
346
|
+
default:
|
|
347
|
+
return extractText(result).substring(0, 400);
|
|
348
|
+
}
|
|
349
|
+
} catch {
|
|
350
|
+
return String(result).substring(0, 300);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ── PLUGIN REGISTRATION ──────────────────────────────────────────────────────
|
|
2
355
|
|
|
3
356
|
export default {
|
|
4
357
|
id: 'amp-governance',
|
|
5
358
|
name: 'AMP Governance',
|
|
6
359
|
description: 'AMP transparency, accountability, and HITL integration for OpenClaw',
|
|
7
360
|
register(api) {
|
|
8
|
-
api.logger.info('AMP Governance registered. Phase
|
|
361
|
+
api.logger.info('AMP Governance registered. Phase 4 - eval policy enforcement active.');
|
|
362
|
+
|
|
363
|
+
// ── BEFORE TOOL CALL: governance check + logging ─────────────────────────
|
|
364
|
+
api.on('before_tool_call', async (event) => {
|
|
365
|
+
const tool = event.toolName || event.name || 'unknown-tool';
|
|
366
|
+
if (SKIP_TOOLS.has(tool)) return {};
|
|
367
|
+
|
|
368
|
+
const instanceId = await ensureInstance();
|
|
369
|
+
if (!instanceId) {
|
|
370
|
+
console.warn('[AMP Governance] No instance available — skipping governance check.');
|
|
371
|
+
return {};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Log the incoming tool call
|
|
375
|
+
const message = `Tool call: ${tool} | Input: ${formatInput(tool, event.params)}`;
|
|
376
|
+
console.log(`[AMP Governance] before_tool_call: ${tool} | instance: ${instanceId}`);
|
|
377
|
+
await ampLog(instanceId, message);
|
|
378
|
+
|
|
379
|
+
// Governance check — returns {} to allow or { block, blockReason } to block
|
|
380
|
+
return await checkToolPolicy(instanceId, tool, event.params);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
// ── AFTER TOOL CALL: result logging ──────────────────────────────────────
|
|
384
|
+
api.on('after_tool_call', async (event) => {
|
|
385
|
+
const tool = event.toolName || event.name || 'unknown-tool';
|
|
386
|
+
if (SKIP_TOOLS.has(tool)) return;
|
|
387
|
+
|
|
388
|
+
const instanceId = _instanceId || readSession()?.instanceId;
|
|
389
|
+
if (!instanceId) return;
|
|
390
|
+
|
|
391
|
+
const result = event.result ?? event.output ?? event.toolOutput;
|
|
392
|
+
const status = event.success === false ? 'FAILED' : 'OK';
|
|
393
|
+
const duration = event.durationMs != null ? ` (${event.durationMs}ms)` : '';
|
|
394
|
+
const output = formatOutput(tool, result);
|
|
395
|
+
const message = `Tool result: ${tool} | Status: ${status}${duration} | ${output}`;
|
|
396
|
+
|
|
397
|
+
console.log(`[AMP Governance] after_tool_call: ${tool} | status: ${status}`);
|
|
398
|
+
await ampLog(instanceId, message, event.success === false ? 'ERROR' : 'INFO');
|
|
399
|
+
});
|
|
9
400
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
401
|
+
// ── OUTBOUND MESSAGE LOGGING ──────────────────────────────────────────────
|
|
402
|
+
api.on('message_sending', async (event) => {
|
|
403
|
+
const instanceId = _instanceId || readSession()?.instanceId;
|
|
404
|
+
const msgText = event.content || event.text || event.message || JSON.stringify(event);
|
|
405
|
+
const preview = msgText.substring(0, 100);
|
|
406
|
+
console.log(`[AMP Governance] message_sending fired: ${preview}`);
|
|
407
|
+
if (instanceId) {
|
|
408
|
+
await ampLog(instanceId, `Agent reply: ${preview}`);
|
|
409
|
+
}
|
|
14
410
|
});
|
|
15
411
|
},
|
|
16
412
|
};
|