@inquiryon/amp-governance 1.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/hook/HOOK.md ADDED
@@ -0,0 +1,20 @@
1
+ ---
2
+ name: amp
3
+ description: "AMP Governance and HITL integration for Inquiryon"
4
+ metadata:
5
+ openclaw:
6
+ emoji: "🔗"
7
+ # events: ["message", "task:start", "tool:*", "task:end"]
8
+ events: ["message", "message:sent"]
9
+ requires:
10
+ bins: ["node"]
11
+ ---
12
+
13
+ # AMP Hook
14
+
15
+ This hook integrates OpenClaw with the **Inquiryon AMP (Agent Management Platform)** to provide governance, activity logging, and lifecycle management.
16
+
17
+ ## Integration Lifecycle
18
+ 1. **Init**: Triggered on `task:start`.
19
+ 2. **Log**: Triggered on `tool:end` for agentic actions.
20
+ 3. **SetState**: Triggered on `task:end` to finalize the instance.
@@ -0,0 +1,8 @@
1
+ {
2
+ "AMP_BACKEND_URL": "https://amp.your-org.com",
3
+ "AMP_API_KEY": "amp_k_...",
4
+ "AMP_ORG_ID": "O-...",
5
+ "AGENT_NAME": "open-claw-1234",
6
+ "AMP_USERNAME": "you@example.com",
7
+ "HITL_TIMEOUT_MINUTES": 10
8
+ }
@@ -0,0 +1,269 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+
4
+ console.log('--- [AMP Hook] Logic Loaded — Phase 5 ---');
5
+
6
+ const configPath = path.join(process.env.HOME || '', '.openclaw/hooks/amp/amp_config.json');
7
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
8
+
9
+ function normalizeBackendUrl(raw: unknown): string {
10
+ let base = String(raw || '').trim();
11
+ if (!base) return '';
12
+ if (!/^https?:\/\//i.test(base)) base = `https://${base}`;
13
+ return base.replace(/\/+$/, '');
14
+ }
15
+
16
+ function buildAmpUrl(p: string): string {
17
+ const base = normalizeBackendUrl((config as any).AMP_BACKEND_URL);
18
+ if (!base) throw new Error('AMP_BACKEND_URL missing');
19
+ const suffix = p.startsWith('/') ? p : `/${p}`;
20
+ return `${base}${suffix}`;
21
+ }
22
+
23
+ function getAmpApiKey(): string {
24
+ let key = String((config as any).AMP_API_KEY || '').trim();
25
+ if ((key.startsWith('"') && key.endsWith('"')) || (key.startsWith("'") && key.endsWith("'"))) {
26
+ key = key.slice(1, -1).trim();
27
+ }
28
+ if (/^bearer\s+/i.test(key)) key = key.replace(/^bearer\s+/i, '').trim();
29
+ return key;
30
+ }
31
+
32
+ const SESSION_FILE = '/tmp/amp-session-state.json';
33
+ const COMMAND_THRESHOLD = 30; // roll to new instance after this many tool calls
34
+
35
+ // In-memory state (cleared on process restart)
36
+ const sessionMap = new Map<string, string>(); // convKey → instanceId
37
+ const commandCounts = new Map<string, number>(); // convKey → tool call count
38
+ const seenMessages = new Set<string>(); // dedup guard
39
+
40
+ let _startupCleanupDone = false;
41
+
42
+ // ── HELPERS ───────────────────────────────────────────────────────────────────
43
+
44
+ async function ampLog(instanceId: string, message: string, level: string = 'INFO'): Promise<void> {
45
+ try {
46
+ const apiKey = getAmpApiKey();
47
+ await fetch(buildAmpUrl('/api/log'), {
48
+ method: 'POST',
49
+ headers: { 'X-API-Key': apiKey, 'Content-Type': 'application/json' },
50
+ body: JSON.stringify({
51
+ instance_id: instanceId,
52
+ service: config.AGENT_NAME,
53
+ level,
54
+ message,
55
+ timestamp: new Date().toISOString(),
56
+ org_id: config.AMP_ORG_ID,
57
+ username: config.AMP_USERNAME
58
+ })
59
+ });
60
+ } catch (err) {
61
+ console.error('[AMP Hook] ampLog failed:', err);
62
+ }
63
+ }
64
+
65
+ async function setInstanceFinished(instanceId: string): Promise<void> {
66
+ try {
67
+ const apiKey = getAmpApiKey();
68
+ await fetch(buildAmpUrl('/api/agent/setState'), {
69
+ method: 'POST',
70
+ headers: { 'X-API-Key': apiKey, 'Content-Type': 'application/json' },
71
+ body: JSON.stringify({ agent_name: config.AGENT_NAME, instance_id: instanceId, state: 'finished' })
72
+ });
73
+ console.log(`[AMP Hook] Instance ${instanceId} marked finished.`);
74
+ } catch (err) {
75
+ console.error('[AMP Hook] setInstanceFinished failed:', err);
76
+ }
77
+ }
78
+
79
+ function writeSessionFile(instanceId: string, convKey: string): void {
80
+ try {
81
+ fs.writeFileSync(SESSION_FILE, JSON.stringify({
82
+ instanceId,
83
+ convKey,
84
+ timestamp: new Date().toISOString()
85
+ }), 'utf-8');
86
+ } catch (err) {
87
+ console.error('[AMP Hook] Failed to write session file:', err);
88
+ }
89
+ }
90
+
91
+ function clearSessionFile(): void {
92
+ try { fs.unlinkSync(SESSION_FILE); } catch {}
93
+ }
94
+
95
+ function conversationKey(event: any): string {
96
+ const ctx = event.context || {};
97
+ const convId = ctx.conversationId || ctx.from || '';
98
+ const channel = ctx.channelId || event.channel || 'whatsapp';
99
+ return convId ? `${channel}:${convId}` : (event.taskId || 'global');
100
+ }
101
+
102
+ function buildToolMessage(event: any): string {
103
+ const tool = event.toolName || event.tool || event.name || 'unknown-tool';
104
+ const parts: string[] = [`Tool: ${tool}`];
105
+ const raw_input = event.toolInput ?? event.input ?? event.args;
106
+ if (raw_input != null) {
107
+ const s = typeof raw_input === 'string' ? raw_input : JSON.stringify(raw_input);
108
+ parts.push(`Input: ${s.substring(0, 200)}`);
109
+ }
110
+ const raw_output = event.toolOutput ?? event.output ?? event.result;
111
+ if (raw_output != null) {
112
+ const s = typeof raw_output === 'string' ? raw_output : JSON.stringify(raw_output);
113
+ parts.push(`Output: ${s.substring(0, 200)}`);
114
+ }
115
+ if (event.durationMs != null) parts.push(`Duration: ${event.durationMs}ms`);
116
+ if (event.success === false) parts.push('Status: FAILED');
117
+ return parts.join(' | ');
118
+ }
119
+
120
+ // ── INSTANCE LIFECYCLE ────────────────────────────────────────────────────────
121
+
122
+ /**
123
+ * On first event after process restart, finalize any orphaned instance left
124
+ * in the session file from the previous run, then clear the file.
125
+ */
126
+ async function startupCleanup(): Promise<void> {
127
+ if (_startupCleanupDone) return;
128
+ _startupCleanupDone = true;
129
+ try {
130
+ const session = JSON.parse(fs.readFileSync(SESSION_FILE, 'utf-8'));
131
+ if (session?.instanceId) {
132
+ console.log(`[AMP Hook] Startup: finalizing orphaned instance ${session.instanceId}`);
133
+ await setInstanceFinished(session.instanceId);
134
+ }
135
+ } catch {} // No session file or parse error — nothing to clean up
136
+ clearSessionFile();
137
+ }
138
+
139
+ async function finalizeInstance(convKey: string, reason: string): Promise<void> {
140
+ const instanceId = sessionMap.get(convKey);
141
+ if (!instanceId) return;
142
+ console.log(`[AMP Hook] Finalizing ${instanceId} (${reason})`);
143
+ await setInstanceFinished(instanceId);
144
+ sessionMap.delete(convKey);
145
+ commandCounts.delete(convKey);
146
+ clearSessionFile();
147
+ }
148
+
149
+ async function initInstance(convKey: string, prompt: string, metadata: object): Promise<string | null> {
150
+ try {
151
+ const initUrl = buildAmpUrl('/api/agent/init');
152
+ const apiKey = getAmpApiKey();
153
+ const response = await fetch(initUrl, {
154
+ method: 'POST',
155
+ headers: { 'X-API-Key': apiKey, 'Content-Type': 'application/json' },
156
+ body: JSON.stringify({
157
+ agent_name: config.AGENT_NAME,
158
+ prompt,
159
+ auto_start: true,
160
+ config: { agent_mode: 'polling' },
161
+ metadata: { source: 'openclaw-whatsapp', org_id: config.AMP_ORG_ID, ...metadata }
162
+ })
163
+ });
164
+ const data = await response.json().catch(() => ({} as any));
165
+ const errText = (data && (data.error || data.detail || data.message)) ? String(data.error || data.detail || data.message) : '';
166
+ console.log(`[AMP Hook] Init response: ${response.status} | instance: ${data.instance_id || 'NONE'}${errText ? ` | error: ${errText}` : ''}`);
167
+ if (data.instance_id) {
168
+ sessionMap.set(convKey, data.instance_id);
169
+ commandCounts.set(convKey, 0);
170
+ writeSessionFile(data.instance_id, convKey);
171
+ return data.instance_id;
172
+ }
173
+ } catch (err: any) {
174
+ const em = err?.message || String(err);
175
+ const ec = err?.cause ? (err.cause.code || err.cause.message || String(err.cause)) : '';
176
+ let target = '';
177
+ try { target = buildAmpUrl('/api/agent/init'); } catch {}
178
+ console.error(`[AMP Hook] initInstance failed: ${em}${ec ? ` | cause=${ec}` : ''}${target ? ` | url=${target}` : ''}`);
179
+ }
180
+ return null;
181
+ }
182
+
183
+ /**
184
+ * Check command threshold. If reached: log, finalize current instance,
185
+ * start a new one. Returns the (possibly new) active instanceId.
186
+ */
187
+ async function checkThreshold(convKey: string): Promise<string | null> {
188
+ const count = (commandCounts.get(convKey) || 0) + 1;
189
+ commandCounts.set(convKey, count);
190
+
191
+ if (count >= COMMAND_THRESHOLD) {
192
+ const oldInstanceId = sessionMap.get(convKey);
193
+ if (oldInstanceId) {
194
+ const msg = `Reached ${COMMAND_THRESHOLD}-command threshold — rolling to a new agent instance.`;
195
+ console.log(`[AMP Hook] ${msg}`);
196
+ await ampLog(oldInstanceId, msg, 'INFO');
197
+ await finalizeInstance(convKey, `${COMMAND_THRESHOLD}-command threshold`);
198
+ }
199
+ // Start fresh instance (reuse last known prompt/metadata not available here — use placeholder)
200
+ const newId = await initInstance(convKey, 'Continued session (threshold rollover)', {});
201
+ if (newId) commandCounts.set(convKey, 1); // count the current call
202
+ return newId;
203
+ }
204
+
205
+ return sessionMap.get(convKey) || null;
206
+ }
207
+
208
+ // ── MAIN HOOK ─────────────────────────────────────────────────────────────────
209
+
210
+ export default async function ampHook(event: any) {
211
+ const type = event.type;
212
+ const convKey = conversationKey(event);
213
+
214
+ const ctx = event.context || {};
215
+ const messageText = ctx.content || event.text || event.input || 'WhatsApp Inbound';
216
+ const senderName = ctx.metadata?.senderName || ctx.from || 'unknown';
217
+ const messageId = ctx.messageId || '';
218
+
219
+ console.log(`[AMP Hook] Event: ${type} | Conv: ${convKey} | Sender: ${senderName}`);
220
+
221
+ // ── STARTUP CLEANUP ────────────────────────────────────────────────────────
222
+ await startupCleanup();
223
+
224
+ // ── 1. INBOUND MESSAGE ─────────────────────────────────────────────────────
225
+ // Init a new instance only if this conversation has no active instance.
226
+ // Subsequent messages in the same conversation reuse the existing instance.
227
+ const isInbound = type === 'message' && !!messageId;
228
+
229
+ if (isInbound) {
230
+ // Deduplicate repeated message events
231
+ if (seenMessages.has(messageId)) return;
232
+ seenMessages.add(messageId);
233
+ if (seenMessages.size > 50) seenMessages.delete(seenMessages.values().next().value);
234
+
235
+ if (!sessionMap.has(convKey)) {
236
+ // No active instance for this conversation — create one
237
+ console.log(`[AMP Hook] New conversation. Init AMP instance for: ${messageText.substring(0, 60)}`);
238
+ const instanceId = await initInstance(convKey, messageText, {
239
+ channel: ctx.channelId || 'whatsapp',
240
+ sender: senderName,
241
+ sender_phone: ctx.from || '',
242
+ message_id: messageId,
243
+ });
244
+ if (instanceId) {
245
+ await ampLog(instanceId, `User prompt: '${messageText}'`);
246
+ }
247
+ } else {
248
+ // Existing instance — log the new message and reuse
249
+ const instanceId = sessionMap.get(convKey)!;
250
+ console.log(`[AMP Hook] Existing instance ${instanceId} — reusing for new message.`);
251
+ await ampLog(instanceId, `User prompt: '${messageText}'`);
252
+ }
253
+ }
254
+
255
+ // ── 2. TOOL LOGGING + THRESHOLD CHECK ─────────────────────────────────────
256
+ if (type.startsWith('tool:')) {
257
+ const instanceId = await checkThreshold(convKey);
258
+ if (instanceId) {
259
+ try {
260
+ const message = buildToolMessage(event);
261
+ console.log(`[AMP Hook] Logging tool: ${message.substring(0, 100)}`);
262
+ await ampLog(instanceId, message, event.success === false ? 'ERROR' : 'INFO');
263
+ } catch (err) {
264
+ console.error('[AMP Hook] Tool log failed:', err);
265
+ }
266
+ }
267
+ }
268
+
269
+ }
package/index.js ADDED
@@ -0,0 +1,532 @@
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
+ const HITL_POLL_INTERVAL_MS = 3000; // 3 seconds between polls
12
+
13
+ let config = null;
14
+ try {
15
+ config = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8'));
16
+ console.log('[AMP Governance] Config loaded. Backend:', config.AMP_BACKEND_URL);
17
+ } catch (err) {
18
+ console.error('[AMP Governance] Failed to load config:', err.message);
19
+ }
20
+
21
+ function normalizeBackendUrl(raw) {
22
+ let base = String(raw || '').trim();
23
+ if (!base) return '';
24
+ // If scheme is missing, default to HTTPS for server deployments.
25
+ if (!/^https?:\/\//i.test(base)) base = `https://${base}`;
26
+ return base.replace(/\/+$/, '');
27
+ }
28
+
29
+ function buildAmpUrl(path) {
30
+ const base = normalizeBackendUrl(config?.AMP_BACKEND_URL);
31
+ if (!base) throw new Error('AMP_BACKEND_URL missing');
32
+ const suffix = String(path || '').startsWith('/') ? path : `/${path}`;
33
+ return `${base}${suffix}`;
34
+ }
35
+
36
+ function getAmpApiKey() {
37
+ let key = String(config?.AMP_API_KEY || '').trim();
38
+ // tolerate accidental wrapping or "Bearer ..." pastes
39
+ if ((key.startsWith('"') && key.endsWith('"')) || (key.startsWith("'") && key.endsWith("'"))) {
40
+ key = key.slice(1, -1).trim();
41
+ }
42
+ if (/^bearer\s+/i.test(key)) key = key.replace(/^bearer\s+/i, '').trim();
43
+ return key;
44
+ }
45
+
46
+ // HITL timeout — configurable via HITL_TIMEOUT_MINUTES in amp_config.json (default: 10)
47
+ const HITL_TIMEOUT_MS = (config?.HITL_TIMEOUT_MINUTES ?? 10) * 60 * 1000;
48
+
49
+ // Module-level instance cache so we only init once per process lifetime
50
+ let _instanceId = null;
51
+
52
+ // Last known sender — populated by message_received, used for HITL notifications
53
+ let _lastSender = null; // { from: string, channelId: string }
54
+
55
+ // Set by register() so notifyUser can use the runtime API directly
56
+ let _runtime = null;
57
+
58
+ // ── HOOK AUTO-DEPLOY ─────────────────────────────────────────────────────────
59
+
60
+ /**
61
+ * On first install (or if the hook dir is missing), copy the bundled hook
62
+ * files from the plugin's install directory into ~/.openclaw/hooks/amp/.
63
+ * Never overwrites an existing amp_config.json so user credentials are safe.
64
+ */
65
+ function deployHookFilesIfNeeded(pluginRootDir) {
66
+ const hookSrc = `${pluginRootDir}/hook`;
67
+ const hookDest = `${process.env.HOME}/.openclaw/hooks/amp`;
68
+
69
+ try {
70
+ if (!fs.existsSync(hookSrc)) {
71
+ console.warn('[AMP Governance] Hook source dir not found — skipping auto-deploy.');
72
+ return;
73
+ }
74
+ fs.mkdirSync(hookDest, { recursive: true });
75
+
76
+ for (const file of ['handler.ts', 'HOOK.md']) {
77
+ const dest = `${hookDest}/${file}`;
78
+ fs.copyFileSync(`${hookSrc}/${file}`, dest);
79
+ console.log(`[AMP Governance] Deployed hook file: ${dest}`);
80
+ }
81
+
82
+ // Only write amp_config.json if it doesn't already exist
83
+ const configDest = `${hookDest}/amp_config.json`;
84
+ if (!fs.existsSync(configDest)) {
85
+ fs.copyFileSync(`${hookSrc}/amp_config.json`, configDest);
86
+ console.log(`[AMP Governance] Created config template: ${configDest}`);
87
+ console.log('[AMP Governance] ACTION REQUIRED: edit amp_config.json with your AMP credentials.');
88
+ }
89
+ } catch (err) {
90
+ console.error('[AMP Governance] Hook auto-deploy failed:', err.message);
91
+ }
92
+ }
93
+
94
+ function readSession() {
95
+ try {
96
+ return JSON.parse(fs.readFileSync(SESSION_FILE, 'utf-8'));
97
+ } catch {
98
+ return null;
99
+ }
100
+ }
101
+
102
+ function writeSession(instanceId) {
103
+ try {
104
+ fs.writeFileSync(SESSION_FILE, JSON.stringify({ instanceId }), 'utf-8');
105
+ } catch (err) {
106
+ console.error('[AMP Governance] writeSession failed:', err.message);
107
+ }
108
+ }
109
+
110
+ // ── INSTANCE MANAGEMENT ──────────────────────────────────────────────────────
111
+
112
+ /**
113
+ * Ensure an AMP instance exists for this session.
114
+ * Priority: in-memory cache → session file → create new via /api/agent/init.
115
+ */
116
+ async function ensureInstance() {
117
+ if (!config) return null;
118
+
119
+ if (_instanceId) return _instanceId;
120
+
121
+ const session = readSession();
122
+ if (session?.instanceId) {
123
+ _instanceId = session.instanceId;
124
+ return _instanceId;
125
+ }
126
+
127
+ try {
128
+ const initUrl = buildAmpUrl('/api/agent/init');
129
+ const apiKey = getAmpApiKey();
130
+ const res = await fetch(initUrl, {
131
+ method: 'POST',
132
+ headers: { 'X-API-Key': apiKey, 'Content-Type': 'application/json' },
133
+ body: JSON.stringify({
134
+ agent_name: config.AGENT_NAME,
135
+ org_id: config.AMP_ORG_ID,
136
+ username: config.AMP_USERNAME,
137
+ prompt: 'OpenClaw governance session',
138
+ auto_start: true,
139
+ config: { agent_mode: 'polling' },
140
+ metadata: { source: 'openclaw-amp-plugin', owner: config.AMP_USERNAME },
141
+ }),
142
+ });
143
+ if (!res.ok) {
144
+ const bodyText = await res.text().catch(() => '');
145
+ console.error(`[AMP Governance] /api/agent/init returned ${res.status}${bodyText ? ` | body=${bodyText}` : ''}`);
146
+ return null;
147
+ }
148
+ const data = await res.json();
149
+ const id = data.instance_id;
150
+ if (id) {
151
+ _instanceId = id;
152
+ writeSession(id);
153
+ console.log(`[AMP Governance] AMP instance created: ${id}`);
154
+ }
155
+ return _instanceId || null;
156
+ } catch (err) {
157
+ const em = err && err.message ? err.message : String(err);
158
+ const ec = err && err.cause ? (err.cause.code || err.cause.message || String(err.cause)) : '';
159
+ let target = '';
160
+ try { target = buildAmpUrl('/api/agent/init'); } catch (_) {}
161
+ console.error(`[AMP Governance] ensureInstance failed: ${em}${ec ? ` | cause=${ec}` : ''}${target ? ` | url=${target}` : ''}`);
162
+ return null;
163
+ }
164
+ }
165
+
166
+ // ── AMP LOGGING ──────────────────────────────────────────────────────────────
167
+
168
+ async function ampLog(instanceId, message, level = 'INFO') {
169
+ if (!config) return;
170
+ try {
171
+ const apiKey = getAmpApiKey();
172
+ await fetch(buildAmpUrl('/api/log'), {
173
+ method: 'POST',
174
+ headers: { 'X-API-Key': apiKey, 'Content-Type': 'application/json' },
175
+ body: JSON.stringify({
176
+ instance_id: instanceId,
177
+ service: config.AGENT_NAME,
178
+ level,
179
+ message,
180
+ timestamp: new Date().toISOString(),
181
+ org_id: config.AMP_ORG_ID,
182
+ username: config.AMP_USERNAME,
183
+ }),
184
+ });
185
+ } catch (err) {
186
+ console.error('[AMP Governance] ampLog failed:', err.message);
187
+ }
188
+ }
189
+
190
+ // ── PROACTIVE NOTIFICATIONS ──────────────────────────────────────────────────
191
+
192
+ /**
193
+ * Send a proactive WhatsApp message to the user without waiting for the agent
194
+ * to finish. Uses the openclaw CLI so we don't need to reverse-engineer the
195
+ * gateway REST API. Fire-and-forget — errors are logged but never thrown.
196
+ */
197
+ async function notifyUser(sender, message) {
198
+ if (!sender?.from || !_runtime) return;
199
+ const prefixed = `[AMP]\n${message}`;
200
+ console.log(`[AMP Governance] Sending notification to ${sender.from}: ${message}`);
201
+ try {
202
+ await _runtime.channel.whatsapp.sendMessageWhatsApp(sender.from, prefixed, { verbose: false });
203
+ } catch (err) {
204
+ console.warn('[AMP Governance] notifyUser failed:', err.message);
205
+ }
206
+ }
207
+
208
+ // ── HITL POLICY ENGINE ───────────────────────────────────────────────────────
209
+
210
+ /**
211
+ * Call /api/hitl/request with the tool call details.
212
+ * The AMP backend eval engine decides allow vs HITL.
213
+ */
214
+ async function requestHitlEval(instanceId, tool, params) {
215
+ const apiKey = getAmpApiKey();
216
+ const res = await fetch(buildAmpUrl('/api/hitl/request'), {
217
+ method: 'POST',
218
+ headers: { 'X-API-Key': apiKey, 'Content-Type': 'application/json' },
219
+ body: JSON.stringify({
220
+ caller_id: instanceId,
221
+ instance_id: instanceId,
222
+ org_id: config.AMP_ORG_ID,
223
+ agent_name: config.AGENT_NAME,
224
+ tool,
225
+ action: params?.action || '*',
226
+ context: params || {},
227
+ hitl: {
228
+ enable: true,
229
+ when: 'policy',
230
+ // who/what/where resolved by the backend from the policy's hitl_spec
231
+ },
232
+ }),
233
+ });
234
+ if (!res.ok) throw new Error(`/api/hitl/request returned HTTP ${res.status}`);
235
+ return res.json();
236
+ }
237
+
238
+ /**
239
+ * Poll /api/hitl/get-decision until we get a 'complete' status or time out.
240
+ */
241
+ async function pollHitlDecision(callerId) {
242
+ const apiKey = getAmpApiKey();
243
+ const deadline = Date.now() + HITL_TIMEOUT_MS;
244
+ while (Date.now() < deadline) {
245
+ await new Promise(r => setTimeout(r, HITL_POLL_INTERVAL_MS));
246
+ const res = await fetch(
247
+ `${buildAmpUrl('/api/hitl/get-decision')}?caller_id=${encodeURIComponent(callerId)}`,
248
+ { headers: { 'X-API-Key': apiKey } }
249
+ );
250
+ if (!res.ok) {
251
+ console.warn(`[AMP Governance] get-decision returned ${res.status}, retrying...`);
252
+ continue;
253
+ }
254
+ const data = await res.json();
255
+ if (data.status === 'complete') return data;
256
+ console.log(`[AMP Governance] Waiting for human decision... (${data.status})`);
257
+ }
258
+ throw new Error(`HITL decision timed out after ${HITL_TIMEOUT_MS / 60000} minutes`);
259
+ }
260
+
261
+ /**
262
+ * Full governance check for a single tool call.
263
+ * Returns normally if the call is allowed (or approved by a human).
264
+ * Throws an Error with a clear reason if the call is blocked or rejected.
265
+ * Fails open (returns without throwing) if AMP backend is unreachable.
266
+ */
267
+ /**
268
+ * Returns {} to allow, or { block: true, blockReason } to block.
269
+ * Never throws — errors fail open so infra issues don't break the agent.
270
+ */
271
+ async function checkToolPolicy(instanceId, tool, params) {
272
+ const paramSummary = formatInput(tool, params);
273
+ await ampLog(instanceId, `Policy check: ${tool} | ${paramSummary}`);
274
+
275
+ let hitlResponse;
276
+ try {
277
+ hitlResponse = await requestHitlEval(instanceId, tool, params);
278
+ } catch (err) {
279
+ console.warn(`[AMP Governance] HITL request failed (fail open): ${err.message}`);
280
+ await ampLog(instanceId, `AMP governance check failed (fail open): ${err.message}`, 'WARN');
281
+ return {};
282
+ }
283
+
284
+ const status = hitlResponse.status;
285
+ const reason = hitlResponse.reason || hitlResponse.information || '';
286
+ await ampLog(instanceId, `Policy decision: ${tool} | status=${status}${reason ? ` | ${reason}` : ''}`);
287
+
288
+ // ── No policy configured — block ─────────────────────────────────────────
289
+ if (status === 'no_policy') {
290
+ const msg = `Tool call "${tool}" blocked — no active governance policy for agent "${config.AGENT_NAME}". Contact your administrator.`;
291
+ await ampLog(instanceId, msg, 'WARN');
292
+ return { block: true, blockReason: msg };
293
+ }
294
+
295
+ // ── Eval engine approved — allow ─────────────────────────────────────────
296
+ if (status === 'no-hitl') {
297
+ console.log(`[AMP Governance] Policy approved: ${tool} (${reason || 'ok'})`);
298
+ return {};
299
+ }
300
+
301
+ // ── HITL required — wait for human decision ──────────────────────────────
302
+ if (status === 'pending' || status === 'waiting-for-response' || hitlResponse.workitem_id) {
303
+ console.log(`[AMP Governance] HITL required for "${tool}" — waiting for human decision...`);
304
+ await ampLog(instanceId, `HITL requested for tool: ${tool} — awaiting human approval in AMP`, 'WARN');
305
+
306
+ // Notify the user immediately so they aren't staring at silence
307
+ notifyUser(_lastSender, `I need a reviewer to approve "${tool}" before I can proceed. I'll follow up once the decision is made — this may take a few minutes.`);
308
+
309
+ let decision;
310
+ try {
311
+ decision = await pollHitlDecision(instanceId);
312
+ } catch (err) {
313
+ const msg = `Tool call "${tool}" timed out waiting for human approval — blocked.`;
314
+ await ampLog(instanceId, msg, 'ERROR');
315
+ notifyUser(_lastSender, `No response from the reviewer — "${tool}" was blocked due to timeout.`);
316
+ return { block: true, blockReason: msg };
317
+ }
318
+
319
+ const resolution = String(decision.resolution || '').toLowerCase().trim();
320
+ console.log(`[AMP Governance] HITL decision for "${tool}": ${resolution}`);
321
+
322
+ if (resolution === 'approve' || resolution === 'approved') {
323
+ await ampLog(instanceId, `HITL approved: ${tool} | workitem: ${decision.workitem_id}`);
324
+ notifyUser(_lastSender, `The reviewer approved "${tool}" — continuing now.`);
325
+ return {};
326
+ }
327
+
328
+ if (resolution === 'modify' || resolution === 'modified') {
329
+ await ampLog(instanceId, `HITL approved with modification: ${tool} | workitem: ${decision.workitem_id}`);
330
+ notifyUser(_lastSender, `The reviewer approved "${tool}" (with modifications) — continuing now.`);
331
+ return {};
332
+ }
333
+
334
+ // Rejected
335
+ const info = decision.information ? ` Reviewer note: ${decision.information}` : '';
336
+ const msg = `Tool call "${tool}" was rejected by a human reviewer.${info}`;
337
+ await ampLog(instanceId, `HITL rejected: ${tool} | workitem: ${decision.workitem_id}`, 'WARN');
338
+ notifyUser(_lastSender, `The reviewer rejected "${tool}".${info}`);
339
+ return { block: true, blockReason: msg };
340
+ }
341
+
342
+ // Unknown response — fail open
343
+ console.warn('[AMP Governance] Unexpected HITL response:', JSON.stringify(hitlResponse));
344
+ await ampLog(instanceId, `Unexpected HITL response for ${tool}: ${JSON.stringify(hitlResponse)}`, 'WARN');
345
+ return {};
346
+ }
347
+
348
+ // ── TEXT FORMATTING HELPERS ──────────────────────────────────────────────────
349
+
350
+ function extractText(obj) {
351
+ if (obj === undefined || obj === null) return '(empty)';
352
+ if (typeof obj === 'string') {
353
+ try { return extractText(JSON.parse(obj)); } catch { return obj; }
354
+ }
355
+ if (Array.isArray(obj)) {
356
+ return obj.map(extractText).filter(Boolean).join('\n');
357
+ }
358
+ if (typeof obj === 'object') {
359
+ if (obj.content && Array.isArray(obj.content)) {
360
+ return obj.content
361
+ .filter(c => c.text !== undefined)
362
+ .map(c => extractText(c.text))
363
+ .join('\n');
364
+ }
365
+ if (typeof obj.text === 'string') return extractText(obj.text);
366
+ return Object.entries(obj)
367
+ .filter(([k]) => k !== 'type')
368
+ .map(([k, v]) => `${k}: ${extractText(v)}`)
369
+ .join(' | ');
370
+ }
371
+ return String(obj);
372
+ }
373
+
374
+ function formatInput(tool, params) {
375
+ if (!params) return '';
376
+ try {
377
+ switch (tool) {
378
+ case 'web_search':
379
+ return `query: "${params.query}"${params.count ? ` (top ${params.count})` : ''}`;
380
+ case 'read':
381
+ return `path: ${params.path || params.file_path || JSON.stringify(params)}`;
382
+ case 'write':
383
+ case 'edit':
384
+ return `path: ${params.path || params.file_path || '?'}`;
385
+ case 'bash':
386
+ case 'shell':
387
+ return `cmd: ${String(params.command || params.cmd || '').substring(0, 120)}`;
388
+ default:
389
+ return extractText(params).substring(0, 200);
390
+ }
391
+ } catch {
392
+ return String(params).substring(0, 200);
393
+ }
394
+ }
395
+
396
+ function stripWrapper(s) {
397
+ if (typeof s !== 'string') return s;
398
+ return s
399
+ .replace(/<<<EXTERNAL_UNTRUSTED_CONTENT[^>]*>>>/g, '')
400
+ .replace(/<<<END_EXTERNAL_UNTRUSTED_CONTENT[^>]*>>>/g, '')
401
+ .replace(/Source:\s*Web Search\s*---/g, '')
402
+ .replace(/\s+/g, ' ')
403
+ .trim();
404
+ }
405
+
406
+ function getRawText(result) {
407
+ try {
408
+ const obj = typeof result === 'string' ? JSON.parse(result) : result;
409
+ if (obj?.content && Array.isArray(obj.content)) {
410
+ const item = obj.content.find(c => c.text !== undefined);
411
+ if (item) return String(item.text);
412
+ }
413
+ if (typeof obj?.text === 'string') return obj.text;
414
+ return typeof result === 'string' ? result : JSON.stringify(result);
415
+ } catch {
416
+ return typeof result === 'string' ? result : '';
417
+ }
418
+ }
419
+
420
+ function formatOutput(tool, result) {
421
+ if (result === undefined || result === null) return '(empty)';
422
+ try {
423
+ switch (tool) {
424
+ case 'web_search': {
425
+ const raw = getRawText(result);
426
+ try {
427
+ const tavily = JSON.parse(raw);
428
+ const results = tavily?.externalContent?.results || tavily?.results || [];
429
+ if (results.length > 0) {
430
+ const lines = results.slice(0, 3).map((r, i) => {
431
+ const title = stripWrapper(r.title || '');
432
+ const url = r.url || '';
433
+ const snippet = stripWrapper(r.content || r.snippet || '').substring(0, 200);
434
+ return `[${i + 1}] ${title} › ${url} › ${snippet}`;
435
+ });
436
+ return `${results.length} result(s): ${lines.join(' â—† ')}`;
437
+ }
438
+ } catch (e) {
439
+ console.error('[AMP Governance] web_search parse failed:', e.message);
440
+ }
441
+ return stripWrapper(raw).substring(0, 400);
442
+ }
443
+ case 'read': {
444
+ const text = extractText(result);
445
+ return text.substring(0, 300);
446
+ }
447
+ default:
448
+ return extractText(result).substring(0, 400);
449
+ }
450
+ } catch {
451
+ return String(result).substring(0, 300);
452
+ }
453
+ }
454
+
455
+ // ── PLUGIN REGISTRATION ──────────────────────────────────────────────────────
456
+
457
+ export default {
458
+ id: 'amp-governance',
459
+ name: 'AMP Governance',
460
+ description: 'AMP transparency, accountability, and HITL integration for OpenClaw',
461
+ register(api) {
462
+ api.logger.info('AMP Governance registered. Phase 4 - eval policy enforcement active.');
463
+ _runtime = api.runtime;
464
+
465
+ // ── HOOK AUTO-DEPLOY on gateway start ────────────────────────────────────
466
+ api.on('gateway_start', () => {
467
+ if (api.rootDir) deployHookFilesIfNeeded(api.rootDir);
468
+ });
469
+
470
+ // ── SESSION RESET: clear instance cache on /new or /reset ────────────────
471
+ api.on('session_start', () => {
472
+ console.log('[AMP Governance] Session started — clearing instance cache.');
473
+ _instanceId = null;
474
+ });
475
+
476
+ // ── INBOUND MESSAGE: cache sender for HITL notifications ─────────────────
477
+ api.on('message_received', (event, ctx) => {
478
+ if (event.from && ctx.channelId) {
479
+ _lastSender = { from: event.from, channelId: ctx.channelId };
480
+ console.log(`[AMP Governance] Sender cached: ${event.from} on ${ctx.channelId}`);
481
+ }
482
+ });
483
+
484
+ // ── BEFORE TOOL CALL: governance check + logging ─────────────────────────
485
+ api.on('before_tool_call', async (event) => {
486
+ const tool = event.toolName || event.name || 'unknown-tool';
487
+ if (SKIP_TOOLS.has(tool)) return {};
488
+
489
+ const instanceId = await ensureInstance();
490
+ if (!instanceId) {
491
+ console.warn('[AMP Governance] No instance available — skipping governance check.');
492
+ return {};
493
+ }
494
+
495
+ // Log the incoming tool call
496
+ const message = `Tool call: ${tool} | Input: ${formatInput(tool, event.params)}`;
497
+ console.log(`[AMP Governance] before_tool_call: ${tool} | instance: ${instanceId}`);
498
+ await ampLog(instanceId, message);
499
+
500
+ // Governance check — returns {} to allow or { block, blockReason } to block
501
+ return await checkToolPolicy(instanceId, tool, event.params);
502
+ });
503
+
504
+ // ── AFTER TOOL CALL: result logging ──────────────────────────────────────
505
+ api.on('after_tool_call', async (event) => {
506
+ const tool = event.toolName || event.name || 'unknown-tool';
507
+ if (SKIP_TOOLS.has(tool)) return;
508
+
509
+ const instanceId = _instanceId || readSession()?.instanceId;
510
+ if (!instanceId) return;
511
+
512
+ const result = event.result ?? event.output ?? event.toolOutput;
513
+ const status = event.success === false ? 'FAILED' : 'OK';
514
+ const duration = event.durationMs != null ? ` (${event.durationMs}ms)` : '';
515
+ const output = formatOutput(tool, result);
516
+ const message = `Tool result: ${tool} | Status: ${status}${duration} | ${output}`;
517
+
518
+ console.log(`[AMP Governance] after_tool_call: ${tool} | status: ${status}`);
519
+ await ampLog(instanceId, message, event.success === false ? 'ERROR' : 'INFO');
520
+ });
521
+
522
+ // ── OUTBOUND MESSAGE LOGGING ──────────────────────────────────────────────
523
+ api.on('message_sending', async (event) => {
524
+ const instanceId = _instanceId || readSession()?.instanceId;
525
+ const msgText = event.content || event.text || event.message || JSON.stringify(event);
526
+ const preview = msgText.substring(0, 100);
527
+ if (instanceId) {
528
+ await ampLog(instanceId, `Agent reply: ${preview}`);
529
+ }
530
+ });
531
+ },
532
+ };
@@ -0,0 +1,9 @@
1
+ {
2
+ "id": "amp-governance",
3
+ "name": "AMP Governance",
4
+ "description": "AMP transparency, accountability, and HITL integration for OpenClaw",
5
+ "configSchema": {
6
+ "type": "object",
7
+ "additionalProperties": false
8
+ }
9
+ }
package/package.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "@inquiryon/amp-governance",
3
+ "version": "1.1.4",
4
+ "description": "AMP governance plugin for OpenClaw",
5
+ "type": "module",
6
+ "files": [
7
+ "index.js",
8
+ "openclaw.plugin.json",
9
+ "hook/handler.ts",
10
+ "hook/HOOK.md",
11
+ "hook/amp_config.json"
12
+ ],
13
+ "publishConfig": {
14
+ "access": "public"
15
+ },
16
+ "openclaw": {
17
+ "extensions": [
18
+ "./index.js"
19
+ ]
20
+ }
21
+ }