@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.
@@ -2,633 +2,211 @@
2
2
  /**
3
3
  * APort OpenClaw Plugin
4
4
  *
5
- * Registers before_tool_call hook for deterministic policy enforcement.
6
- * Calls APort guardrail (local or API) before every tool execution.
7
- * Returns { block?, blockReason?, params?, reasons?, reasonSummary? }. On allow, reasons from APort are propagated for UX.
8
- *
9
- * Installation:
10
- * openclaw plugins install /path/to/aport-agent-guardrails/extensions/openclaw-aport
11
- *
12
- * Configuration (in config.yaml):
13
- * plugins:
14
- * entries:
15
- * openclaw-aport:
16
- * enabled: true
17
- * config:
18
- * mode: local # "local" | "api"
19
- * passportFile: ~/.openclaw/aport/passport.json # Omit when using agentId (hosted)
20
- * agentId: ap_... # Optional: hosted passport from aport.io (API fetches passport)
21
- * guardrailScript: ~/.openclaw/.skills/aport-guardrail-bash.sh
22
- * apiUrl: https://api.aport.io # For API mode
23
- * # apiKey optional: set APORT_API_KEY env var if your API requires it
24
- * failClosed: true # Block on error
25
- *
26
- * Decisions (local mode): Written to <config_dir>/decisions/<timestamp>-<id>.json and left for
27
- * audit. The guardrail script also appends a one-line summary to <config_dir>/audit.log (when
28
- * passport is in config/aport/, audit lives in config/aport/audit.log). Decisions
29
- * follow OAP v1.0 schema (see agent-passport spec/oap/decision-schema.json). Local mode uses
30
- * unsigned/local-unsigned; API mode can return signed decisions (chained audit in agent-passport).
5
+ * Deterministic pre-action authorization via before_tool_call.
6
+ * Uses a built-in JS local evaluator for offline mode and direct API calls for hosted mode.
31
7
  */
32
8
 
