@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.
- package/README.md +8 -3
- package/bin/openclaw +14 -14
- package/docs/PROVIDER.md +6 -6
- package/docs/QUICKSTART_OPENCLAW_PLUGIN.md +42 -408
- package/docs/RELEASE.md +3 -2
- package/docs/frameworks/openclaw.md +123 -39
- package/extensions/openclaw-aport/CHANGELOG.md +8 -2
- package/extensions/openclaw-aport/MIGRATION.md +22 -375
- package/extensions/openclaw-aport/README.md +72 -362
- package/extensions/openclaw-aport/api-client.js +22 -0
- package/extensions/openclaw-aport/audit.js +32 -0
- package/extensions/openclaw-aport/decision.js +21 -0
- package/extensions/openclaw-aport/index.js +169 -591
- package/extensions/openclaw-aport/local-evaluator.js +303 -0
- package/extensions/openclaw-aport/openclaw.plugin.json +5 -5
- package/extensions/openclaw-aport/package-lock.json +2 -2
- package/extensions/openclaw-aport/package.json +27 -6
- package/extensions/openclaw-aport/tool-mapping.js +89 -0
- package/package.json +1 -1
- package/extensions/openclaw-aport/index.ts +0 -547
- package/extensions/openclaw-aport/test.js +0 -356
|
@@ -2,633 +2,211 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* APort OpenClaw Plugin
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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 {
|
|
34
|
-
import {
|
|
35
|
-
import {
|
|
36
|
-
import {
|
|
37
|
-
import {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
config.
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
129
|
-
|
|
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
|
-
|
|
192
|
-
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
|
|
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
|
-
|
|
403
|
-
|
|
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
|
-
|
|
559
|
-
|
|
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
|
|
569
|
-
|
|
570
|
-
|
|
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
|
-
|
|
575
|
-
|
|
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
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
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
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
return
|
|
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
|
}
|