@aporthq/aport-agent-guardrails 1.0.21 → 1.0.22

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.
@@ -1,547 +0,0 @@
1
- /**
2
- * APort OpenClaw Plugin
3
- *
4
- * Registers before_tool_call hook for deterministic policy enforcement.
5
- * Calls APort guardrail (local or API) before every tool execution.
6
- */
7
-
8
- import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
9
- import { spawn } from "child_process";
10
- import { createHash, randomUUID } from "crypto";
11
- import { readFile, mkdir, appendFile } from "fs/promises";
12
- import { appendFileSync, mkdirSync, existsSync } from "fs";
13
- import { join, dirname } from "path";
14
- import { homedir } from "os";
15
-
16
- // Re-export utility functions for testing
17
- export { mapToolToPolicy, canonicalize, verifyDecisionIntegrity };
18
-
19
- interface APortPluginConfig {
20
- mode?: "local" | "api";
21
- agentId?: string;
22
- passportFile?: string;
23
- guardrailScript?: string;
24
- apiUrl?: string;
25
- apiKey?: string;
26
- failClosed?: boolean;
27
- allowUnmappedTools?: boolean;
28
- alwaysVerifyEachToolCall?: boolean;
29
- mapExecToPolicy?: boolean;
30
- }
31
-
32
- export default definePluginEntry({
33
- id: "openclaw-aport",
34
- name: "APort Guardrails",
35
- description:
36
- "Deterministic pre-action authorization via APort policy enforcement. Registers before_tool_call to block disallowed tools.",
37
-
38
- register(api) {
39
- // Get plugin config
40
- const config = (api.pluginConfig || {}) as APortPluginConfig;
41
- const mode = config.mode || "local";
42
- const agentId = config.agentId || null;
43
- const passportFile = expandPath(
44
- config.passportFile || "~/.openclaw/aport/passport.json"
45
- );
46
- const guardrailScript = expandPath(
47
- config.guardrailScript || "~/.openclaw/.skills/aport-guardrail-bash.sh"
48
- );
49
- const apiUrl =
50
- config.apiUrl || process.env.APORT_API_URL || "https://api.aport.io";
51
- const apiKey = process.env.APORT_API_KEY || config.apiKey;
52
-
53
- const failClosed = config.failClosed !== false;
54
- const allowUnmappedTools = config.allowUnmappedTools !== false; // Default true for backward compatibility
55
- const mapExecToPolicy = config.mapExecToPolicy !== false;
56
-
57
- const log = (msg: string) => api.logger?.info?.(msg);
58
- const warn = (msg: string) => api.logger?.warn?.(msg);
59
- const err = (msg: string) => api.logger?.error?.(msg);
60
-
61
- log(
62
- `[APort] Loaded: mode=${mode}, ${
63
- agentId ? `agentId=${agentId}` : `passportFile=${passportFile}`
64
- }, unmapped=${
65
- allowUnmappedTools ? "allow" : "block"
66
- }, mapExec=${mapExecToPolicy}`
67
- );
68
-
69
- /**
70
- * before_tool_call hook - Runs before EVERY tool execution
71
- */
72
- api.on("before_tool_call", async (event: any, _ctx: any) => {
73
- const { toolName, params } = event;
74
-
75
- try {
76
- // Map OpenClaw tool names to APort policy names
77
- const policyName =
78
- toolName === "exec" && !mapExecToPolicy
79
- ? null
80
- : mapToolToPolicy(toolName);
81
-
82
- if (!policyName) {
83
- if (allowUnmappedTools) {
84
- log(`[APort] ALLOW: ${toolName} - (unmapped, no policy)`);
85
- return {};
86
- }
87
- log(
88
- `[APort] BLOCKED: ${toolName} - no policy mapping (allowUnmappedTools=false)`
89
- );
90
- return {
91
- block: true,
92
- blockReason: `🛡️ APort: Tool "${toolName}" has no policy mapping. Unmapped tools are blocked (allowUnmappedTools: false). Set allowUnmappedTools: true in config to allow unmapped custom skills and ClawHub tools.`,
93
- };
94
- }
95
-
96
- log(`[APort] Checking tool: ${toolName} → policy: ${policyName}`);
97
-
98
- // Normalize context
99
- let effectivePolicyName = policyName;
100
- let effectiveToolName = toolName;
101
- let context =
102
- policyName === "system.command.execute.v1"
103
- ? normalizeExecContext(params, event)
104
- : params;
105
-
106
- // Allow exec with no command
107
- if (effectivePolicyName === "system.command.execute.v1") {
108
- const cmdStr =
109
- typeof context.command === "string" ? context.command.trim() : "";
110
- if (!cmdStr) {
111
- log(`[APort] ALLOW: exec - (empty command, skip)`);
112
- return {};
113
- }
114
- }
115
-
116
- // Verify via API or script
117
- const scriptToolName = effectivePolicyName.replace(/\.v\d+$/, "");
118
- let decision: any;
119
- if (mode === "api") {
120
- decision = await verifyViaAPI(effectivePolicyName, context, {
121
- apiUrl,
122
- apiKey,
123
- passportFile: agentId ? null : passportFile,
124
- agentId,
125
- });
126
- // Audit log for API mode (local mode is logged by the bash script via OPENCLAW_AUDIT_LOG)
127
- const configDir = dirname(passportFile);
128
- const auditLogPath = join(configDir, "audit.log");
129
- const ctxSummary =
130
- typeof context.command === "string"
131
- ? context.command
132
- : typeof context.file_path === "string"
133
- ? context.file_path
134
- : typeof context.recipient === "string"
135
- ? context.recipient
136
- : undefined;
137
- logAuditEntry(auditLogPath, {
138
- tool: effectiveToolName,
139
- allow: Boolean(decision.allow),
140
- policy: effectivePolicyName,
141
- code: decision.reasons?.[0]?.code,
142
- agentId: agentId || undefined,
143
- context: ctxSummary,
144
- });
145
- } else {
146
- decision = await verifyViaScript(scriptToolName, context, {
147
- guardrailScript,
148
- passportFile,
149
- });
150
- }
151
-
152
- // Verify decision integrity (prevent tampering)
153
- if (!verifyDecisionIntegrity(decision)) {
154
- err(
155
- `[APort] Decision integrity check failed for ${effectiveToolName} - content_hash mismatch`
156
- );
157
- return {
158
- block: true,
159
- blockReason:
160
- "🛡️ APort: Decision integrity verification failed (content_hash mismatch). Possible tampering detected.",
161
- };
162
- }
163
-
164
- // Check decision
165
- if (!decision.allow) {
166
- const { reasons, primaryMessage } = formatReasons(decision);
167
- const message = primaryMessage || "Policy denied";
168
- log(`[APort] BLOCKED: ${effectiveToolName} - ${message}`);
169
-
170
- const reasonLines = reasons
171
- .map(
172
- (r: any) => ` • ${r.code || "oap.unknown"}: ${r.message || ""}`
173
- )
174
- .join("\n");
175
-
176
- const blockReason = [
177
- "🛡️ APort Policy Denied",
178
- "",
179
- `Policy: ${effectivePolicyName}`,
180
- "",
181
- "Reasons (OAP codes):",
182
- reasonLines || ` • ${message}`,
183
- "",
184
- agentId
185
- ? `To allow this action, update limits at aport.io (hosted passport: ${agentId})`
186
- : `To allow this action, update limits in your passport: ${passportFile}`,
187
- ].join("\n");
188
-
189
- return {
190
- block: true,
191
- blockReason,
192
- reasons,
193
- };
194
- }
195
-
196
- log(`[APort] ALLOW: ${effectiveToolName}`);
197
- return {
198
- reasons: decision.reasons?.length ? decision.reasons : undefined,
199
- };
200
- } catch (error: any) {
201
- err(`[APort] Error evaluating policy: ${error.message}`);
202
-
203
- if (failClosed) {
204
- return {
205
- block: true,
206
- blockReason: `🛡️ APort Policy Error (fail-closed)\n\nError: ${error.message}\n\nCheck configuration at plugins.entries.openclaw-aport.config`,
207
- };
208
- } else {
209
- warn(`[APort] Allowing tool despite error (failClosed=false)`);
210
- return {};
211
- }
212
- }
213
- });
214
-
215
- log(`[APort] Registered hooks: before_tool_call`);
216
- },
217
- });
218
-
219
- // Helper functions
220
-
221
- function formatReasons(decision: any) {
222
- const reasons = decision.reasons || [];
223
- const primaryMessage = reasons[0]?.message || decision.reason || "";
224
- return { reasons, primaryMessage };
225
- }
226
-
227
- function normalizeExecContext(params: any, event: any) {
228
- const src =
229
- event && typeof event === "object" ? { ...event, ...params } : params || {};
230
- if (typeof src !== "object") return { command: "" };
231
-
232
- const raw =
233
- src.command ??
234
- src.cmd ??
235
- (src.arguments &&
236
- typeof src.arguments === "object" &&
237
- src.arguments.command) ??
238
- (src.input && typeof src.input === "object" && src.input.command) ??
239
- (typeof src.input === "string" && src.input.trim().length > 0
240
- ? src.input
241
- : null) ??
242
- (src.args && typeof src.args === "object" && src.args.command) ??
243
- (src.invocation &&
244
- typeof src.invocation === "object" &&
245
- src.invocation.command) ??
246
- (src.payload && typeof src.payload === "object" && src.payload.command) ??
247
- (Array.isArray(src.args) && src.args.length > 0
248
- ? src.args.join(" ")
249
- : src.args?.[0]);
250
-
251
- const full = typeof raw === "string" ? raw : raw != null ? String(raw) : "";
252
-
253
- const out = { ...params, command: full, full_command: full };
254
- if (params && params.workdir !== undefined && out.cwd === undefined)
255
- out.cwd = params.workdir;
256
- return out;
257
- }
258
-
259
- function mapToolToPolicy(toolName: string): string | null {
260
- const tool = toolName.toLowerCase();
261
-
262
- // Git/Code operations
263
- if (tool.match(/git\.(create_pr|merge|push|commit)/))
264
- return "code.repository.merge.v1";
265
- if (tool.startsWith("git.")) return "code.repository.merge.v1";
266
-
267
- // System commands / exec
268
- if (tool === "exec") return "system.command.execute.v1";
269
- if (tool.match(/exec\.(run|shell)/)) return "system.command.execute.v1";
270
- if (tool.startsWith("exec.")) return "system.command.execute.v1";
271
- if (tool.startsWith("system.command.")) return "system.command.execute.v1";
272
- if (tool === "bash" || tool === "shell" || tool === "command")
273
- return "system.command.execute.v1";
274
-
275
- // Messaging
276
- if (tool.startsWith("message.")) return "messaging.message.send.v1";
277
- if (tool.startsWith("messaging.")) return "messaging.message.send.v1";
278
- if (tool.match(/sms|whatsapp|slack|email/))
279
- return "messaging.message.send.v1";
280
-
281
- // File operations
282
- if (tool === "read") return "data.file.read.v1";
283
- if (tool.startsWith("file.read")) return "data.file.read.v1";
284
- if (tool.startsWith("data.file.read")) return "data.file.read.v1";
285
- if (tool === "write") return "data.file.write.v1";
286
- if (tool === "edit") return "data.file.write.v1";
287
- // Claude Code tool names
288
- if (tool === "multiedit" || tool === "notebookedit")
289
- return "data.file.write.v1";
290
- if (
291
- tool === "glob" ||
292
- tool === "ls" ||
293
- tool === "grep" ||
294
- tool === "toolsearch"
295
- )
296
- return "data.file.read.v1";
297
- if (tool === "todoread") return "data.file.read.v1";
298
- if (tool === "todowrite") return "data.file.write.v1";
299
- if (
300
- tool === "task" ||
301
- tool === "taskcreate" ||
302
- tool === "taskupdate" ||
303
- tool === "taskstop"
304
- )
305
- return "agent.session.create.v1";
306
- if (tool === "taskget" || tool === "tasklist" || tool === "taskoutput")
307
- return "data.file.read.v1";
308
- if (tool === "agent" || tool === "skill" || tool === "enterworktree")
309
- return "agent.session.create.v1";
310
- if (
311
- tool === "askuserquestion" ||
312
- tool === "enterplanmode" ||
313
- tool === "exitplanmode"
314
- )
315
- return null; // allow
316
- if (tool === "croncreate" || tool === "crondelete")
317
- return "agent.session.create.v1";
318
- if (tool === "cronlist") return "data.file.read.v1";
319
- if (tool.startsWith("file.write")) return "data.file.write.v1";
320
- if (tool.startsWith("file.edit")) return "data.file.write.v1";
321
- if (tool.startsWith("data.file.write")) return "data.file.write.v1";
322
-
323
- // Web operations
324
- if (tool === "web_fetch" || tool === "webfetch") return "web.fetch.v1";
325
- if (tool === "web_search" || tool === "websearch") return "web.fetch.v1";
326
- if (tool.startsWith("web.fetch")) return "web.fetch.v1";
327
- if (tool.startsWith("web.search")) return "web.fetch.v1";
328
- if (tool === "browser") return "web.browser.v1";
329
- if (tool.startsWith("web.browser")) return "web.browser.v1";
330
- if (tool.startsWith("browser.")) return "web.browser.v1";
331
-
332
- // MCP tools
333
- if (tool.startsWith("mcp.")) return "mcp.tool.execute.v1";
334
- // Claude Code MCP tools use mcp__ prefix (double underscore, not mcp.)
335
- if (tool.startsWith("mcp__")) return "mcp.tool.execute.v1";
336
-
337
- // Agent sessions and spawning
338
- if (tool.match(/agent\.session|session\.create/))
339
- return "agent.session.create.v1";
340
- if (tool === "sessions_spawn" || tool === "sessions_send")
341
- return "agent.session.create.v1";
342
- if (tool.startsWith("session.") || tool.startsWith("sessions."))
343
- return "agent.session.create.v1";
344
-
345
- // Scheduled tasks (cron)
346
- if (tool === "cron" || tool.startsWith("cron."))
347
- return "agent.session.create.v1";
348
-
349
- // Gateway operations (high risk - treat as command execution)
350
- if (tool === "gateway" || tool.startsWith("gateway."))
351
- return "system.command.execute.v1";
352
-
353
- // Process operations
354
- if (tool === "process" || tool.startsWith("process."))
355
- return "system.command.execute.v1";
356
-
357
- // Tool registration
358
- if (tool.match(/agent\.tool|tool\.register/)) return "agent.tool.register.v1";
359
-
360
- // Financial operations
361
- if (tool.match(/payment\.refund|refund/)) return "finance.payment.refund.v1";
362
- if (tool.match(/payment\.charge|charge/)) return "finance.payment.charge.v1";
363
- if (tool.startsWith("finance.")) return "finance.payment.refund.v1";
364
-
365
- // Data operations
366
- if (tool.match(/database\.(write|insert|update|delete)/))
367
- return "data.export.create.v1";
368
- if (tool.match(/data\.export|export/)) return "data.export.create.v1";
369
-
370
- return null;
371
- }
372
-
373
- function canonicalize(obj: any): string {
374
- if (obj === null || typeof obj !== "object") return JSON.stringify(obj);
375
- if (Array.isArray(obj)) return "[" + obj.map(canonicalize).join(",") + "]";
376
- const keys = Object.keys(obj).sort();
377
- const parts = keys.map((k) => JSON.stringify(k) + ":" + canonicalize(obj[k]));
378
- return "{" + parts.join(",") + "}";
379
- }
380
-
381
- function verifyDecisionIntegrity(decision: any): boolean {
382
- if (!decision || !decision.content_hash) return true;
383
- const { content_hash, ...rest } = decision;
384
- const canonical = canonicalize(rest);
385
- const computed =
386
- "sha256:" + createHash("sha256").update(canonical, "utf8").digest("hex");
387
- return computed === content_hash;
388
- }
389
-
390
- async function verifyViaScript(
391
- toolName: string,
392
- params: any,
393
- { guardrailScript, passportFile }: any
394
- ): Promise<any> {
395
- const contextJson = JSON.stringify(params);
396
- const configDir = dirname(passportFile);
397
- const decisionsDir = join(configDir, "decisions");
398
- await mkdir(decisionsDir, { recursive: true });
399
- const decisionFile = join(decisionsDir, `${randomUUID()}.json`);
400
-
401
- return new Promise((resolve, reject) => {
402
- const proc = spawn(guardrailScript, [toolName, contextJson], {
403
- env: {
404
- ...process.env,
405
- OPENCLAW_PASSPORT_FILE: passportFile,
406
- OPENCLAW_DECISION_FILE: decisionFile,
407
- OPENCLAW_AUDIT_LOG: join(configDir, "audit.log"),
408
- },
409
- });
410
-
411
- let stdout = "";
412
- let stderr = "";
413
-
414
- proc.stdout.on("data", (data) => (stdout += data));
415
- proc.stderr.on("data", (data) => (stderr += data));
416
-
417
- proc.on("close", async (code) => {
418
- try {
419
- const decisionData = await readFile(decisionFile, "utf8");
420
- const decision = JSON.parse(decisionData);
421
- resolve(decision);
422
- } catch (err) {
423
- if (code === 0) {
424
- resolve({ allow: true });
425
- } else {
426
- resolve({
427
- allow: false,
428
- reasons: [
429
- { message: stderr || `Tool ${toolName} denied (exit ${code})` },
430
- ],
431
- });
432
- }
433
- }
434
- });
435
-
436
- proc.on("error", (error) => {
437
- reject(new Error(`Failed to run guardrail script: ${error.message}`));
438
- });
439
- });
440
- }
441
-
442
- function ensureIdempotencyKey(context: any) {
443
- if (context && context.idempotency_key) return context;
444
- const ts = Date.now().toString(36);
445
- const r = Math.random().toString(36).slice(2, 10);
446
- const key = `idem_${ts}_${r}`.slice(0, 64);
447
- return { ...context, idempotency_key: key };
448
- }
449
-
450
- async function verifyViaAPI(
451
- policyName: string,
452
- params: any,
453
- { apiUrl, apiKey, passportFile, agentId }: any
454
- ): Promise<any> {
455
- try {
456
- const context = ensureIdempotencyKey(params);
457
-
458
- const url = `${apiUrl}/api/verify/policy/${policyName}`;
459
- const headers: any = {
460
- "Content-Type": "application/json",
461
- };
462
- if (apiKey) {
463
- headers["Authorization"] = `Bearer ${apiKey}`;
464
- }
465
-
466
- let body;
467
- if (agentId) {
468
- body = JSON.stringify({
469
- context: { agent_id: agentId, ...context },
470
- });
471
- } else {
472
- const passportData = await readFile(passportFile, "utf8");
473
- const passport = JSON.parse(passportData);
474
- body = JSON.stringify({
475
- passport,
476
- context,
477
- });
478
- }
479
-
480
- const response = await fetch(url, {
481
- method: "POST",
482
- headers,
483
- body,
484
- });
485
-
486
- if (!response.ok) {
487
- throw new Error(
488
- `API request failed: ${response.status} ${response.statusText}`
489
- );
490
- }
491
-
492
- const data = (await response.json()) as { decision?: any };
493
- return data.decision || data;
494
- } catch (error: any) {
495
- throw new Error(`API verification failed: ${error.message}`);
496
- }
497
- }
498
-
499
- function expandPath(path: string): string {
500
- if (path.startsWith("~/")) {
501
- return join(homedir(), path.slice(2));
502
- }
503
- return path;
504
- }
505
-
506
- /**
507
- * Write one-line audit entry matching bash guardrail format.
508
- * Deny: sync (blocking). Allow: async (non-blocking). Best-effort: never throws.
509
- */
510
- function logAuditEntry(
511
- auditLogPath: string,
512
- entry: {
513
- tool: string;
514
- allow: boolean;
515
- policy: string;
516
- code?: string;
517
- agentId?: string;
518
- context?: string;
519
- }
520
- ): void {
521
- try {
522
- const ts = new Date()
523
- .toISOString()
524
- .replace("T", " ")
525
- .replace(/\.\d+Z$/, "");
526
- const code = entry.code || (entry.allow ? "oap.allowed" : "oap.denied");
527
- let line = `[${ts}] tool=${entry.tool} allow=${entry.allow} policy=${entry.policy} code=${code}`;
528
- if (entry.agentId) line += ` agent_id=${entry.agentId}`;
529
- if (entry.context) {
530
- const sanitized = entry.context
531
- .replace(/[\r\n]+/g, " ")
532
- .replace(/"/g, '\\"')
533
- .slice(0, 120);
534
- line += ` context="${sanitized}"`;
535
- }
536
- line += "\n";
537
- const dir = dirname(auditLogPath);
538
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
539
- if (!entry.allow) {
540
- appendFileSync(auditLogPath, line, "utf8");
541
- } else {
542
- appendFile(auditLogPath, line, "utf8").catch(() => {});
543
- }
544
- } catch {
545
- /* best-effort */
546
- }
547
- }