33
- import { spawn } from "child_process";
34
- import { createHash } from "crypto";
35
- import { readFile, mkdir } from "fs/promises";
36
- import { join, dirname } from "path";
37
- import { homedir } from "os";
38
-
39
- export default function (api) {
40
- const id = "openclaw-aport";
41
- const name = "APort Guardrails";
42
-
43
- // Plugin config from plugins.entries.openclaw-aport.config (OpenClaw passes api.pluginConfig)
44
- const config = api.pluginConfig || {};
45
- const mode = config.mode || "local";
46
- const agentId = config.agentId || null;
47
- const passportFile = expandPath(
48
- config.passportFile || "~/.openclaw/aport/passport.json",
49
- );
50
- const guardrailScript = expandPath(
51
- config.guardrailScript || "~/.openclaw/.skills/aport-guardrail-bash.sh",
52
- );
53
- const apiUrl =
54
- config.apiUrl || process.env.APORT_API_URL || "https://api.aport.io";
55
- const apiKey = config.apiKey || process.env.APORT_API_KEY;
56
- const failClosed = config.failClosed !== false; // Default true
57
- const allowUnmappedTools = config.allowUnmappedTools !== false; // Default true; set false to block unmapped tools
58
- // When true (default), every before_tool_call runs a fresh APort verify; we never reuse a previous decision (passport/limits may have changed).
59
- const alwaysVerifyEachToolCall = config.alwaysVerifyEachToolCall !== false;
60
- // When true (default), exec is mapped to system.command.execute.v1 and checked against passport allowed_commands.
61
- // When false, exec is not mapped (unmapped tools allowed by default) so OpenClaw can run any command — no guardrail for exec (use only if you rely on other controls).
62
- const mapExecToPolicy = config.mapExecToPolicy !== false;
63
-
64
- const log = (msg) => api.logger?.info?.(msg);
65
- const warn = (msg) => api.logger?.warn?.(msg);
66
- const err = (msg) => api.logger?.error?.(msg);
67
-
68
- /**
69
- * One-line summary for ALLOW/BLOCKED logs — tool + context hint. No I/O, no heavy work.
70
- * Keeps logs scannable and screenshot-friendly (e.g. "system.command.execute - mkdir test").
71
- */
72
- function decisionLogSummary(effectiveToolName, policyName, context) {
73
- if (policyName === "system.command.execute.v1" && context?.command) {
74
- const cmd = String(context.command).replace(/\s+/g, " ").trim();
75
- return cmd.length > 52 ? cmd.slice(0, 52) + "…" : cmd;
76
- }
77
- if (policyName === "messaging.message.send.v1") {
78
- const to = context?.recipient ?? context?.to ?? "";
79
- return to ? `send → ${String(to).slice(0, 32)}` : "send";
80
- }
81
- if (policyName?.startsWith("code.repository.")) return "repo";
82
- if (policyName?.startsWith("mcp.")) return "mcp tool";
83
- return policyName?.replace(/\.v\d+$/, "") ?? effectiveToolName;
84
- }
85
-
86
- /** Format decision.reasons (OAP code + message) for logs and UX; used for both allow and deny. */
87
- function formatReasons(decision) {
88
- const reasons = decision.reasons || [];
89
- const primaryMessage = reasons[0]?.message || decision.reason || "";
90
- const codes = reasons.map((r) => r.code).filter(Boolean);
91
- const codeList = codes.length ? codes.join(", ") : "";
92
- const lines =
93
- reasons.length > 0
94
- ? reasons
95
- .map((r) => ` • ${r.code || "oap.unknown"}: ${r.message || ""}`)
96
- .join("\n")
97
- : primaryMessage
98
- ? ` • ${primaryMessage}`
99
- : "";
100
- return { reasons, codeList, lines, primaryMessage };
101
- }
102
-
103
- /**
104
- * Detect if exec is actually invoking our guardrail script (e.g. agent/skill runs
105
- * "aport-guardrail.sh messaging.message.send '{}'" or "aport-guardrail.sh system.command.execute '{\"command\":\"mkdir ...\"}'").
106
- * If so, return { innerToolName, innerContext } so we evaluate the inner tool's policy, not exec as a shell command.
107
- * @param {string} command - params.command from exec
108
- * @returns {{ innerToolName: string, innerContext: object } | null}
109
- */
110
- function parseGuardrailInvocation(command) {
111
- if (typeof command !== "string" || !command.includes("aport-guardrail"))
112
- return null;
113
- const match = command.match(
114
- /aport-guardrail[^\s]*\s+(\S+)\s+['"]([\s\S]*)['"]\s*$/,
9
+ import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
10
+ import { readFile } from "node:fs/promises";
11
+ import { dirname, join } from "node:path";
12
+ import { homedir } from "node:os";
13
+ import { logAuditEntry } from "./audit.js";
14
+ import { canonicalize, formatReasons, verifyDecisionIntegrity } from "./decision.js";
15
+ import { evaluateLocalDecision } from "./local-evaluator.js";
16
+ import { mapToolToPolicy, normalizeExecContext } from "./tool-mapping.js";
17
+ import { verifyViaApi } from "./api-client.js";
18
+
19
+ export { canonicalize, mapToolToPolicy, verifyDecisionIntegrity };
20
+
21
+ export default definePluginEntry({
22
+ id: "openclaw-aport",
23
+ name: "APort Guardrails",
24
+ description:
25
+ "Deterministic pre-action authorization via APort policy enforcement. Registers before_tool_call to block disallowed tools.",
26
+
27
+ register(api) {
28
+ const config = api.pluginConfig || {};
29
+ const mode = config.mode === "api" ? "api" : "local";
30
+ const agentId = typeof config.agentId === "string" && config.agentId ? config.agentId : null;
31
+ const passportFile = expandPath(config.passportFile || "~/.openclaw/aport/passport.json");
32
+ const apiUrl = config.apiUrl || "https://api.aport.io";
33
+ const apiKey = config.apiKey || undefined;
34
+ const failClosed = config.failClosed !== false;
35
+ const allowUnmappedTools = config.allowUnmappedTools !== false;
36
+ const mapExecToPolicy = config.mapExecToPolicy !== false;
37
+
38
+ const log = (msg) => api.logger?.info?.(msg);
39
+ const warn = (msg) => api.logger?.warn?.(msg);
40
+ const err = (msg) => api.logger?.error?.(msg);
41
+
42
+ log(
43
+ `[APort] Loaded: mode=${mode}, ${agentId ? `agentId=${agentId}` : `passportFile=${passportFile}`}, unmapped=${allowUnmappedTools ? "allow" : "block"}, mapExec=${mapExecToPolicy}`,
115
44
  );
116
- if (!match) return null;
117
- const innerToolName = match[1];
118
- let innerContext = {};
119
- try {
120
- const jsonStr = match[2].trim();
121
- if (jsonStr) innerContext = JSON.parse(jsonStr);
122
- } catch (_) {
123
- return null;
124
- }
125
- return { innerToolName, innerContext };
126
- }
127
45
 
128
- /** Collect all string values from a nested object (like openclaw-shield). */
129
- function collectStrings(value) {
130
- const out = [];
131
- if (typeof value === "string") {
132
- out.push(value);
133
- } else if (Array.isArray(value)) {
134
- for (const v of value) out.push(...collectStrings(v));
135
- } else if (value && typeof value === "object") {
136
- for (const v of Object.values(value)) out.push(...collectStrings(v));
137
- }
138
- return out;
139
- }
140
-
141
- /**
142
- * Normalize context for exec / system.command.execute so the actual shell command
143
- * is always in context.command. OpenClaw uses one tool "exec" for all commands (cp, mkdir, etc.);
144
- * the policy checks the command string against allowed_commands, so we must pass the real command.
145
- * Per https://docs.openclaw.ai/tools/exec the tool takes "command" (required). Gateway may
146
- * pass it as params.command, event.input, or nested; we also fall back to first long string in params/event.
147
- * @param {object} params - event.params from before_tool_call
148
- * @param {object} [event] - full event in case gateway puts command on event.input/event.arguments
149
- */
150
- function normalizeExecContext(params, event) {
151
- const src =
152
- event && typeof event === "object"
153
- ? { ...event, ...params }
154
- : params || {};
155
- if (typeof src !== "object") return { command: "" };
156
- const raw =
157
- src.command ??
158
- src.cmd ??
159
- (src.arguments &&
160
- typeof src.arguments === "object" &&
161
- src.arguments.command) ??
162
- (src.input && typeof src.input === "object" && src.input.command) ??
163
- (typeof src.input === "string" && src.input.trim().length > 0
164
- ? src.input
165
- : null) ??
166
- (src.args && typeof src.args === "object" && src.args.command) ??
167
- (src.invocation &&
168
- typeof src.invocation === "object" &&
169
- src.invocation.command) ??
170
- (src.payload && typeof src.payload === "object" && src.payload.command) ??
171
- (Array.isArray(src.args) && src.args.length > 0
172
- ? src.args.join(" ")
173
- : src.args?.[0]);
174
- let full = typeof raw === "string" ? raw : raw != null ? String(raw) : "";
175
- if (!full) {
176
- const strings = collectStrings(src);
177
- const likeCommand = (s) =>
178
- typeof s === "string" && s.length > 2 && s.trim().length > 0;
179
- const withSpace = strings.filter(
180
- (s) => likeCommand(s) && s.includes(" "),
181
- );
182
- const candidate = withSpace[0] ?? strings.find(likeCommand);
183
- if (candidate) full = candidate.trim();
184
- }
185
- const out = { ...params, command: full, full_command: full };
186
- if (params && params.workdir !== undefined && out.cwd === undefined)
187
- out.cwd = params.workdir;
188
- return out;
189
- }
46
+ api.on("before_tool_call", async (event) => {
47
+ const { toolName, params } = event;
190
48
 
191
- log(
192
- `[${name}] Loaded: mode=${mode}, ${agentId ? `agentId=${agentId}` : `passportFile=${passportFile}`}, unmapped=${allowUnmappedTools ? "allow" : "block"}, alwaysVerify=${alwaysVerifyEachToolCall}, mapExec=${mapExecToPolicy}`,
193
- );
194
-
195
- /**
196
- * before_tool_call hook - Runs before EVERY tool execution.
197
- * We never reuse a previous decision: each call triggers a fresh APort verify (passport/limits may have changed).
198
- *
199
- * @param {object} event - { toolName, params, ... }
200
- * @param {object} ctx - OpenClaw context
201
- * @returns {Promise<object>} - { block?, blockReason?, params?, reasons? (OAP), reasonSummary? }
202
- */
203
- api.on("before_tool_call", async (event, ctx) => {
204
- const { toolName, params } = event;
205
-
206
- try {
207
- // Map OpenClaw tool names to APort policy names. If mapExecToPolicy is false, exec is unmapped (never blocked).
208
- const policyName =
209
- toolName === "exec" && !mapExecToPolicy
210
- ? null
211
- : mapToolToPolicy(toolName);
49
+ try {
50
+ const policyName =
51
+ toolName === "exec" && !mapExecToPolicy ? null : mapToolToPolicy(toolName);
212
52
 
213
- if (!policyName) {
214
- // No policy mapping: allow by default for compatibility; block only when strict mode is enabled.
215
- if (allowUnmappedTools) {
216
- log(`[${name}] ALLOW: ${toolName} - (unmapped, no policy)`);
217
- return {};
53
+ if (!policyName) {
54
+ if (allowUnmappedTools) {
55
+ log(`[APort] ALLOW: ${toolName} - (unmapped, no policy)`);
56
+ return {};
57
+ }
58
+ log(`[APort] BLOCKED: ${toolName} - no policy mapping (allowUnmappedTools=false)`);
59
+ return {
60
+ block: true,
61
+ 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.`,
62
+ };
218
63
  }
219
- log(
220
- `[${name}] BLOCKED: ${toolName} - no policy mapping (allowUnmappedTools=false)`,
221
- );
222
- return {
223
- block: true,
224
- blockReason: `🛡️ APort: Tool "${toolName}" has no policy mapping. Unmapped tools are blocked (allowUnmappedTools: false). Set allowUnmappedTools: true in config to allow custom skills and ClawHub tools.`,
225
- };
226
- }
227
64
 
228
- log(`[${name}] Checking tool: ${toolName} → policy: ${policyName}`);
65
+ let effectivePolicyName = policyName;
66
+ let effectiveToolName = toolName;
67
+ let context =
68
+ policyName === "system.command.execute.v1"
69
+ ? normalizeExecContext(params, event)
70
+ : (params || {});
229
71
 
230
- // For exec: the "command" may be (1) a real shell command (mkdir, npm, etc.) or
231
- // (2) an invocation of our guardrail script (e.g. aport-guardrail.sh messaging.message.send '{}').
232
- // In case (2) we evaluate the inner tool's policy, not exec as a shell command.
233
- let effectivePolicyName = policyName;
234
- let effectiveToolName = toolName;
235
- let context =
236
- policyName === "system.command.execute.v1"
237
- ? normalizeExecContext(params, event)
238
- : params;
239
-
240
- if (policyName === "system.command.execute.v1" && context.command) {
241
- const guardrailInvocation = parseGuardrailInvocation(context.command);
242
- if (guardrailInvocation) {
243
- const { innerToolName, innerContext } = guardrailInvocation;
244
- const innerPolicy = mapToolToPolicy(innerToolName);
72
+ const delegated = parseGuardrailInvocation(
73
+ effectivePolicyName === "system.command.execute.v1" ? context.command : null,
74
+ );
75
+ if (delegated) {
76
+ const innerPolicy = mapToolToPolicy(delegated.innerToolName);
245
77
  if (innerPolicy) {
246
78
  effectivePolicyName = innerPolicy;
247
- effectiveToolName = innerToolName;
79
+ effectiveToolName = delegated.innerToolName;
248
80
  context =
249
81
  innerPolicy === "system.command.execute.v1"
250
- ? normalizeExecContext(innerContext, { params: innerContext })
251
- : innerContext;
252
- log(
253
- `[${name}] exec delegates to inner tool: ${innerToolName} → policy: ${innerPolicy}`,
254
- );
82
+ ? normalizeExecContext(delegated.innerContext, { params: delegated.innerContext })
83
+ : delegated.innerContext;
255
84
  }
256
85
  }
257
- const cmd = context.command || "";
258
- log(
259
- `[${name}] exec params.command → effective policy=${effectivePolicyName} context.command=${cmd ? `"${cmd.slice(0, 60)}${cmd.length > 60 ? "…" : ""}"` : "(n/a)"}`,
260
- );
261
- }
262
86
 
263
- // Allow exec with no command (probe/placeholder) without calling guardrail so we don't block pre-checks.
264
- if (effectivePolicyName === "system.command.execute.v1") {
265
- const cmdStr =
266
- typeof context.command === "string" ? context.command.trim() : "";
267
- if (!cmdStr) {
268
- log(`[${name}] ALLOW: exec - (empty command, skip)`);
269
- return {};
87
+ if (effectivePolicyName === "system.command.execute.v1") {
88
+ const command = typeof context.command === "string" ? context.command.trim() : "";
89
+ if (!command) {
90
+ log("[APort] ALLOW: exec - (empty command, skip)");
91
+ return {};
92
+ }
270
93
  }
271
- }
272
94
 
273
- // Every call runs a fresh verify — no cache. Each invocation gets a unique decision file path; we never reuse a previous decision.
274
- // Local mode: guardrail script maps tool names via case "exec.run|exec.*|system.*" etc. Raw "exec" does not match, so pass policy-derived name (e.g. system.command.execute) so the script recognizes it.
275
- const scriptToolName = effectivePolicyName.replace(/\.v\d+$/, "");
276
- let decision;
277
- if (mode === "api") {
278
- decision = await verifyViaAPI(effectivePolicyName, context, {
279
- apiUrl,
280
- apiKey,
281
- passportFile: agentId ? null : passportFile,
282
- agentId,
283
- });
284
- } else {
285
- decision = await verifyViaScript(scriptToolName, context, {
286
- guardrailScript,
287
- passportFile,
288
- });
289
- }
95
+ const requestContext = ensureIdempotencyKey(context);
96
+ const auditLogPath = join(dirname(passportFile), "audit.log");
97
+ const decision =
98
+ mode === "api"
99
+ ? await verifyViaApi({
100
+ apiUrl,
101
+ apiKey,
102
+ policyName: effectivePolicyName,
103
+ context: requestContext,
104
+ passport: agentId ? null : JSON.parse(await readFile(passportFile, "utf8")),
105
+ agentId,
106
+ })
107
+ : evaluateLocalDecision({
108
+ policyName: effectivePolicyName,
109
+ context: requestContext,
110
+ passportFile,
111
+ });
112
+
113
+ if (!verifyDecisionIntegrity(decision)) {
114
+ err(`[APort] Decision integrity check failed for ${effectiveToolName} - content_hash mismatch`);
115
+ return {
116
+ block: true,
117
+ blockReason:
118
+ "🛡️ APort: Decision integrity verification failed (content_hash mismatch). Possible tampering detected.",
119
+ };
120
+ }
290
121
 
291
- // Tamper check is non-core: run after we return so it never blocks the tool call
292
- if (!decision.allow && decision.content_hash) {
293
- const decisionId = decision.decision_id;
294
- setImmediate(() => {
295
- if (!verifyDecisionIntegrity(decision)) {
296
- warn(
297
- `[${name}] Decision ${decisionId} may be tampered (content_hash mismatch)`,
298
- );
299
- }
122
+ logAuditEntry(auditLogPath, {
123
+ tool: effectiveToolName,
124
+ decisionId: decision.decision_id,
125
+ allow: Boolean(decision.allow),
126
+ policy: effectivePolicyName,
127
+ code: decision.reasons?.[0]?.code,
128
+ agentId: agentId || decision.agent_id || undefined,
129
+ context: extractContextSummary(requestContext),
300
130
  });
301
- }
302
131
 
303
- if (!decision.allow) {
304
- const {
305
- reasons,
306
- codeList,
307
- lines: reasonLines,
308
- primaryMessage,
309
- } = formatReasons(decision);
310
- const message = primaryMessage || "Policy denied";
311
- log(
312
- `[${name}] BLOCKED: ${effectiveToolName} - ${message}${codeList ? ` (${codeList})` : ""}`,
313
- );
314
- const isCommandNotAllowed =
315
- effectivePolicyName === "system.command.execute.v1" &&
316
- reasons.some((r) => r.code === "oap.command_not_allowed");
317
- if (isCommandNotAllowed) {
318
- if (agentId) {
319
- warn(
320
- `[${name}] Hosted passport (agent_id: ${agentId}). Add allowed_commands at aport.io or use "*" to allow all (blocked patterns still apply).`,
321
- );
322
- } else {
323
- try {
324
- const passportData = await readFile(passportFile, "utf8");
325
- const passport = JSON.parse(passportData);
326
- const allowed =
327
- passport?.limits?.["system.command.execute"]?.allowed_commands;
328
- warn(
329
- `[${name}] Passport allowed_commands: ${JSON.stringify(allowed)} — add "*" or the command (e.g. ls) to fix. File: ${passportFile}`,
330
- );
331
- } catch (_) {
332
- warn(
333
- `[${name}] Could not read passport for diagnostic: ${passportFile}`,
334
- );
335
- }
336
- }
132
+ if (!decision.allow) {
133
+ const { reasons, primaryMessage } = formatReasons(decision);
134
+ const message = primaryMessage || "Policy denied";
135
+ log(`[APort] BLOCKED: ${effectiveToolName} - ${message}`);
136
+
137
+ const reasonLines = reasons
138
+ .map((reason) => ` • ${reason.code || "oap.unknown"}: ${reason.message || ""}`)
139
+ .join("\n");
140
+
141
+ return {
142
+ block: true,
143
+ blockReason: [
144
+ "🛡️ APort Policy Denied",
145
+ "",
146
+ `Policy: ${effectivePolicyName}`,
147
+ "",
148
+ "Reasons (OAP codes):",
149
+ reasonLines || ` • ${message}`,
150
+ "",
151
+ agentId
152
+ ? `To allow this action, update limits at aport.io (hosted passport: ${agentId})`
153
+ : `To allow this action, update limits in your passport: ${passportFile}`,
154
+ ].join("\n"),
155
+ };
337
156
  }
338
- const hint = isCommandNotAllowed
339
- ? "\nFor shell commands (cp, mkdir, npm, etc.), add them to limits.allowed_commands in your passport."
340
- : "";
341
- const passportHint = agentId
342
- ? `To allow this action, update limits at aport.io (hosted passport: ${agentId})`
343
- : `To allow this action, update limits in your passport: ${passportFile}`;
344
- const blockReason = [
345
- "🛡️ APort Policy Denied",
346
- "",
347
- `Policy: ${effectivePolicyName}`,
348
- "",
349
- "Reasons (OAP codes):",
350
- reasonLines || ` • ${message}`,
351
- "",
352
- passportHint,
353
- hint,
354
- ].join("\n");
355
- return {
356
- block: true,
357
- blockReason,
358
- reasons,
359
- };
360
- }
361
-
362
- const {
363
- reasons,
364
- codeList,
365
- lines: reasonLines,
366
- primaryMessage,
367
- } = formatReasons(decision);
368
- const reasonSummary =
369
- reasonLines || primaryMessage
370
- ? ["APort allowed", reasonLines || primaryMessage]
371
- .filter(Boolean)
372
- .join("\n")
373
- : undefined;
374
- const allowSummary = decisionLogSummary(
375
- effectiveToolName,
376
- effectivePolicyName,
377
- context,
378
- );
379
- log(`[${name}] ALLOW: ${effectiveToolName} - ${allowSummary}`);
380
- return {
381
- reasons: decision.reasons?.length ? decision.reasons : undefined,
382
- reasonSummary: reasonSummary || undefined,
383
- };
384
- } catch (error) {
385
- err(`[${name}] Error evaluating policy: ${error.message}`);
386
157
 
387
- if (failClosed) {
388
- // Fail closed - block on error
389
- return {
390
- block: true,
391
- blockReason: `🛡️ APort Policy Error (fail-closed)\n\nError: ${error.message}\n\nCheck configuration at plugins.entries.openclaw-aport.config`,
392
- };
393
- } else {
394
- // Fail open - allow on error (not recommended)
395
- warn(`[${name}] Allowing tool despite error (failClosed=false)`);
158
+ log(`[APort] ALLOW: ${effectiveToolName}`);
396
159
  return {};
397
- }
398
- }
399
- });
400
-
401
- /**
402
- * after_tool_call hook - Runs after successful tool execution
403
- * Optional: For audit logging
404
- */
405
- api.on("after_tool_call", async (event, ctx) => {
406
- const { toolName, params, result } = event;
407
- log(`[${name}] Tool completed: ${toolName}`);
408
- // Could log to audit trail here
409
- });
410
-
411
- log(`[${name}] Registered hooks: before_tool_call, after_tool_call`);
412
- }
413
-
414
- /**
415
- * Map OpenClaw tool names to APort policy names.
416
- * Exported for tests; used by before_tool_call to decide if we run guardrail and for API mode.
417
- */
418
- export function mapToolToPolicy(toolName) {
419
- // Normalize tool name
420
- const tool = toolName.toLowerCase();
421
-
422
- // Git/Code operations
423
- if (tool.match(/git\.(create_pr|merge|push|commit)/))
424
- return "code.repository.merge.v1";
425
- if (tool.startsWith("git.")) return "code.repository.merge.v1";
426
-
427
- // System commands / exec (include bare "exec" - OpenClaw may send this for run-command tools)
428
- if (tool === "exec") return "system.command.execute.v1";
429
- if (tool.match(/exec\.(run|shell)/)) return "system.command.execute.v1";
430
- if (tool.startsWith("exec.")) return "system.command.execute.v1";
431
- if (tool.startsWith("system.command.")) return "system.command.execute.v1";
432
- if (tool === "bash" || tool === "shell" || tool === "command")
433
- return "system.command.execute.v1";
434
-
435
- // Messaging
436
- if (tool.startsWith("message.")) return "messaging.message.send.v1";
437
- if (tool.startsWith("messaging.")) return "messaging.message.send.v1";
438
- if (tool.match(/sms|whatsapp|slack|email/))
439
- return "messaging.message.send.v1";
440
-
441
- // File operations
442
- if (tool === "read") return "data.file.read.v1";
443
- if (tool.startsWith("file.read")) return "data.file.read.v1";
444
- if (tool.startsWith("data.file.read")) return "data.file.read.v1";
445
- if (tool === "write" || tool === "edit") return "data.file.write.v1";
446
- if (tool.startsWith("file.write")) return "data.file.write.v1";
447
- if (tool.startsWith("file.edit")) return "data.file.write.v1";
448
- if (tool.startsWith("data.file.write")) return "data.file.write.v1";
449
-
450
- // MCP tools
451
- if (tool.startsWith("mcp.")) return "mcp.tool.execute.v1";
452
-
453
- // Agent sessions
454
- if (tool.match(/agent\.session|session\.create/))
455
- return "agent.session.create.v1";
456
- if (tool.startsWith("session.")) return "agent.session.create.v1";
457
-
458
- // Tool registration
459
- if (tool.match(/agent\.tool|tool\.register/)) return "agent.tool.register.v1";
460
-
461
- // Financial operations
462
- if (tool.match(/payment\.refund|refund/)) return "finance.payment.refund.v1";
463
- if (tool.match(/payment\.charge|charge/)) return "finance.payment.charge.v1";
464
- if (tool.startsWith("finance.")) return "finance.payment.refund.v1";
465
-
466
- // Data operations
467
- if (tool.match(/database\.(write|insert|update|delete)/))
468
- return "data.export.create.v1";
469
- if (tool.match(/data\.export|export/)) return "data.export.create.v1";
470
-
471
- // No mapping found
472
- return null;
473
- }
474
-
475
- /**
476
- * Canonicalize object for hashing (sort keys at every level, like jq -c -S).
477
- * Must match guardrail script's jq --sort-keys output so content_hash verifies.
478
- * Exported for tests.
479
- */
480
- export function canonicalize(obj) {
481
- if (obj === null || typeof obj !== "object") return JSON.stringify(obj);
482
- if (Array.isArray(obj)) return "[" + obj.map(canonicalize).join(",") + "]";
483
- const keys = Object.keys(obj).sort();
484
- const parts = keys.map((k) => JSON.stringify(k) + ":" + canonicalize(obj[k]));
485
- return "{" + parts.join(",") + "}";
486
- }
487
-
488
- /**
489
- * Verify local decision file integrity (content_hash). Returns true if valid or no hash (legacy).
490
- * If the file was edited or moved, the hash will not match. Exported for tests.
491
- */
492
- export function verifyDecisionIntegrity(decision) {
493
- if (!decision || !decision.content_hash) return true;
494
- const { content_hash, ...rest } = decision;
495
- const canonical = canonicalize(rest);
496
- const computed =
497
- "sha256:" + createHash("sha256").update(canonical, "utf8").digest("hex");
498
- return computed === content_hash;
499
- }
500
-
501
- /**
502
- * Verify action via local guardrail script.
503
- * toolName must match the script's case patterns (e.g. system.command.execute, messaging.message.send); the plugin passes the policy-derived name (policy id without .v1) so "exec" is not passed (script would treat it as unknown).
504
- * Decisions are written under config dir (decisions/) with content_hash and chain (prev_*);
505
- * they are left for audit and are tamper-resistant (edit or reorder breaks verification).
506
- */
507
- async function verifyViaScript(
508
- toolName,
509
- params,
510
- { guardrailScript, passportFile },
511
- ) {
512
- const contextJson = JSON.stringify(params);
513
- // Unique decision file per invocation — no cache, no reuse. We only read the file we pass here.
514
- const configDir = dirname(passportFile);
515
- const decisionsDir = join(configDir, "decisions");
516
- await mkdir(decisionsDir, { recursive: true });
517
- const decisionFile = join(
518
- decisionsDir,
519
- `${Date.now()}-${Math.random().toString(36).slice(2, 10)}.json`,
520
- );
521
-
522
- return new Promise((resolve, reject) => {
523
- const proc = spawn(guardrailScript, [toolName, contextJson], {
524
- env: {
525
- ...process.env,
526
- OPENCLAW_PASSPORT_FILE: passportFile,
527
- OPENCLAW_DECISION_FILE: decisionFile,
528
- OPENCLAW_AUDIT_LOG: join(configDir, "audit.log"),
529
- },
530
- });
531
-
532
- let stdout = "";
533
- let stderr = "";
534
-
535
- proc.stdout.on("data", (data) => (stdout += data));
536
- proc.stderr.on("data", (data) => (stderr += data));
537
-
538
- proc.on("close", async (code) => {
539
- // Always read the decision file we passed to this invocation only (script writes to it before exit).
540
- try {
541
- const decisionData = await readFile(decisionFile, "utf8");
542
- const decision = JSON.parse(decisionData);
543
- resolve(decision);
544
- } catch (err) {
545
- if (code === 0) {
546
- resolve({ allow: true });
547
- } else {
548
- resolve({
549
- allow: false,
550
- reasons: [
551
- { message: stderr || `Tool ${toolName} denied (exit ${code})` },
552
- ],
553
- });
160
+ } catch (error) {
161
+ err(`[APort] Error evaluating policy: ${error.message}`);
162
+ if (failClosed) {
163
+ return {
164
+ block: true,
165
+ blockReason: `🛡️ APort Policy Error (fail-closed)\n\nError: ${error.message}\n\nCheck configuration at plugins.entries.openclaw-aport.config`,
166
+ };
554
167
  }
168
+ warn("[APort] Allowing tool despite error (failClosed=false)");
169
+ return {};
555
170
  }
556
171
  });
557
172
 
558
- proc.on("error", (error) => {
559
- reject(new Error(`Failed to run guardrail script: ${error.message}`));
560
- });
561
- });
562
- }
173
+ log("[APort] Registered hooks: before_tool_call");
174
+ },
175
+ });
563
176
 
564
- /** Generate an idempotency key (10–64 chars, alphanumeric + hyphen/underscore) for API requests that require it. */
565
177
  function ensureIdempotencyKey(context) {
566
178
  if (context && context.idempotency_key) return context;
567
179
  const ts = Date.now().toString(36);
568
- const r = Math.random().toString(36).slice(2, 10);
569
- const key = `idem_${ts}_${r}`.slice(0, 64);
570
- return { ...context, idempotency_key: key };
180
+ const rand = Math.random().toString(36).slice(2, 10);
181
+ return {
182
+ ...context,
183
+ idempotency_key: `idem_${ts}_${rand}`.slice(0, 64),
184
+ };
571
185
  }
572
186
 
573
- /**
574
- * Verify action via APort API
575
- * When agentId is set (hosted passport), API fetches passport from registry; no passport file.
576
- */
577
- async function verifyViaAPI(
578
- policyName,
579
- params,
580
- { apiUrl, apiKey, passportFile, agentId },
581
- ) {
582
- try {
583
- const context = ensureIdempotencyKey(params);
584
-
585
- const url = `${apiUrl}/api/verify/policy/${policyName}`;
586
- const headers = {
587
- "Content-Type": "application/json",
588
- };
589
- if (apiKey) {
590
- headers["Authorization"] = `Bearer ${apiKey}`;
591
- }
592
-
593
- let body;
594
- if (agentId) {
595
- body = JSON.stringify({
596
- context: { agent_id: agentId, ...context },
597
- });
598
- } else {
599
- const passportData = await readFile(passportFile, "utf8");
600
- const passport = JSON.parse(passportData);
601
- body = JSON.stringify({
602
- passport,
603
- context,
604
- });
605
- }
606
-
607
- const response = await fetch(url, {
608
- method: "POST",
609
- headers,
610
- body,
611
- });
612
-
613
- if (!response.ok) {
614
- throw new Error(
615
- `API request failed: ${response.status} ${response.statusText}`,
616
- );
617
- }
187
+ function expandPath(value) {
188
+ if (value.startsWith("~/")) return join(homedir(), value.slice(2));
189
+ return value;
190
+ }
618
191
 
619
- const data = await response.json();
620
- return data.decision || data;
621
- } catch (error) {
622
- throw new Error(`API verification failed: ${error.message}`);
623
- }
192
+ function extractContextSummary(context) {
193
+ if (typeof context?.command === "string" && context.command) return context.command;
194
+ if (typeof context?.file_path === "string" && context.file_path) return context.file_path;
195
+ if (typeof context?.recipient === "string" && context.recipient) return context.recipient;
196
+ if (typeof context?.url === "string" && context.url) return context.url;
197
+ return undefined;
624
198
  }
625
199
 
626
- /**
627
- * Expand ~ to home directory
628
- */
629
- function expandPath(path) {
630
- if (path.startsWith("~/")) {
631
- return join(homedir(), path.slice(2));
200
+ function parseGuardrailInvocation(command) {
201
+ if (typeof command !== "string" || !command.includes("aport-guardrail")) return null;
202
+ const match = command.match(/aport-guardrail[^\s]*\s+(\S+)\s+['"]([\s\S]*)['"]\s*$/);
203
+ if (!match) return null;
204
+ try {
205
+ return {
206
+ innerToolName: match[1],
207
+ innerContext: match[2].trim() ? JSON.parse(match[2]) : {},
208
+ };
209
+ } catch {
210
+ return null;
632
211
  }
633
- return path;
634
212
  }